Repository: semaphoreui/semaphore Branch: develop Commit: 64749e880b1d Files: 722 Total size: 6.4 MB Directory structure: gitextract_0gdlbcvx/ ├── .codacy.yml ├── .cursorignore ├── .devcontainer/ │ ├── config-runner.json │ ├── config.json │ ├── devcontainer.json │ └── postCreateCommand.sh ├── .dockerignore ├── .dredd/ │ ├── dredd.docker.yml │ ├── dredd.local.yml │ ├── dredd.testing.yml │ ├── dredd.windows.yml │ ├── hooks/ │ │ ├── capabilities.go │ │ ├── helpers.go │ │ └── main.go │ └── server-wrapper.sh ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── documentation.yml │ │ ├── feature_request.yml │ │ ├── problem.yml │ │ └── question.yml │ ├── copilot-instructions.md │ └── workflows/ │ ├── community_beta.yml │ ├── community_release.yml │ ├── dev.yml │ ├── pro_selfhosted_beta.yml │ └── pro_selfhosted_release.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── .postman/ │ ├── api │ ├── api_4023cf7c-aabb-4d5a-a742-72dadbd4924a │ ├── api_5306c424-9fc0-4923-be37-fbda305ca8de │ ├── api_9a8524cc-4892-4b54-a6b3-1ef18d907626 │ └── postman/ │ └── collections/ │ └── Semaphore API.json ├── .vscode/ │ └── launch.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── TERRAFORM_ARGS_IMPROVEMENT.md ├── Taskfile.yml ├── api/ │ ├── api_test.go │ ├── apps.go │ ├── apps_test.go │ ├── auth.go │ ├── cache.go │ ├── debug/ │ │ ├── gc.go │ │ └── pprof.go │ ├── events.go │ ├── helpers/ │ │ ├── context.go │ │ ├── event_log.go │ │ ├── helpers.go │ │ ├── helpers_test.go │ │ ├── query_params.go │ │ ├── route_params.go │ │ └── write_response.go │ ├── integration.go │ ├── integration_test.go │ ├── login.go │ ├── login_test.go │ ├── options.go │ ├── projects/ │ │ ├── backup_restore.go │ │ ├── environment.go │ │ ├── integration.go │ │ ├── integration_alias.go │ │ ├── integration_extract_value.go │ │ ├── integration_matcher.go │ │ ├── inventory.go │ │ ├── inventory_test.go │ │ ├── keys.go │ │ ├── project.go │ │ ├── projects.go │ │ ├── repository.go │ │ ├── schedules.go │ │ ├── secret_storages.go │ │ ├── tasks.go │ │ ├── templates.go │ │ ├── users.go │ │ └── views.go │ ├── router.go │ ├── runners/ │ │ └── runners.go │ ├── runners.go │ ├── sockets/ │ │ ├── handler.go │ │ └── pool.go │ ├── system_info.go │ ├── tasks/ │ │ └── tasks.go │ ├── user.go │ └── users.go ├── api-docs.yml ├── cli/ │ ├── cmd/ │ │ ├── migrate.go │ │ ├── project.go │ │ ├── project_export.go │ │ ├── project_import.go │ │ ├── root.go │ │ ├── runner.go │ │ ├── runner_register.go │ │ ├── runner_setup.go │ │ ├── runner_start.go │ │ ├── runner_unregister.go │ │ ├── server.go │ │ ├── setup.go │ │ ├── syslog.go │ │ ├── syslog_windows.go │ │ ├── token.go │ │ ├── user.go │ │ ├── user_add.go │ │ ├── user_change.go │ │ ├── user_delete.go │ │ ├── user_get.go │ │ ├── user_list.go │ │ ├── user_totp.go │ │ ├── vault.go │ │ ├── vault_rekey.go │ │ └── version.go │ ├── main.go │ └── setup/ │ └── setup.go ├── db/ │ ├── APIToken.go │ ├── AccessKey.go │ ├── Alias.go │ ├── BackupEntity.go │ ├── Environment.go │ ├── Environment_test.go │ ├── Event.go │ ├── ExportEntityType.go │ ├── Integration.go │ ├── Inventory.go │ ├── Migration.go │ ├── Option.go │ ├── Project.go │ ├── ProjectInvite.go │ ├── ProjectInvite_test.go │ ├── ProjectStats.go │ ├── ProjectUser.go │ ├── ProjectUser_test.go │ ├── Repository.go │ ├── Repository_test.go │ ├── Role.go │ ├── Runner.go │ ├── Schedule.go │ ├── SecretStorage.go │ ├── Session.go │ ├── Store.go │ ├── Store_test.go │ ├── Task.go │ ├── TaskParams.go │ ├── Template.go │ ├── TemplateVault.go │ ├── Template_alias.go │ ├── TerraformInventoryAlias.go │ ├── TerraformInventoryState_pro.go │ ├── TerraformInventoryStore_pro.go │ ├── User.go │ ├── View.go │ ├── ansible.go │ ├── bolt/ │ │ ├── BoltDb.go │ │ ├── BoltDb_test.go │ │ ├── Task_test.go │ │ ├── access_key.go │ │ ├── environment.go │ │ ├── event.go │ │ ├── global_runner.go │ │ ├── global_runner_test.go │ │ ├── integrations.go │ │ ├── integrations_alias.go │ │ ├── inventory.go │ │ ├── migration.go │ │ ├── migration_2_10_12.go │ │ ├── migration_2_10_12_test.go │ │ ├── migration_2_10_16.go │ │ ├── migration_2_10_16_test.go │ │ ├── migration_2_10_24.go │ │ ├── migration_2_10_24_test.go │ │ ├── migration_2_10_33.go │ │ ├── migration_2_10_33_test.go │ │ ├── migration_2_14_7.go │ │ ├── migration_2_14_7_test.go │ │ ├── migration_2_17_0.go │ │ ├── migration_2_17_0_test.go │ │ ├── migration_2_17_2.go │ │ ├── migration_2_8_28.go │ │ ├── migration_2_8_28_test.go │ │ ├── migration_2_8_40.go │ │ ├── migration_2_8_40_test.go │ │ ├── migration_2_8_91.go │ │ ├── migration_2_8_91_test.go │ │ ├── option.go │ │ ├── option_test.go │ │ ├── project.go │ │ ├── project_invite.go │ │ ├── project_test.go │ │ ├── public_alias.go │ │ ├── repository.go │ │ ├── role.go │ │ ├── runner_pro.go │ │ ├── runner_pro_test.go │ │ ├── schedule.go │ │ ├── secret_storage.go │ │ ├── session.go │ │ ├── task.go │ │ ├── template.go │ │ ├── template_test.go │ │ ├── template_vault.go │ │ ├── template_vault_test.go │ │ ├── user.go │ │ ├── user_test.go │ │ ├── view.go │ │ └── view_test.go │ ├── config.go │ ├── config_test.go │ ├── factory/ │ │ └── store.go │ ├── migration/ │ │ └── migration.go │ └── sql/ │ ├── SqlDb.go │ ├── SqlDb_test.go │ ├── access_key.go │ ├── environment.go │ ├── event.go │ ├── global_runner.go │ ├── integration.go │ ├── integration_alias.go │ ├── inventory.go │ ├── migration.go │ ├── migration_2_10_24.go │ ├── migration_2_8_28.go │ ├── migration_2_8_42.go │ ├── migrations/ │ │ ├── v0.0.0.sql │ │ ├── v1.0.0.sql │ │ ├── v1.2.0.sql │ │ ├── v1.3.0.sql │ │ ├── v1.4.0.sql │ │ ├── v1.5.0.sql │ │ ├── v1.6.0.sql │ │ ├── v1.7.0.sql │ │ ├── v1.8.0.sql │ │ ├── v1.9.0.sql │ │ ├── v2.10.12.sql │ │ ├── v2.10.15.sql │ │ ├── v2.10.16.sql │ │ ├── v2.10.24.sql │ │ ├── v2.10.26.sql │ │ ├── v2.10.28.sql │ │ ├── v2.10.33.sql │ │ ├── v2.10.46.sql │ │ ├── v2.11.5.sql │ │ ├── v2.12.0.sql │ │ ├── v2.12.15.sql │ │ ├── v2.12.3.sql │ │ ├── v2.12.4.sql │ │ ├── v2.12.5.sql │ │ ├── v2.13.0.sql │ │ ├── v2.14.0.err.sql │ │ ├── v2.14.0.sql │ │ ├── v2.14.1.err.sql │ │ ├── v2.14.1.sql │ │ ├── v2.14.12.err.sql │ │ ├── v2.14.12.sql │ │ ├── v2.14.5.sql │ │ ├── v2.14.7.sql │ │ ├── v2.15.0.err.sql │ │ ├── v2.15.0.sql │ │ ├── v2.15.1.err.sql │ │ ├── v2.15.1.sql │ │ ├── v2.15.1.sqlite.sql │ │ ├── v2.15.2.err.sql │ │ ├── v2.15.2.sql │ │ ├── v2.15.3.err.sql │ │ ├── v2.15.3.sql │ │ ├── v2.15.4.err.sql │ │ ├── v2.15.4.sql │ │ ├── v2.16.0.err.sql │ │ ├── v2.16.0.sql │ │ ├── v2.16.1.err.sql │ │ ├── v2.16.1.sql │ │ ├── v2.16.2.err.sql │ │ ├── v2.16.2.sql │ │ ├── v2.16.3.err.sql │ │ ├── v2.16.3.sql │ │ ├── v2.16.50.err.sql │ │ ├── v2.16.50.sql │ │ ├── v2.16.8.err.sql │ │ ├── v2.16.8.sql │ │ ├── v2.17.0.err.sql │ │ ├── v2.17.0.sql │ │ ├── v2.17.1.err.sql │ │ ├── v2.17.1.sql │ │ ├── v2.17.15.err.sql │ │ ├── v2.17.15.sql │ │ ├── v2.17.2.err.sql │ │ ├── v2.17.2.sql │ │ ├── v2.2.1.sql │ │ ├── v2.3.0.sql │ │ ├── v2.3.1.sql │ │ ├── v2.3.2.sql │ │ ├── v2.4.0.sql │ │ ├── v2.5.0.sql │ │ ├── v2.5.2.sql │ │ ├── v2.7.1.sql │ │ ├── v2.7.10.sql │ │ ├── v2.7.12.sql │ │ ├── v2.7.13.sql │ │ ├── v2.7.4.sql │ │ ├── v2.7.6.sql │ │ ├── v2.7.8.sql │ │ ├── v2.7.9.sql │ │ ├── v2.8.0.sql │ │ ├── v2.8.1.sql │ │ ├── v2.8.20.sql │ │ ├── v2.8.25.sql │ │ ├── v2.8.26.sql │ │ ├── v2.8.36.sql │ │ ├── v2.8.38.sql │ │ ├── v2.8.39.sql │ │ ├── v2.8.40.sql │ │ ├── v2.8.42.sql │ │ ├── v2.8.51.sql │ │ ├── v2.8.57.sql │ │ ├── v2.8.58.sql │ │ ├── v2.8.7.sql │ │ ├── v2.8.8.sql │ │ ├── v2.8.91.sql │ │ ├── v2.9.100.sql │ │ ├── v2.9.46.sql │ │ ├── v2.9.6.sql │ │ ├── v2.9.60.sql │ │ ├── v2.9.61.sql │ │ ├── v2.9.62.sql │ │ ├── v2.9.70.sql │ │ └── v2.9.97.sql │ ├── option.go │ ├── project.go │ ├── project_invite.go │ ├── repository.go │ ├── role.go │ ├── runner.go │ ├── schedule.go │ ├── secret_storage.go │ ├── session.go │ ├── task.go │ ├── template.go │ ├── template_vault.go │ ├── user.go │ └── view.go ├── db_lib/ │ ├── AccessKeyInstaller.go │ ├── AnsibleApp.go │ ├── AnsiblePlaybook.go │ ├── AppFactory.go │ ├── CmdGitClient.go │ ├── GitClientFactory.go │ ├── GitRepository.go │ ├── GoGitClient.go │ ├── LocalApp.go │ ├── LocalApp_test.go │ ├── ShellApp.go │ └── TerraformApp.go ├── deployment/ │ ├── compose/ │ │ ├── README.md │ │ ├── dredd/ │ │ │ ├── base.yml │ │ │ ├── boltdb.yml │ │ │ ├── mariadb.yml │ │ │ ├── mysql.yml │ │ │ ├── postgres.yml │ │ │ └── sqlite.yml │ │ ├── runner/ │ │ │ ├── base.yml │ │ │ ├── build.yml │ │ │ └── config.yml │ │ ├── server/ │ │ │ ├── base.yml │ │ │ ├── build.yml │ │ │ └── config.yml │ │ └── store/ │ │ ├── boltdb.yml │ │ ├── local.yml │ │ ├── mariadb.yml │ │ ├── mysql.yml │ │ ├── postgres.yml │ │ └── sqlite.yml │ ├── docker/ │ │ ├── README.md │ │ ├── dredd/ │ │ │ ├── Dockerfile │ │ │ └── entrypoint │ │ ├── runner/ │ │ │ ├── Dockerfile │ │ │ ├── ansible.cfg │ │ │ ├── goss.yaml │ │ │ └── runner-wrapper │ │ └── server/ │ │ ├── Dockerfile │ │ ├── ansible.cfg │ │ ├── goss.yaml │ │ ├── powershell/ │ │ │ └── Dockerfile │ │ └── server-wrapper │ ├── packaging/ │ │ └── semaphore.spec │ └── systemd/ │ ├── README.md │ ├── env │ ├── runner.service │ ├── semaphore.service │ └── util/ │ ├── install.sh │ └── uninstall.sh ├── examples/ │ ├── authentik_ldap/ │ │ ├── .gitignore │ │ ├── README.md │ │ └── docker-compose.yml │ ├── openldap/ │ │ ├── README.md │ │ └── docker-compose.yml │ └── terraform_args_example.json ├── go.mod ├── go.sum ├── hook_helpers/ │ └── hooks_helpers.go ├── pkg/ │ ├── common_errors/ │ │ └── common_errors.go │ ├── conv/ │ │ └── conv.go │ ├── random/ │ │ └── string.go │ ├── ssh/ │ │ ├── agent.go │ │ └── agent_test.go │ ├── task_logger/ │ │ └── task_logger.go │ └── tz/ │ └── time.go ├── pro/ │ ├── api/ │ │ ├── auth_verify.go │ │ ├── projects/ │ │ │ ├── runners.go │ │ │ └── terraform_inventory.go │ │ ├── roles.go │ │ ├── subscriptions.go │ │ └── terraform.go │ ├── db/ │ │ ├── factory/ │ │ │ └── factory.go │ │ └── sql/ │ │ ├── ansible_task.go │ │ └── terraform_inventory.go │ ├── go.mod │ ├── go.sum │ ├── pkg/ │ │ ├── features/ │ │ │ └── features.go │ │ └── stage_parsers/ │ │ └── next_step.go │ └── services/ │ ├── ha/ │ │ └── ha.go │ ├── server/ │ │ ├── access_key_serializer_dvls.go │ │ ├── access_key_serializer_vault.go │ │ ├── log_write_svc.go │ │ ├── secret_storage_svc.go │ │ └── subscription_svc.go │ └── tasks/ │ └── task_state_store_factory.go ├── pro_interfaces/ │ ├── log_write_svc.go │ ├── project_runner_ctl.go │ ├── subscription_ctl.go │ ├── subscription_svc.go │ └── terraform_inventory_ctl.go ├── qodana.yaml ├── renovate.json ├── services/ │ ├── export/ │ │ ├── AccessKey.go │ │ ├── Environment.go │ │ ├── Event.go │ │ ├── Exporter.go │ │ ├── Integration.go │ │ ├── IntegrationAliases.go │ │ ├── IntegrationExtractValue.go │ │ ├── IntegrationMatcher.go │ │ ├── Inventory.go │ │ ├── Option.go │ │ ├── Project.go │ │ ├── ProjectUser.go │ │ ├── Repository.go │ │ ├── Role.go │ │ ├── Runner.go │ │ ├── Schedule.go │ │ ├── SecretStorage.go │ │ ├── Task.go │ │ ├── TaskOutput.go │ │ ├── TaskStage.go │ │ ├── TaskStageResult.go │ │ ├── Template.go │ │ ├── TemplateRoles.go │ │ ├── TemplateVault.go │ │ ├── User.go │ │ └── View.go │ ├── project/ │ │ ├── backup.go │ │ ├── backup_marshal.go │ │ ├── backup_marshal_test.go │ │ ├── backup_test.go │ │ ├── restore.go │ │ └── types.go │ ├── runners/ │ │ ├── job_pool.go │ │ ├── running_job.go │ │ └── types.go │ ├── schedules/ │ │ ├── SchedulePool.go │ │ └── SchedulePool_test.go │ ├── server/ │ │ ├── AccessKey_test.go │ │ ├── access_key_encryption_svc.go │ │ ├── access_key_installation_svc.go │ │ ├── access_key_serializer.go │ │ ├── access_key_serializer_local.go │ │ ├── access_key_svc.go │ │ ├── environment_svc.go │ │ ├── intergration_svc.go │ │ ├── inventory_svc.go │ │ ├── project_svc.go │ │ ├── project_svc_test.go │ │ └── secret_storage_svc.go │ ├── session_svc.go │ └── tasks/ │ ├── LocalJob.go │ ├── LocalJob_inventory.go │ ├── RemoteJob.go │ ├── TaskPool.go │ ├── TaskPool_test.go │ ├── TaskRunner.go │ ├── TaskRunner_logging.go │ ├── TaskRunner_test.go │ ├── alert.go │ ├── alert_test_sender.go │ ├── hooks/ │ │ ├── ansible.go │ │ ├── common.go │ │ └── factory.go │ ├── http_test.go │ ├── task_state_store.go │ └── templates/ │ ├── dingtalk.tmpl │ ├── email.tmpl │ ├── gotify.tmpl │ ├── microsoft-teams.tmpl │ ├── rocketchat.tmpl │ ├── slack.tmpl │ └── telegram.tmpl ├── test/ │ ├── e2e/ │ │ ├── .gitignore │ │ ├── package.json │ │ ├── playwright.config.ts │ │ └── tests/ │ │ ├── fixtures.ts │ │ ├── task.spec.ts │ │ └── variable-group.spec.ts │ └── mcp/ │ ├── api/ │ │ ├── AGENT.md │ │ ├── data/ │ │ │ └── case4/ │ │ │ └── test.sh │ │ ├── run.sh │ │ └── test_plan.md │ └── e2e/ │ ├── .gitignore │ ├── AGENT.md │ ├── package.json │ ├── playwright.config.ts │ ├── run.sh │ └── test_plan.md ├── util/ │ ├── App.go │ ├── OdbcProvider.go │ ├── ansi.go │ ├── config.go │ ├── config_assign_test.go │ ├── config_auth.go │ ├── config_sysproc.go │ ├── config_sysproc_windows.go │ ├── config_test.go │ ├── debug.go │ ├── encryption.go │ ├── errorLogging.go │ ├── mailer/ │ │ ├── auth.go │ │ └── mailer.go │ ├── shell.go │ ├── test_helpers.go │ └── version.go └── web/ ├── .browserslistrc ├── .editorconfig ├── .eslintrc.js ├── README.md ├── babel.config.js ├── gulp-gpt-translate.js ├── gulpfile.js ├── package.json ├── public/ │ ├── index.html │ ├── swagger/ │ │ ├── api-docs.yml │ │ ├── index.css │ │ ├── index.html │ │ ├── oauth2-redirect.html │ │ ├── swagger-initializer.js │ │ ├── swagger-ui-bundle.js │ │ ├── swagger-ui-es-bundle-core.js │ │ ├── swagger-ui-es-bundle.js │ │ ├── swagger-ui-standalone-preset.js │ │ ├── swagger-ui.css │ │ └── swagger-ui.js │ └── test.txt ├── src/ │ ├── App.vue │ ├── assets/ │ │ ├── fonts/ │ │ │ └── LICENSE.txt │ │ └── scss/ │ │ ├── components.scss │ │ └── main.scss │ ├── components/ │ │ ├── AboutDialog.vue │ │ ├── AnsibleStageView.vue │ │ ├── AppFieldsMixin.js │ │ ├── AppForm.vue │ │ ├── AppsMixin.js │ │ ├── ArgsPicker.vue │ │ ├── ChangePasswordForm.vue │ │ ├── CopyClipboardButton.vue │ │ ├── CronInput.vue │ │ ├── DashboardMenu.vue │ │ ├── DvlsIcon.vue │ │ ├── EditDialog.vue │ │ ├── EditRoleForm.vue │ │ ├── EditTeamMemberDialog.vue │ │ ├── EditTemplateDialog.vue │ │ ├── EditTemplatePermissionDialog.vue │ │ ├── EditTemplatePermissionForm.vue │ │ ├── EditViewsForm.vue │ │ ├── EnvironmentForm.vue │ │ ├── HashicorpVaultIcon.vue │ │ ├── IndeterminateProgressCircular.vue │ │ ├── IntegrationExtractValueForm.vue │ │ ├── IntegrationExtractorChildValueFormBase.js │ │ ├── IntegrationExtractorForm.vue │ │ ├── IntegrationExtractorFormBase.js │ │ ├── IntegrationExtractorRefsView.vue │ │ ├── IntegrationExtractorsBase.js │ │ ├── IntegrationForm.vue │ │ ├── IntegrationMatcherForm.vue │ │ ├── IntegrationRefsView.vue │ │ ├── InventoryForm.vue │ │ ├── InventorySelectForm.vue │ │ ├── ItemFormBase.js │ │ ├── ItemListPageBase.js │ │ ├── KeyForm.vue │ │ ├── KeyStoreMenu.vue │ │ ├── LineChart.vue │ │ ├── NewTaskDialog.vue │ │ ├── ObjectRefsDialog.vue │ │ ├── ObjectRefsView.vue │ │ ├── OpenTofuIcon.vue │ │ ├── PageBottomSheet.vue │ │ ├── PageMixin.js │ │ ├── PermissionsCheck.js │ │ ├── ProjectForm.vue │ │ ├── ProjectMixin.js │ │ ├── PulumiIcon.vue │ │ ├── RepositoryForm.vue │ │ ├── RestoreProjectForm.vue │ │ ├── RichEditor.vue │ │ ├── RunnerForm.vue │ │ ├── ScheduleForm.vue │ │ ├── SecretStorageForm.vue │ │ ├── SecretStorageSyncOptionsForm.vue │ │ ├── SingleLineEditable.vue │ │ ├── SubscriptionForm.vue │ │ ├── SubscriptionLabel.vue │ │ ├── SurveyVars.vue │ │ ├── SystemInfoDialog.vue │ │ ├── SystemSettingsDialog.vue │ │ ├── TableSettingsSheet.vue │ │ ├── TaskDetails.vue │ │ ├── TaskForm.vue │ │ ├── TaskLink.vue │ │ ├── TaskList.vue │ │ ├── TaskLogDialog.vue │ │ ├── TaskLogView.vue │ │ ├── TaskLogViewRecord.vue │ │ ├── TaskParamsAnsibleForm.vue │ │ ├── TaskParamsForm.vue │ │ ├── TaskParamsTerraformForm.vue │ │ ├── TaskStats.vue │ │ ├── TaskStatus.vue │ │ ├── TeamMemberForm.vue │ │ ├── TeamMenu.vue │ │ ├── TemplateForm.vue │ │ ├── TemplatePermissionsChips.vue │ │ ├── TemplateSelectForm.vue │ │ ├── TemplateVaults.vue │ │ ├── TerraformAliasForm.vue │ │ ├── TerraformInventoryForm.vue │ │ ├── TerraformStateView.vue │ │ ├── TerragruntIcon.vue │ │ ├── UserForm.vue │ │ ├── YesNoDialog.vue │ │ └── chartjs-adapter-day.js │ ├── event-bus.js │ ├── lang/ │ │ ├── de.js │ │ ├── en.js │ │ ├── es.js │ │ ├── fr.js │ │ ├── index.js │ │ ├── it.js │ │ ├── ja.js │ │ ├── ko.js │ │ ├── nl.js │ │ ├── pl.js │ │ ├── pt.js │ │ ├── pt_br.js │ │ ├── ru.js │ │ ├── uk.js │ │ ├── zh_cn.js │ │ └── zh_tw.js │ ├── lib/ │ │ ├── FakeWebSocket.js │ │ ├── Listenable.js │ │ ├── PubSub.js │ │ ├── Socket.js │ │ ├── api.js │ │ ├── constants.js │ │ ├── copyToClipboard.js │ │ ├── delay.js │ │ └── error.js │ ├── main.js │ ├── plugins/ │ │ ├── i18.js │ │ └── vuetify.js │ ├── router/ │ │ └── index.js │ ├── scss/ │ │ └── variables.scss │ ├── socket.js │ └── views/ │ ├── AcceptInvite.vue │ ├── Apps.vue │ ├── Auth.vue │ ├── Options.vue │ ├── Roles.vue │ ├── Runners.vue │ ├── Tasks.vue │ ├── Tokens.vue │ ├── Users.vue │ └── project/ │ ├── Activity.vue │ ├── Environment.vue │ ├── History.vue │ ├── IntegrationExtractValue.vue │ ├── IntegrationExtractor.vue │ ├── IntegrationExtractorCrumb.vue │ ├── IntegrationMatcher.vue │ ├── Integrations.vue │ ├── IntegrationsBase.js │ ├── Inventory.vue │ ├── Invites.vue │ ├── Keys.vue │ ├── New.vue │ ├── Repositories.vue │ ├── RestoreProject.vue │ ├── Schedule.vue │ ├── SecretStorages.vue │ ├── Settings.vue │ ├── Stats.vue │ ├── Team.vue │ ├── TemplateView.vue │ ├── Templates.vue │ └── template/ │ ├── TemplateDetails.vue │ ├── TemplatePerms.vue │ └── TemplateTerraformState.vue ├── tests/ │ └── unit/ │ ├── example.spec.js │ └── lib/ │ ├── Listenable.spec.js │ ├── Socket.spec.js │ └── error.spec.js └── vue.config.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .codacy.yml ================================================ --- exclude_paths: - .dredd/** ================================================ FILE: .cursorignore ================================================ /tests/e2e/ ================================================ FILE: .devcontainer/config-runner.json ================================================ { "web_host": "http://localhost:3000", "runner": { "token_file": "/home/codespace/.semaphore-runner-token" } } ================================================ FILE: .devcontainer/config.json ================================================ { "bolt": { "host": "/workspaces/semaphore/database.boltdb", "options": { "sessionConnection": "false" } }, "dialect": "bolt", "cookie_hash": "5WJjXCLpvf3Cn5t+C/IV9F0asZUQLakOhCT+eSdIwP0=", "cookie_encryption": "6x6mmQWGn6YcsHN1rN0HiQjhYA+7HukcbCxUGHuT2CE=" } ================================================ FILE: .devcontainer/devcontainer.json ================================================ { "image": "mcr.microsoft.com/devcontainers/universal:4", "features": { "ghcr.io/devcontainers/features/go:1": {}, "ghcr.io/devcontainers/features/node:1": {} }, "postCreateCommand": "./.devcontainer/postCreateCommand.sh" } ================================================ FILE: .devcontainer/postCreateCommand.sh ================================================ #!/bin/sh go install github.com/go-task/task/v3/cmd/task@latest (cd ./web && npm install) python3 -m venv .venv ./.venv/bin/pip3 install ansible task build task dredd:goodman task dredd:hooks cp ./.devcontainer/config.json ./.dredd/config.json ./bin/semaphore user add \ --admin \ --login admin \ --name Admin \ --email admin@example.com \ --password changeme \ --config ./.devcontainer/config.json ================================================ FILE: .dockerignore ================================================ web/node_modules/ vendor/ ================================================ FILE: .dredd/dredd.docker.yml ================================================ dry-run: null hookfiles: ./.dredd/compiled_hooks language: go server-wait: 5 init: false custom: {} names: false only: [] reporter: [] output: [] header: "Authorization: bearer h4a_i4qslpnxyyref71rk5nqbwxccrs7enwvggx0vfs=" sorted: false user: null inline-errors: false details: false method: [] color: true loglevel: debug path: [] hooks-worker-timeout: 5000 hooks-worker-connect-timeout: 1500 hooks-worker-connect-retry: 500 hooks-worker-after-connect-wait: 100 hooks-worker-term-timeout: 5000 hooks-worker-term-retry: 500 hooks-worker-handler-host: 0.0.0.0 hooks-worker-handler-port: 61321 config: ./.dredd/dredd.yml blueprint: api-docs.yml endpoint: 'http://server:3000' ================================================ FILE: .dredd/dredd.local.yml ================================================ dry-run: null hookfiles: ./.dredd/compiled_hooks language: go server-wait: 5 init: false custom: {} names: false only: [] reporter: [] output: [] header: "Authorization: bearer h4a_i4qslpnxyyref71rk5nqbwxccrs7enwvggx0vfs=" sorted: false user: null inline-errors: false details: false method: [] color: true loglevel: debug path: [] hooks-worker-timeout: 5000 hooks-worker-connect-timeout: 1500 hooks-worker-connect-retry: 500 hooks-worker-after-connect-wait: 100 hooks-worker-term-timeout: 5000 hooks-worker-term-retry: 500 hooks-worker-handler-host: 0.0.0.0 hooks-worker-handler-port: 61321 config: ./.dredd/dredd.yml blueprint: api-docs.yml endpoint: 'http://localhost:3000' ================================================ FILE: .dredd/dredd.testing.yml ================================================ dry-run: null hookfiles: ./.dredd/compiled_hooks language: go server: ./.dredd/server-wrapper.sh server-wait: 5 init: false custom: {} names: false only: [] reporter: [] output: [] header: "Authorization: bearer h4a_i4qslpnxyyref71rk5nqbwxccrs7enwvggx0vfs=" sorted: false user: null inline-errors: false details: false method: [] color: true loglevel: debug path: [] hooks-worker-timeout: 5000 hooks-worker-connect-timeout: 1500 hooks-worker-connect-retry: 500 hooks-worker-after-connect-wait: 100 hooks-worker-term-timeout: 5000 hooks-worker-term-retry: 500 hooks-worker-handler-host: 0.0.0.0 hooks-worker-handler-port: 61321 config: ./.dredd/dredd.yml blueprint: api-docs.yml endpoint: 'http://localhost:3000' ================================================ FILE: .dredd/dredd.windows.yml ================================================ dry-run: null hookfiles: ./.dredd/compiled_hooks.exe language: go server-wait: 5 init: false custom: {} names: false only: [] reporter: [] output: [] header: "Authorization: bearer h4a_i4qslpnxyyref71rk5nqbwxccrs7enwvggx0vfs=" sorted: false user: null inline-errors: false details: false method: [] color: true loglevel: debug path: [] hooks-worker-timeout: 5000 hooks-worker-connect-timeout: 1500 hooks-worker-connect-retry: 500 hooks-worker-after-connect-wait: 100 hooks-worker-term-timeout: 5000 hooks-worker-term-retry: 500 hooks-worker-handler-host: 0.0.0.0 hooks-worker-handler-port: 61321 config: ./.dredd/dredd.yml blueprint: api-docs.yml endpoint: 'http://localhost:3000' ================================================ FILE: .dredd/hooks/capabilities.go ================================================ package main import ( "encoding/json" "regexp" "strconv" "strings" "github.com/semaphoreui/semaphore/db" trans "github.com/snikch/goodman/transaction" ) // STATE // Runtime created objects we need to reference in test setups var testRunnerUser *db.User var userPathTestUser *db.User var userProject *db.Project var userKey *db.AccessKey var task *db.Task var schedule *db.Schedule var view *db.View var integration *db.Integration var integrationextractvalue *db.IntegrationExtractValue var integrationmatch *db.IntegrationMatcher var invite *db.ProjectInvite // Runtime created simple ID values for some items we need to reference in other objects var repoID int var inventoryID int var environmentID int var templateID int var integrationID int var integrationExtractValueID int var integrationMatchID int var capabilities = map[string][]string{ "user": {}, "project": {"user"}, "repository": {"access_key"}, "inventory": {"repository"}, "environment": {"repository"}, "template": {"repository", "inventory", "environment", "view"}, "task": {"template"}, "schedule": {"template"}, "view": {}, "integration": {"project", "template"}, "integrationextractvalue": {"integration"}, "integrationmatcher": {"integration"}, "invite": {"user", "project"}, } func capabilityWrapper(cap string) func(t *trans.Transaction) { return func(t *trans.Transaction) { addCapabilities([]string{cap}) } } func addCapabilities(caps []string) { dbConnect() defer store.Close("") resolved := make([]string, 0) uid := getUUID() resolveCapability(caps, resolved, uid) } func resolveCapability(caps []string, resolved []string, uid string) { for _, v := range caps { //if cap has deps resolve them if val, ok := capabilities[v]; ok { resolveCapability(val, resolved, uid) } //skip if already resolved if _, exists := stringInSlice(v, resolved); exists { continue } //Add dep specific stuff switch v { case "invite": invite = addInvite() case "schedule": schedule = addSchedule() case "view": view = addView() case "user": userPathTestUser = addUser() case "project": userProject = addProject() //allow the admin user (test executor) to manipulate the project addUserProjectRelation(userProject.ID, testRunnerUser.ID) addUserProjectRelation(userProject.ID, userPathTestUser.ID) case "access_key": userKey = addAccessKey(&userProject.ID) case "repository": pRepo, err := store.CreateRepository(db.Repository{ ProjectID: userProject.ID, GitURL: "git@github.com/ansible,semaphore/semaphore", GitBranch: "develop", SSHKeyID: userKey.ID, Name: "ITR-" + uid, }) printError(err) repoID = pRepo.ID case "inventory": res, err := store.CreateInventory(db.Inventory{ ProjectID: userProject.ID, Name: "ITI-" + uid, Type: "static", SSHKeyID: &userKey.ID, BecomeKeyID: &userKey.ID, Inventory: "Test Inventory", RepositoryID: &repoID, }) printError(err) inventoryID = res.ID case "environment": pwd := "test-pass" env := "{}" secret := db.EnvironmentSecret{ Type: db.EnvironmentSecretEnv, Name: "TEST", Secret: "VALUE", Operation: "create", } res, err := store.CreateEnvironment(db.Environment{ ProjectID: userProject.ID, Name: "ITI-" + uid, JSON: "{}", Password: &pwd, ENV: &env, }) printError(err) _, err = store.CreateAccessKey(db.AccessKey{ String: secret.Secret, EnvironmentID: &res.ID, ProjectID: &userProject.ID, Type: db.AccessKeyString, Owner: secret.Type.GetAccessKeyOwner(), }) printError(err) environmentID = res.ID case "template": args := "[]" desc := "Hello, World!" branch := "main" res, err := store.CreateTemplate(db.Template{ ProjectID: userProject.ID, InventoryID: &inventoryID, RepositoryID: repoID, EnvironmentID: &environmentID, Name: "Test-" + uid, Playbook: "test-playbook.yml", Arguments: &args, AllowOverrideArgsInTask: false, Description: &desc, ViewID: &view.ID, App: db.AppAnsible, GitBranch: &branch, SurveyVars: []db.SurveyVar{}, }) printError(err) templateID = res.ID case "task": task = addTask() case "integration": integration = addIntegration() integrationID = integration.ID case "integrationextractvalue": integrationextractvalue = addIntegrationExtractValue() integrationExtractValueID = integrationextractvalue.ID case "integrationmatcher": integrationmatch = addIntegrationMatcher() integrationMatchID = integrationmatch.ID default: panic("unknown capability " + v) } resolved = append(resolved, v) } } // HOOKS var skipTest = func(t *trans.Transaction) { t.Skip = true } // Contains all the substitutions for paths under test // The parameter example value in the api-doc should respond to the index+1 of the function in this slice // ie the project id, with example value 1, will be replaced by the return value of pathSubPatterns[0] var pathSubPatterns = []func() string{ func() string { return strconv.Itoa(userProject.ID) }, func() string { return strconv.Itoa(userPathTestUser.ID) }, func() string { return strconv.Itoa(userKey.ID) }, func() string { return strconv.Itoa(repoID) }, func() string { return strconv.Itoa(inventoryID) }, func() string { return strconv.Itoa(environmentID) }, func() string { return strconv.Itoa(templateID) }, func() string { return strconv.Itoa(task.ID) }, func() string { return strconv.Itoa(schedule.ID) }, func() string { return strconv.Itoa(view.ID) }, func() string { return strconv.Itoa(integration.ID) }, func() string { return strconv.Itoa(integrationextractvalue.ID) }, func() string { return strconv.Itoa(integrationmatch.ID) }, func() string { return strconv.Itoa(invite.ID) }, // invite_id, x-example: 14 } // alterRequestPath with the above slice of functions func alterRequestPath(t *trans.Transaction) { pathArgs := strings.Split(t.FullPath, "/") exploded := make([]string, len(pathArgs)) copy(exploded, pathArgs) for k, v := range pathSubPatterns { pos, exists := stringInSlice(strconv.Itoa(k+1), exploded) if exists { pathArgs[pos] = v() } } t.FullPath = strings.Join(pathArgs, "/") t.Request.URI = t.FullPath } func alterRequestBody(t *trans.Transaction) { var request map[string]interface{} json.Unmarshal([]byte(t.Request.Body), &request) if userProject != nil { bodyFieldProcessor("project_id", userProject.ID, &request) } bodyFieldProcessor("json", "{}", &request) if userKey != nil { bodyFieldProcessor("ssh_key_id", userKey.ID, &request) bodyFieldProcessor("become_key_id", userKey.ID, &request) } if invite != nil { bodyFieldProcessor("invite_id", 4, &request) } bodyFieldProcessor("environment_id", environmentID, &request) bodyFieldProcessor("inventory_id", inventoryID, &request) bodyFieldProcessor("repository_id", repoID, &request) bodyFieldProcessor("template_id", templateID, &request) bodyFieldProcessor("build_template_id", nil, &request) if task != nil { bodyFieldProcessor("task_id", task.ID, &request) } if schedule != nil { bodyFieldProcessor("schedule_id", schedule.ID, &request) } if view != nil { bodyFieldProcessor("view_id", view.ID, &request) } if integration != nil { bodyFieldProcessor("integration_id", integration.ID, &request) } if integrationextractvalue != nil { bodyFieldProcessor("value_id", integrationextractvalue.ID, &request) } if integrationmatch != nil { bodyFieldProcessor("matcher_id", integrationmatch.ID, &request) } // Inject object ID to body for PUT requests if strings.ToLower(t.Request.Method) == "put" { putRequestPathRE := regexp.MustCompile(`\w+/(\d+)/?$`) m := putRequestPathRE.FindStringSubmatch(t.FullPath) if len(m) > 0 { objectID, err := strconv.Atoi(m[1]) if err != nil { panic("Invalid object ID in PUT request " + t.FullPath) } request["id"] = objectID } else { panic("Unexpected PUT request " + t.FullPath) } } out, _ := json.Marshal(request) t.Request.Body = string(out) } func bodyFieldProcessor(id string, sub interface{}, request *map[string]interface{}) { if _, ok := (*request)[id]; ok { (*request)[id] = sub } } ================================================ FILE: .dredd/hooks/helpers.go ================================================ package main import ( "encoding/json" "fmt" "os" "github.com/semaphoreui/semaphore/pkg/tz" "github.com/go-gorp/gorp/v3" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/db/bolt" "github.com/semaphoreui/semaphore/db/factory" "github.com/semaphoreui/semaphore/db/sql" "github.com/semaphoreui/semaphore/pkg/random" "github.com/semaphoreui/semaphore/util" "github.com/snikch/goodman/transaction" ) // Test Runner User func addTestRunnerUser() { uid := getUUID() testRunnerUser = &db.User{ Username: "ITU-" + uid, Name: "ITU-" + uid, Email: uid + "@semaphore.test", Created: db.GetParsedTime(tz.Now()), Admin: true, } dbConnect() defer store.Close("") truncateAll() newUser, err := store.CreateUserWithoutPassword(*testRunnerUser) if err != nil { panic(err) } testRunnerUser.ID = newUser.ID addToken(adminToken, testRunnerUser.ID) } func truncateAll() { var tablesShouldBeTruncated = [...]string{ "access_key", "event", "user__token", "project", "task__output", "task", "session", "project__environment", "project__inventory", "project__repository", "project__template", "project__template_vault", "project__schedule", "project__user", "user", "project__view", "project__integration", "project__integration_extract_value", "project__integration_matcher", } switch store.(type) { case *bolt.BoltDb: // Do nothing case *sql.SqlDb: switch store.(*sql.SqlDb).Sql().Dialect.(type) { case gorp.PostgresDialect: // Do nothing case gorp.MySQLDialect: tx, err := store.(*sql.SqlDb).Sql().Begin() if err != nil { panic(err) } _, err = tx.Exec("SET FOREIGN_KEY_CHECKS = 0") if err == nil { for _, tableName := range tablesShouldBeTruncated { tx.Exec("TRUNCATE TABLE " + tableName) } tx.Exec("SET FOREIGN_KEY_CHECKS = 1") } if err := tx.Commit(); err != nil { panic(err) } } } } func removeTestRunnerUser(transactions []*transaction.Transaction) { dbConnect() defer store.Close("") _ = store.DeleteAPIToken(testRunnerUser.ID, adminToken) _ = store.DeleteUser(testRunnerUser.ID) } // Parameter Substitution func setupObjectsAndPaths(t *transaction.Transaction) { alterRequestPath(t) alterRequestBody(t) } // Object Lifecycle func addUserProjectRelation(pid int, user int) { _, err := store.CreateProjectUser(db.ProjectUser{ ProjectID: pid, UserID: user, Role: db.ProjectOwner, }) if err != nil { panic(err) } } func deleteUserProjectRelation(pid int, user int) { err := store.DeleteProjectUser(pid, user) if err != nil { panic(err) } } func addAccessKey(pid *int) *db.AccessKey { uid := getUUID() secret := "5up3r53cr3t\n" key, err := store.CreateAccessKey(db.AccessKey{ Name: "ITK-" + uid, Type: "ssh", Secret: &secret, ProjectID: pid, }) if err != nil { panic(err) } return &key } func addProject() *db.Project { uid := getUUID() chat := "Test" project := db.Project{ Name: "ITP-" + uid, Created: tz.Now(), AlertChat: &chat, } project, err := store.CreateProject(project) if err != nil { panic(err) } err = store.UpdateProject(project) if err != nil { panic(err) } return &project } func addUser() *db.User { uid := getUUID() user := db.User{ Created: tz.Now(), Username: "ITU-" + uid, Email: "test@semaphore." + uid, Name: "ITU-" + uid, } user, err := store.CreateUserWithoutPassword(user) if err != nil { panic(err) } return &user } func addView() *db.View { view, err := store.CreateView(db.View{ ProjectID: userProject.ID, Title: "Test", Position: 1, }) if err != nil { panic(err) } return &view } func addInvite() *db.ProjectInvite { invite, err := store.CreateProjectInvite(db.ProjectInvite{ ProjectID: userProject.ID, UserID: &userPathTestUser.ID, Email: &userPathTestUser.Email, Role: "owner", Status: db.ProjectInvitePending, Token: getUUID(), InviterUserID: testRunnerUser.ID, Created: tz.Now(), ExpiresAt: nil, // No expiration for this test AcceptedAt: nil, }) fmt.Println("***************************************") fmt.Println("***************************************") fmt.Println("***************************************") fmt.Println(invite.ID) fmt.Println("***************************************") fmt.Println("***************************************") fmt.Println("***************************************") if err != nil { panic(err) } return &invite } func addSchedule() *db.Schedule { schedule, err := store.CreateSchedule(db.Schedule{ TemplateID: int(templateID), CronFormat: "* * * 1 *", ProjectID: userProject.ID, }) if err != nil { panic(err) } return &schedule } func addTask() *db.Task { t := db.Task{ ProjectID: userProject.ID, TemplateID: templateID, Status: "testing", UserID: &userPathTestUser.ID, Created: db.GetParsedTime(tz.Now()), } t, err := store.CreateTask(t, 0) if err != nil { fmt.Println("error during insertion of task:") if j, e := json.Marshal(t); e == nil { fmt.Println(string(j)) } else { fmt.Println("can not stringify task object") } panic(err) } return &t } func addIntegration() *db.Integration { integration, err := store.CreateIntegration(db.Integration{ ProjectID: userProject.ID, Name: "Test Integration", TemplateID: templateID, }) if err != nil { panic(err) } return &integration } func addIntegrationExtractValue() *db.IntegrationExtractValue { integrationextractvalue, err := store.CreateIntegrationExtractValue(userProject.ID, db.IntegrationExtractValue{ Name: "Value", IntegrationID: integrationID, ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Key: "key", Variable: "var", VariableType: db.IntegrationVariableEnvironment, }) if err != nil { panic(err) } return &integrationextractvalue } func addIntegrationMatcher() *db.IntegrationMatcher { integrationmatch, err := store.CreateIntegrationMatcher(userProject.ID, db.IntegrationMatcher{ Name: "matcher", IntegrationID: integrationID, MatchType: "body", Method: "equals", BodyDataType: "json", Key: "key", Value: "value", }) if err != nil { panic(err) } return &integrationmatch } // Token Handling func addToken(tok string, user int) { _, err := store.CreateAPIToken(db.APIToken{ ID: tok, Created: tz.Now(), UserID: user, Expired: false, }) if err != nil { panic(err) } } // HELPERS var randSetup = false func getUUID() string { if !randSetup { randSetup = true } return random.String(8) } func loadConfig() { cwd, _ := os.Getwd() file, _ := os.Open(cwd + "/.dredd/config.json") if err := json.NewDecoder(file).Decode(&util.Config); err != nil { fmt.Println("Could not decode configuration!") panic(err) } } var store db.Store func dbConnect() { store = factory.CreateStore() store.Connect("") } func stringInSlice(a string, list []string) (int, bool) { for k, b := range list { if b == a { return k, true } } return 0, false } func printError(err error) { if err != nil { //fmt.Println(err) panic(err) } } ================================================ FILE: .dredd/hooks/main.go ================================================ package main import ( "strconv" "strings" "github.com/snikch/goodman/hooks" trans "github.com/snikch/goodman/transaction" ) const ( adminToken = "h4a_i4qslpnxyyref71rk5nqbwxccrs7enwvggx0vfs=" expiredToken = "kwofd61g93-yuqvex8efmhjkgnbxlo8mp1tin6spyhu=" ) var skipTests = []string{ // TODO - dredd seems not to like the text response from this endpoint "/api/ping > PING test > 200 > text/plain; charset=utf-8", "/api/ws > Websocket handler > 200 > application/json", "authentication > /api/auth/login > Performs Login > 204 > application/json", "authentication > /api/auth/logout > Destroys current session > 204 > application/json", "/project/{project_id}/notifications/test", //"/api/upgrade > Upgrade the server > 200 > application/json", // TODO - Skipping this while we work out how to get a 204 response from the api for testing //"/api/upgrade > Check if new updates available and fetch /info > 204 > application/json", } // Dredd expects that you have already set up the database and run all migrations before it begins. // It will NOT initialize the database, only insert its test data. // It does this in a way which ignores errors, which is fine on the ci, but might be an issue locally // so look at the logs carefully if these tests fail and if in doubt re-init the db // These hooks do NOT clean up after themselves and they produce a lot of database writes, // so don't run this in production func main() { h := hooks.NewHooks() server := hooks.NewServer(hooks.NewHooksRunner(h)) //Get database connection info and create an admin who's token is used to execute the tests h.BeforeAll(func(t []*trans.Transaction) { loadConfig() addTestRunnerUser() }) for _, v := range skipTests { h.Before(v, skipTest) } h.BeforeEach(func(t *trans.Transaction) { if strings.HasPrefix(t.Name, "user") { addCapabilities([]string{"user"}) } else if strings.HasPrefix(t.Name, "project") || strings.HasPrefix(t.Name, "projects") { addCapabilities([]string{"project"}) } }) h.Before("user > /api/user/tokens/{api_token_id} > Expires API token > 204 > application/json", func(transaction *trans.Transaction) { dbConnect() defer store.Close("") addToken(expiredToken, testRunnerUser.ID) }) h.After("user > /api/user/tokens/{api_token_id} > Expires API token > 204 > application/json", func(transaction *trans.Transaction) { dbConnect() defer store.Close("") //tokens are expired and not deleted so we need to clean up _ = store.DeleteAPIToken(testRunnerUser.ID, expiredToken) }) // This one seems to need some manual value setting in the body h.Before("user > /api/users/{user_id}/password > Updates user password > 204 > application/json", func(transaction *trans.Transaction) { transaction.Request.Body = "{\"password\":\"staub\"}" }) // delete the auto generated association and insert the user id into the query h.Before("project > /api/project/{project_id}/users > Link user to project > 204 > application/json", func(transaction *trans.Transaction) { dbConnect() defer store.Close("") deleteUserProjectRelation(userProject.ID, userPathTestUser.ID) transaction.Request.Body = "{ \"user_id\": " + strconv.Itoa(userPathTestUser.ID) + ",\"role\": \"owner\"}" }) h.Before("project > /api/project/{project_id}/invites > Get invitations for project > 200 > application/json", capabilityWrapper("invite")) h.Before("project > /api/project/{project_id}/invites > Create project invitation > 201 > application/json", capabilityWrapper("invite")) h.Before("project > /api/project/{project_id}/invites/{invite_id} > Get specific project invitation > 200 > application/json", capabilityWrapper("invite")) h.Before("project > /api/project/{project_id}/invites/{invite_id} > Update project invitation status > 204 > application/json", capabilityWrapper("invite")) h.Before("project > /api/project/{project_id}/invites/{invite_id} > Delete project invitation > 204 > application/json", capabilityWrapper("invite")) h.Before("integration > /api/project/{project_id}/integrations > get all integrations > 200 > application/json", capabilityWrapper("integration")) h.Before("integration > /api/project/{project_id}/integrations/{integration_id} > Get Integration > 200 > application/json", capabilityWrapper("integration")) h.Before("integration > /api/project/{project_id}/integrations/{integration_id} > Update Integration > 204 > application/json", capabilityWrapper("integration")) h.Before("integration > /api/project/{project_id}/integrations/{integration_id} > Remove integration > 204 > application/json", capabilityWrapper("integration")) h.Before("integration > /api/project/{project_id}/integrations/{integration_id}/values > Get Integration Extracted Values linked to integration extractor > 200 > application/json", capabilityWrapper("integrationextractvalue")) h.Before("integration > /api/project/{project_id}/integrations/{integration_id}/values > Add Integration Extracted Value > 204 > application/json", capabilityWrapper("integrationextractvalue")) h.Before("integration > /api/project/{project_id}/integrations/{integration_id}/values/{extractvalue_id} > Removes integration extract value > 204 > application/json", capabilityWrapper("integrationextractvalue")) h.Before("integration > /api/project/{project_id}/integrations/{integration_id}/values > Add Integration Extracted Value > 204 > application/json", capabilityWrapper("integration")) h.Before("integration > /api/project/{project_id}/integrations/{integration_id}/values/{extractvalue_id} > Updates Integration ExtractValue > 204 > application/json", capabilityWrapper("integrationextractvalue")) h.Before("integration > /api/project/{project_id}/integrations/{integration_id}/matchers > Get Integration Matcher linked to integration extractor > 200 > application/json", capabilityWrapper("integration")) h.Before("integration > /api/project/{project_id}/integrations/{integration_id}/matchers > Add Integration Matcher > 204 > application/json", capabilityWrapper("integration")) h.Before("integration > /api/project/{project_id}/integrations/{integration_id}/matchers/{matcher_id} > Updates Integration Matcher > 204 > application/json", capabilityWrapper("integrationmatcher")) h.Before("key-store > /api/project/{project_id}/keys > Add access key > 201 > application/json", capabilityWrapper("access_key")) h.Before("key-store > /api/project/{project_id}/keys/{key_id} > Updates access key > 204 > application/json", capabilityWrapper("access_key")) h.Before("key-store > /api/project/{project_id}/keys/{key_id} > Removes access key > 204 > application/json", capabilityWrapper("access_key")) h.Before("repository > /api/project/{project_id}/repositories > Add repository > 201 > application/json", capabilityWrapper("access_key")) h.Before("repository > /api/project/{project_id}/repositories/{repository_id} > Get repository > 200 > application/json", capabilityWrapper("repository")) h.Before("repository > /api/project/{project_id}/repositories/{repository_id} > Updates repository > 204 > application/json", capabilityWrapper("repository")) h.Before("repository > /api/project/{project_id}/repositories/{repository_id} > Removes repository > 204 > application/json", capabilityWrapper("repository")) h.Before("inventory > /api/project/{project_id}/inventory > create inventory > 201 > application/json", capabilityWrapper("inventory")) h.Before("inventory > /api/project/{project_id}/inventory/{inventory_id} > Get inventory > 200 > application/json", capabilityWrapper("inventory")) h.Before("inventory > /api/project/{project_id}/inventory/{inventory_id} > Updates inventory > 204 > application/json", capabilityWrapper("inventory")) h.Before("inventory > /api/project/{project_id}/inventory/{inventory_id} > Removes inventory > 204 > application/json", capabilityWrapper("inventory")) h.Before("variable-group > /api/project/{project_id}/environment > Add environment > 201 > application/json", capabilityWrapper("environment")) h.Before("variable-group > /api/project/{project_id}/environment/{environment_id} > Get environment > 200 > application/json", capabilityWrapper("environment")) h.Before("variable-group > /api/project/{project_id}/environment/{environment_id} > Update environment > 204 > application/json", capabilityWrapper("environment")) h.Before("variable-group > /api/project/{project_id}/environment/{environment_id} > Removes environment > 204 > application/json", capabilityWrapper("environment")) h.Before("template > /api/project/{project_id}/templates > create template > 201 > application/json", func(t *trans.Transaction) { addCapabilities([]string{"repository", "inventory", "environment", "view"}) }) h.Before("template > /api/project/{project_id}/templates/{template_id}/stop_all_tasks > Stop all active tasks of template > 204 > application/json", capabilityWrapper("template")) h.Before("template > /api/project/{project_id}/templates/{template_id} > Get template > 200 > application/json", capabilityWrapper("template")) h.Before("template > /api/project/{project_id}/templates/{template_id} > Updates template > 204 > application/json", capabilityWrapper("template")) h.Before("template > /api/project/{project_id}/templates/{template_id} > Removes template > 204 > application/json", capabilityWrapper("template")) h.Before("task > /api/project/{project_id}/tasks > Starts a job > 201 > application/json", capabilityWrapper("template")) h.Before("task > /api/project/{project_id}/tasks/last > Get last 200 Tasks related to current project > 200 > application/json", capabilityWrapper("template")) h.Before("task > /api/project/{project_id}/tasks/{task_id} > Get a single task > 200 > application/json", capabilityWrapper("task")) h.Before("task > /api/project/{project_id}/tasks/{task_id} > Deletes task (including output) > 204 > application/json", capabilityWrapper("task")) h.Before("task > /api/project/{project_id}/tasks/{task_id}/output > Get task output > 200 > application/json", capabilityWrapper("task")) h.Before("task > /api/project/{project_id}/tasks/{task_id}/raw_output > Get task raw output > 200 > text/plain; charset=utf-8", capabilityWrapper("task")) h.Before("task > /api/project/{project_id}/tasks/{task_id}/stop > Stop a job > 204 > application/json", capabilityWrapper("task")) h.Before("schedule > /api/project/{project_id}/schedules/{schedule_id} > Get schedule > 200 > application/json", capabilityWrapper("schedule")) h.Before("schedule > /api/project/{project_id}/schedules/{schedule_id} > Updates schedule > 204 > application/json", capabilityWrapper("schedule")) h.Before("schedule > /api/project/{project_id}/schedules/{schedule_id} > Deletes schedule > 204 > application/json", capabilityWrapper("schedule")) h.Before("project > /api/project/{project_id}/views/{view_id} > Get view > 200 > application/json", capabilityWrapper("view")) h.Before("project > /api/project/{project_id}/views/{view_id} > Updates view > 204 > application/json", capabilityWrapper("view")) h.Before("project > /api/project/{project_id}/views/{view_id} > Removes view > 204 > application/json", capabilityWrapper("view")) h.Before("project > /api/project/{project_id}/backup > Get backup > 200 > application/json", func(t *trans.Transaction) { addCapabilities([]string{"repository", "inventory", "environment", "view", "template"}) }) //Add these last as they normalize the requests and path values after hook processing h.BeforeAll(func(transactions []*trans.Transaction) { for _, t := range transactions { h.Before(t.Name, setupObjectsAndPaths) } }) // Delete the test runner user so adding him next time does not result in errors h.AfterAll(removeTestRunnerUser) server.Serve() defer server.Listener.Close() } ================================================ FILE: .dredd/server-wrapper.sh ================================================ #!/bin/sh export SEMAPHORE_MAX_TASKS_PER_TEMPLATE=300 ./semaphore server --config .dredd/config.json ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: semaphoreui #patreon: semaphoreui #ko_fi: fiftin open_collective: # semaphore tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/ISSUE_TEMPLATE/documentation.yml ================================================ --- name: Documentation description: You have a found missing or invalid documentation title: "Docs: " labels: ['documentation', 'triage'] body: - type: markdown attributes: value: | Please make sure to go through these steps **before opening an issue**: - [ ] Read the [documentation](https://docs.semaphoreui.com/) - [ ] Read the [troubleshooting guide](https://docs.semaphoreui.com/administration-guide/troubleshooting) - [ ] Read the [documentation regarding manual installations](https://docs.semaphoreui.com/administration-guide/installation_manually) if you did install Semaphore that way - [ ] Check if there are existing [issues](https://github.com/semaphoreui/semaphore/issues) or [discussions](https://github.com/semaphoreui/semaphore/discussions) regarding your topic - type: textarea id: problem attributes: label: Problem description: | Describe what part of the documentation is missing or wrong! Please also tell us what you would expected to find. What would you change or add? validations: required: true - type: dropdown id: related-to attributes: label: Related to description: | To what parts of Semaphore is the documentation related? (if any) multiple: true options: - Web-Frontend (what users interact with) - Web-Backend (APIs) - Service (scheduled tasks, alerts) - Ansible (task execution) - Configuration - Database - Docker validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ --- name: Feature request description: You would like to have a new feature implemented title: "Feature: " labels: ['feature', 'triage'] body: - type: markdown attributes: value: | Please make sure to go through these steps **before opening an issue**: - [ ] Read the [documentation](https://docs.semaphoreui.com/) - [ ] Read the [troubleshooting guide](https://docs.semaphoreui.com/administration-guide/troubleshooting) - [ ] Read the [documentation regarding manual installations](https://docs.semaphoreui.com/administration-guide/installation_manually) if you did install Semaphore that way - [ ] Check if there are existing [issues](https://github.com/semaphoreui/semaphore/issues) or [discussions](https://github.com/semaphoreui/semaphore/discussions) regarding your topic - type: dropdown id: related-to attributes: label: Related to description: | To what parts of Semaphore is the feature related? multiple: true options: - Web-Frontend (what users interact with) - Web-Backend (APIs) - Service (scheduled tasks, alerts) - Ansible (task execution) - Configuration - Database - Docker - Other validations: required: true - type: dropdown id: impact attributes: label: Impact description: | What impact would the feature have for Semaphore users? multiple: false options: - nice to have - nice to have for enterprise usage - better user experience - security improvements - major improvement to user experience - must have for enterprise usage - must have validations: required: true - type: textarea id: feature attributes: label: Missing Feature description: | Describe the feature you are missing. Why would you like to see such a feature being implemented? validations: required: true - type: textarea id: implementation attributes: label: Implementation description: | Please think about how the feature should be implemented. What would you suggest? How should it look and behave? validations: required: true - type: textarea id: design attributes: label: Design description: | If you have programming experience yourself: Please provide us with an example how you would design this feature. What edge-cases need to be covered? Are there relations to other components that need to be though of? validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/problem.yml ================================================ --- name: Problem description: You have encountered problems when using Semaphore title: "Problem: " labels: ['problem', 'triage'] body: - type: markdown attributes: value: | Please make sure to go through these steps **before opening an issue**: - [ ] Read the [documentation](https://docs.semaphoreui.com/) - [ ] Read the [troubleshooting guide](https://docs.semaphoreui.com/administration-guide/troubleshooting) - [ ] Read the [documentation regarding manual installations](https://docs.semaphoreui.com/administration-guide/installation_manually) if you don't use docker - [ ] Check if there are existing [issues](https://github.com/semaphoreui/semaphore/issues) or [discussions](https://github.com/semaphoreui/semaphore/discussions) regarding your topic - type: textarea id: problem attributes: label: Issue description: | Describe the problem you encountered and tell us what you would have expected to happen validations: required: true - type: dropdown id: impact attributes: label: Impact description: | What parts of Semaphore are impacted by the problem? multiple: true options: - Web-Frontend (what users interact with) - Web-Backend (APIs) - Service (scheduled tasks, alerts) - Ansible (task execution) - Configuration - Database - Docker - Semaphore Project - Other validations: required: true - type: dropdown id: install-method attributes: label: Installation method description: | How did you install Semaphore? multiple: false options: - Docker - Package - Binary - Snap validations: required: true - type: dropdown id: databases attributes: label: Database description: | What database you use? multiple: true options: - SQLite - MySQL - BoltDB - Postgres - type: dropdown id: browsers attributes: label: Browser description: | If the problem occurs in the Semaphore WebUI - in what browsers do you see it? multiple: true options: - Firefox - Chrome - Safari - Microsoft Edge - Opera - type: textarea id: version-semaphore attributes: label: Semaphore Version description: | What version of Semaphore are you running? > Command: `semaphore version` validations: required: true - type: textarea id: version-ansible attributes: label: Ansible Version description: | If your problem occurs when executing a task: > What version of Ansible are you running? > Command: `ansible --version` If your problem occurs when executing a specific Ansible Module: > Provide the Ansible Module versions! > Command: `ansible-galaxy collection list` render: bash validations: required: false - type: textarea id: logs attributes: label: Logs & errors description: | Provide logs and error messages you have encountered! Logs of the service: > Docker command: `docker logs ` > Systemd command: `journalctl -u --no-pager --full -n 250` If the error occurs in the WebUI: > please add a screenshot > check your browser console for errors (`F12` in most browsers) Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. validations: required: false - type: textarea id: manual-installation attributes: label: Manual installation - system information description: | If you have installed Semaphore using the package or binary: Please share your operating system & -version! > Command: `uname -a` What reverse proxy are you using? validations: required: false - type: textarea id: config attributes: label: Configuration description: | Please provide Semaphore configuration related to your problem - like: * Config file options * Environment variables * WebUI configuration * Task templates * Inventories * Environment * Repositories * ... validations: required: false - type: textarea id: additional attributes: label: Additional information description: | Do you have additional information that could help troubleshoot the problem? validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/question.yml ================================================ --- name: Question description: You have a question on how to use Semaphore title: "Question: " labels: ['question', 'triage'] body: - type: markdown attributes: value: | Please make sure to go through these steps **before opening an issue**: - [ ] Read the [documentation](https://docs.semaphoreui.com/) - [ ] Read the [troubleshooting guide](https://docs.semaphoreui.com/administration-guide/troubleshooting) - [ ] Read the [documentation regarding manual installations](https://docs.semaphoreui.com/administration-guide/installation_manually) if you did install Semaphore that way - [ ] Check if there are existing [issues](https://github.com/semaphoreui/semaphore/issues) or [discussions](https://github.com/semaphoreui/semaphore/discussions) regarding your topic - type: textarea id: question attributes: label: Question validations: required: true - type: dropdown id: related-to attributes: label: Related to description: | To what parts of Semaphore is the question related? (if any) multiple: true options: - Web-Frontend (what users interact with) - Web-Backend (APIs) - Service (scheduled tasks, alerts) - Ansible (task execution) - Configuration - Database - Documentation - Docker validations: required: false ================================================ FILE: .github/copilot-instructions.md ================================================ # Semaphore UI Development Instructions **Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here.** Semaphore UI is a modern web interface for managing popular DevOps tools like Ansible, Terraform, PowerShell, and Bash scripts. It's built with Go (backend) and Vue.js (frontend), using Task runner for build automation. ## Working Effectively ### Bootstrap, build, and test the repository: - Install Go 1.21+ (currently requires go version 1.21 or higher) - Install Node.js 16+ - Install Task runner: `go install github.com/go-task/task/v3/cmd/task@latest` - Install dependencies: `task deps` -- takes 3 minutes first time (faster with cache). NEVER CANCEL. Set timeout to 5+ minutes. - Build the application: `task build` -- takes 1.5 minutes. NEVER CANCEL. Set timeout to 3+ minutes. - Run tests: `task test` -- takes 33 seconds. NEVER CANCEL. Set timeout to 2+ minutes. ### Run the application: - ALWAYS run the bootstrapping steps first - Setup database and admin user: `./bin/semaphore setup` (interactive, use BoltDB option 2 for development) - Start server: `./bin/semaphore server --config ./config.json` - Web UI: http://localhost:3000 (login: admin / changeme) - API: http://localhost:3000/api/ (test with: `curl http://localhost:3000/api/ping`) ## Validation - **CRITICAL**: Always manually validate any new code by building and running the application. - ALWAYS run through at least one complete end-to-end scenario after making changes: 1. Build the application: `task build` 2. Start the server: `./bin/semaphore server --config ./config.json` 3. Test API endpoint: `curl http://localhost:3000/api/ping` (should return "pong") 4. Access web UI at http://localhost:3000 and verify it loads 5. For auth changes: Test login with admin/changeme - For significant changes, run full setup process to ensure setup still works - Always build and exercise your changes before considering the task complete ### Complete Validation Scenario (for major changes): ```bash # 1. Clean build task build # 2. Setup database (if config.json doesn't exist) ./bin/semaphore setup # Choose option 2 (BoltDB), use admin/changeme # 3. Start server ./bin/semaphore server --config ./config.json # 4. Test in another terminal curl http://localhost:3000/api/ping # Should return "pong" curl -I http://localhost:3000/ # Should return HTTP 200 # 5. Test web interface manually in browser at http://localhost:3000 # 6. Test login with admin/changeme if auth-related changes ``` ### Linting and Code Quality - Frontend linting: `cd web && npm run lint` (has known warnings about console statements and asset sizes - ignore existing issues) - Backend linting: `golangci-lint run --timeout=3m` (has known type errors due to module import issues - ignore existing issues) - **DO NOT** try to fix existing linting issues unless specifically asked to - Always run linting on new code you add to ensure it follows project standards - Install golangci-lint if needed: `go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.57.2` ## Common Tasks ### Repository Structure ``` . ├── README.md - Project documentation ├── CONTRIBUTING.md - Development guidelines ├── Taskfile.yml - Task runner configuration ├── go.mod - Go module dependencies ├── web/ - Vue.js frontend application │ ├── package.json - Frontend dependencies │ ├── src/ - Vue.js source code │ └── public/ - Static assets ├── cli/ - Go CLI application entry point ├── api/ - Go API server endpoints ├── db/ - Database models and interfaces ├── services/ - Business logic services ├── util/ - Utility functions and configuration ├── bin/ - Built binaries (after build) └── config.json - Runtime configuration (after setup) ``` ### Key Commands Reference ```bash # Install task runner go install github.com/go-task/task/v3/cmd/task@latest # Install all dependencies (backend + frontend + tools) task deps # Build application (frontend + backend) task build # Run tests task test # Run linting task lint # Setup application (interactive) ./bin/semaphore setup # Start server ./bin/semaphore server --config ./config.json # View available task commands task --list ``` ### Database Options for Development During setup, choose option 2 (BoltDB) for simplest development setup: - No external database required - Database file stored as `database.boltdb` in project root - Perfect for development and testing ### Frontend Development - Vue.js 2.x application in `web/` directory - Built with Vue CLI and Vuetify components - Build output goes to `api/public/` for serving by Go backend - Development server not typically used - Go server serves built assets ### Backend Development - Go application with CLI and API server - Uses Gorilla Mux for routing - Supports multiple databases: MySQL, PostgreSQL, SQLite, BoltDB - Configuration via JSON file or environment variables ## Troubleshooting ### Build Issues - If `task` command not found: Install with `go install github.com/go-task/task/v3/cmd/task@latest` - If Go version errors: Ensure Go 1.21+ is installed - If npm install fails: Ensure Node.js 16+ is installed - If build takes too long: This is normal - frontend build can take 60+ seconds ### Runtime Issues - If server won't start: Check config.json exists and database is accessible - If web UI shows errors: Check that frontend build completed successfully in `api/public/` - If API returns errors: Check server logs for specific error messages ### Database Issues - For development, always use BoltDB (option 2) during setup - Database file will be created automatically - If database errors occur, delete `database.boltdb` and run setup again ## Important Notes - **NEVER CANCEL** long-running builds or dependency installations - Set appropriate timeouts: deps (5+ min), build (3+ min), tests (2+ min) - The application serves the frontend from the Go backend - no separate frontend server needed - Configuration is stored in `config.json` after running setup - Default admin credentials after setup: admin / changeme - Linting has known issues - focus on not introducing new ones - Always test changes by running the full application, not just unit tests ================================================ FILE: .github/workflows/community_beta.yml ================================================ name: Community Beta 'on': push: tags: - v*-beta* permissions: contents: write jobs: prerelease: runs-on: ubuntu-latest steps: - name: Checkout source uses: actions/checkout@v4 - name: Setup golang uses: actions/setup-go@v5 with: go-version: '^1.24.6' - name: Setup nodejs uses: actions/setup-node@v4 with: node-version: '24' cache: 'npm' cache-dependency-path: web/package-lock.json - name: Install go-task run: | go install github.com/go-task/task/v3/cmd/task@latest - name: Install rpm run: | sudo apt update && sudo apt-get install rpm - name: Install deps run: | task deps - name: Import gnupg run: | echo "${{ secrets.GPG_KEY }}" | tr " " "\n" | base64 -d | gpg --import --batch gpg --sign -u "${{ vars.GPG_KEY_ID }}" --pinentry-mode loopback --yes --batch --passphrase "${{ secrets.GPG_PASS }}" --output unlock.sig --detach-sign README.md rm -f unlock.sig - name: Reset repo run: | git reset --hard - name: Run release run: | GITHUB_TOKEN=${{ github.token }} \ PROJECT_NAME=semaphore_community \ GPG_KEY_ID="${{ vars.GPG_KEY_ID }}" \ task release:prod deploy-beta: runs-on: ubuntu-latest if: github.repository_owner == 'semaphoreui' 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: Server meta id: server uses: docker/metadata-action@v5 with: github-token: ${{ secrets.GITHUB_TOKEN }} images: | semaphoreui/semaphore-community labels: | org.opencontainers.image.vendor=SemaphoreUI maintainer=Semaphore UI tags: | type=raw,value=${{ github.ref_name }} flavor: | latest=false - name: Server build uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . file: deployment/docker/server/Dockerfile platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.server.outputs.labels }} tags: ${{ steps.server.outputs.tags }} - name: Runner meta if: false id: runner uses: docker/metadata-action@v5 with: github-token: ${{ secrets.GITHUB_TOKEN }} images: | semaphoreui/runner-community labels: | org.opencontainers.image.vendor=SemaphoreUI maintainer=Semaphore UI tags: | type=raw,value=${{ github.ref_name }} flavor: | latest=false - name: Runner build if: false uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . file: deployment/docker/runner/Dockerfile platforms: linux/amd64,linux/arm64 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.runner.outputs.labels }} tags: ${{ steps.runner.outputs.tags }} ================================================ FILE: .github/workflows/community_release.yml ================================================ name: Community Release 'on': push: tags: - 'v[0-9]+.[0-9]+.[0-9]+' - v*-rc* permissions: contents: write jobs: release: runs-on: ubuntu-latest steps: - name: Checkout source uses: actions/checkout@v4 - name: Setup golang uses: actions/setup-go@v5 with: go-version: '^1.24.6' - name: Setup nodejs uses: actions/setup-node@v4 with: node-version: '24' cache: 'npm' cache-dependency-path: web/package-lock.json - name: Install go-task run: | go install github.com/go-task/task/v3/cmd/task@latest - name: Install rpm run: | sudo apt update && sudo apt-get install rpm - name: Install deps run: | task deps - name: Import gnupg run: | echo "${{ secrets.GPG_KEY }}" | tr " " "\n" | base64 -d | gpg --import --batch gpg --sign -u "${{ vars.GPG_KEY_ID }}" --pinentry-mode loopback --yes --batch --passphrase "${{ secrets.GPG_PASS }}" --output unlock.sig --detach-sign README.md rm -f unlock.sig - name: Reset repo run: | git reset --hard - name: Run release run: | GITHUB_TOKEN=${{ github.token }} \ GPG_KEY_ID="${{ vars.GPG_KEY_ID }}" \ PROJECT_NAME=semaphore_community \ task release:prod deploy-prod: runs-on: ubuntu-latest if: github.repository_owner == 'semaphoreui' 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: Server meta id: server uses: docker/metadata-action@v5 with: github-token: ${{ secrets.GITHUB_TOKEN }} images: | semaphoreui/semaphore-community labels: | org.opencontainers.image.vendor=SemaphoreUI maintainer=Semaphore UI tags: | type=raw,value=${{ github.ref_name }} flavor: | latest=true - name: Server build uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . file: deployment/docker/server/Dockerfile platforms: linux/amd64,linux/arm64 # ,linux/arm/v6 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.server.outputs.labels }} tags: ${{ steps.server.outputs.tags }} - name: Server build with Ansible 2.16.5 uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . build-args: | ANSIBLE_VERSION=9.4.0 file: deployment/docker/server/Dockerfile platforms: linux/amd64,linux/arm64 # ,linux/arm/v6 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.server.outputs.labels }} tags: semaphoreui/semaphore-community:${{ github.ref_name }}-ansible2.16.5 - name: Server build with PowerShell 7.5.0 uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . build-args: | POWERSHELL_VERSION=7.5.0 SEMAPHORE_IMAGE=semaphoreui/semaphore-community SEMAPHORE_VERSION=${{ github.ref_name }} file: deployment/docker/server/powershell/Dockerfile platforms: linux/amd64 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.server.outputs.labels }} tags: semaphoreui/semaphore-community:${{ github.ref_name }}-powershell7.5.0 - name: Runner meta id: runner uses: docker/metadata-action@v5 with: github-token: ${{ secrets.GITHUB_TOKEN }} images: | semaphoreui/runner-community labels: | org.opencontainers.image.vendor=SemaphoreUI maintainer=Semaphore UI tags: | type=raw,value=${{ github.ref_name }} flavor: | latest=true - name: Runner build uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . file: deployment/docker/runner/Dockerfile platforms: linux/amd64,linux/arm64 #,linux/arm/v6 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.runner.outputs.labels }} tags: ${{ steps.runner.outputs.tags }} - name: Runner build with Ansible 2.16.5 uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . build-args: | ANSIBLE_VERSION=9.4.0 file: deployment/docker/runner/Dockerfile platforms: linux/amd64,linux/arm64 #,linux/arm/v6 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.runner.outputs.labels }} tags: semaphoreui/runner-community:${{ github.ref_name }}-ansible2.16.5 - name: Runner build with PowerShell 7.5.0 uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . build-args: | POWERSHELL_VERSION=7.5.0 SEMAPHORE_IMAGE=semaphoreui/runner-community SEMAPHORE_VERSION=${{ github.ref_name }} file: deployment/docker/server/powershell/Dockerfile platforms: linux/amd64 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.runner.outputs.labels }} tags: semaphoreui/runner-community:${{ github.ref_name }}-powershell7.5.0 ================================================ FILE: .github/workflows/dev.yml ================================================ name: Dev 'on': push: branches: - develop - 2-*-stable pull_request: branches: - develop jobs: build-local: runs-on: ubuntu-latest steps: - name: Checkout source uses: actions/checkout@v4 - name: Setup golang uses: actions/setup-go@v5 with: go-version: '^1.24.6' - name: Setup nodejs uses: actions/setup-node@v4 with: node-version: '24' cache: 'npm' cache-dependency-path: web/package-lock.json - name: Install go-task run: | go install github.com/go-task/task/v3/cmd/task@latest - name: Install deps run: | task deps - name: Run build run: task build - name: Check modification run: | git diff --exit-code --stat -- . ':(exclude)web/package.json' ':(exclude)web/package-lock.json' ':(exclude)go.mod' ':(exclude)go.sum' - name: Run tests run: task test - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: semaphore path: bin/semaphore retention-days: 1 migrate-mysql: runs-on: ubuntu-latest needs: - build-local services: mysql: image: mysql:8.4 env: MYSQL_ROOT_PASSWORD: p455w0rd MYSQL_USER: semaphore MYSQL_PASSWORD: p455w0rd MYSQL_DATABASE: semaphore options: >- --health-cmd "mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 3306:3306 steps: - name: Download artifacts uses: actions/download-artifact@v4 with: name: semaphore - name: Write config run: | cat > config.json <- --health-cmd "mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 3306:3306 steps: - name: Download artifacts uses: actions/download-artifact@v4 with: name: semaphore - name: Write config run: | cat > config.json <- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 steps: - name: Download artifacts uses: actions/download-artifact@v4 with: name: semaphore - name: Write config run: | cat > config.json < config.json <- --health-cmd "mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 3306:3306 steps: - name: Checkout source uses: actions/checkout@v4 - name: Setup golang uses: actions/setup-go@v5 with: go-version: '^1.24.6' - name: Setup nodejs uses: actions/setup-node@v4 with: node-version: '24' cache: 'npm' cache-dependency-path: web/package-lock.json - name: Install go-task run: | go install github.com/go-task/task/v3/cmd/task@latest - name: Download artifacts uses: actions/download-artifact@v4 with: name: semaphore - name: Write config run: | cat > config.stdin <- --health-cmd "mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 3306:3306 steps: - name: Checkout source uses: actions/checkout@v4 - name: Setup golang uses: actions/setup-go@v5 with: go-version: '^1.24.6' - name: Setup nodejs uses: actions/setup-node@v4 with: node-version: '24' cache: 'npm' cache-dependency-path: web/package-lock.json - name: Install go-task run: | go install github.com/go-task/task/v3/cmd/task@latest - name: Download artifacts uses: actions/download-artifact@v4 with: name: semaphore - name: Write config run: | cat > config.stdin <- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 steps: - name: Checkout source uses: actions/checkout@v4 - name: Setup golang uses: actions/setup-go@v5 with: go-version: '^1.24.6' - name: Setup nodejs uses: actions/setup-node@v4 with: node-version: '24' cache: 'npm' cache-dependency-path: web/package-lock.json - name: Install go-task run: | go install github.com/go-task/task/v3/cmd/task@latest - name: Download artifacts uses: actions/download-artifact@v4 with: name: semaphore - name: Write config run: | cat > config.stdin < config.stdin < tags: | type=raw,value=develop flavor: | latest=false - name: Server build uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . file: deployment/docker/server/Dockerfile platforms: linux/amd64,linux/arm64 #,linux/arm/v6 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.server.outputs.labels }} tags: ${{ steps.server.outputs.tags }} deploy-runner: runs-on: ubuntu-latest if: github.repository_owner == 'semaphoreui' needs: - 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 with: github-token: ${{ secrets.GITHUB_TOKEN }} images: | semaphoreui/runner-community labels: | org.opencontainers.image.vendor=SemaphoreUI maintainer=Semaphore UI tags: | type=raw,value=develop flavor: | latest=false - name: Runner build uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . file: deployment/docker/runner/Dockerfile platforms: linux/amd64,linux/arm64 #,linux/arm/v6 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.runner.outputs.labels }} tags: ${{ steps.runner.outputs.tags }} ================================================ FILE: .github/workflows/pro_selfhosted_beta.yml ================================================ name: Pro Self-Hosted Release 'on': push: tags: - v*-beta* - v*-rc* permissions: contents: write jobs: prerelease: runs-on: ubuntu-latest steps: - name: Checkout source uses: actions/checkout@v4 - name: Setup golang uses: actions/setup-go@v5 with: go-version: '^1.24.6' - name: Setup nodejs uses: actions/setup-node@v4 with: node-version: '24' cache: 'npm' cache-dependency-path: web/package-lock.json - name: Install go-task run: | go install github.com/go-task/task/v3/cmd/task@latest - name: Install rpm run: | sudo apt update && sudo apt-get install rpm - name: Add PRO implementation run: | git clone https://${{ secrets.GH_TOKEN }}@github.com/semaphoreui/semaphorepro-module.git pro_impl go work init . ./pro_impl - name: Install deps run: | task deps APP_BUILD_TYPE=pro_selfhosted - name: Import gnupg run: | echo "${{ secrets.GPG_KEY }}" | tr " " "\n" | base64 -d | gpg --import --batch gpg --sign -u "${{ vars.GPG_KEY_ID }}" --pinentry-mode loopback --yes --batch --passphrase "${{ secrets.GPG_PASS }}" --output unlock.sig --detach-sign README.md rm -f unlock.sig - name: Reset repo run: | git reset --hard - name: Run release run: | APP_BUILD_TYPE=pro_selfhosted \ GITHUB_TOKEN=${{ github.token }} \ GPG_KEY_ID="${{ vars.GPG_KEY_ID }}" \ PROJECT_NAME=semaphore \ task release:prod deploy-beta: runs-on: ubuntu-latest if: github.repository_owner == 'semaphoreui' 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: registry: public.ecr.aws username: ${{ vars.AWS_ACCESS_KEY_ID }} password: ${{ secrets.AWS_SECRET_ACCESS_KEY }} env: AWS_REGION: us-east-1 - name: Docker Hub login uses: docker/login-action@v3 if: github.event_name != 'pull_request' with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASS }} - name: Server meta id: server uses: docker/metadata-action@v5 with: github-token: ${{ secrets.GITHUB_TOKEN }} images: | public.ecr.aws/semaphore/pro/server public.ecr.aws/semaphore/server semaphoreui/semaphore labels: | org.opencontainers.image.vendor=SemaphoreUI maintainer=Semaphore UI tags: | type=raw,value=${{ github.ref_name }} - name: Server build uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . build-args: | GH_TOKEN=${{ secrets.GH_TOKEN }} APP_BUILD_TYPE=pro_selfhosted file: deployment/docker/server/Dockerfile platforms: linux/amd64,linux/arm64 # ,linux/arm/v6 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.server.outputs.labels }} tags: ${{ steps.server.outputs.tags }} cache-from: type=gha cache-to: type=gha,mode=max provenance: false - name: Server build with Ansible 2.16.5 uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . build-args: | ANSIBLE_VERSION=9.4.0 GH_TOKEN=${{ secrets.GH_TOKEN }} APP_BUILD_TYPE=pro_selfhosted file: deployment/docker/server/Dockerfile platforms: linux/amd64,linux/arm64 # ,linux/arm/v6 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.server.outputs.labels }} tags: | public.ecr.aws/semaphore/pro/server:${{ github.ref_name }}-ansible2.16.5 public.ecr.aws/semaphore/server:${{ github.ref_name }}-ansible2.16.5 semaphoreui/semaphore:${{ github.ref_name }}-ansible2.16.5 cache-from: type=gha cache-to: type=gha,mode=max provenance: false - name: Server build with PowerShell 7.5.0 uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . build-args: | POWERSHELL_VERSION=7.5.0 SEMAPHORE_IMAGE=public.ecr.aws/semaphore/server SEMAPHORE_VERSION=${{ github.ref_name }} GH_TOKEN=${{ secrets.GH_TOKEN }} APP_BUILD_TYPE=pro_selfhosted file: deployment/docker/server/powershell/Dockerfile platforms: linux/amd64 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.server.outputs.labels }} tags: | public.ecr.aws/semaphore/pro/server:${{ github.ref_name }}-powershell7.5.0 public.ecr.aws/semaphore/server:${{ github.ref_name }}-powershell7.5.0 semaphoreui/semaphore:${{ github.ref_name }}-powershell7.5.0 cache-from: type=gha cache-to: type=gha,mode=max provenance: false - name: Runner meta id: runner uses: docker/metadata-action@v5 with: github-token: ${{ secrets.GITHUB_TOKEN }} images: | public.ecr.aws/semaphore/pro/runner public.ecr.aws/semaphore/runner semaphoreui/runner labels: | org.opencontainers.image.vendor=SemaphoreUI maintainer=Semaphore UI tags: | type=raw,value=${{ github.ref_name }} - name: Runner build uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . build-args: | GH_TOKEN=${{ secrets.GH_TOKEN }} file: deployment/docker/runner/Dockerfile platforms: linux/amd64,linux/arm64 #,linux/arm/v6 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.runner.outputs.labels }} tags: ${{ steps.runner.outputs.tags }} cache-from: type=gha cache-to: type=gha,mode=max provenance: false - name: Runner build with Ansible 2.16.5 uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . build-args: | ANSIBLE_VERSION=9.4.0 GH_TOKEN=${{ secrets.GH_TOKEN }} file: deployment/docker/runner/Dockerfile platforms: linux/amd64,linux/arm64 #,linux/arm/v6 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.runner.outputs.labels }} tags: | public.ecr.aws/semaphore/pro/runner:${{ github.ref_name }}-ansible2.16.5 public.ecr.aws/semaphore/runner:${{ github.ref_name }}-ansible2.16.5 semaphoreui/runner:${{ github.ref_name }}-ansible2.16.5 cache-from: type=gha cache-to: type=gha,mode=max provenance: false - name: Runner build with PowerShell 7.5.0 uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . build-args: | POWERSHELL_VERSION=7.5.0 SEMAPHORE_IMAGE=public.ecr.aws/semaphore/runner SEMAPHORE_VERSION=${{ github.ref_name }} GH_TOKEN=${{ secrets.GH_TOKEN }} file: deployment/docker/server/powershell/Dockerfile platforms: linux/amd64 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.runner.outputs.labels }} tags: | public.ecr.aws/semaphore/pro/runner:${{ github.ref_name }}-powershell7.5.0 public.ecr.aws/semaphore/runner:${{ github.ref_name }}-powershell7.5.0 semaphoreui/runner:${{ github.ref_name }}-powershell7.5.0 cache-from: type=gha cache-to: type=gha,mode=max provenance: false ================================================ FILE: .github/workflows/pro_selfhosted_release.yml ================================================ name: Pro Self-Hosted Release 'on': push: tags: - 'v[0-9]+.[0-9]+.[0-9]+' permissions: contents: write jobs: release: runs-on: ubuntu-latest steps: - name: Checkout source uses: actions/checkout@v4 - name: Setup golang uses: actions/setup-go@v5 with: go-version: '^1.24.6' - name: Setup nodejs uses: actions/setup-node@v4 with: node-version: '24' cache: 'npm' cache-dependency-path: web/package-lock.json - name: Install go-task run: | go install github.com/go-task/task/v3/cmd/task@latest - name: Install rpm run: | sudo apt update && sudo apt-get install rpm - name: Add PRO implementation run: | git clone https://${{ secrets.GH_TOKEN }}@github.com/semaphoreui/semaphorepro-module.git pro_impl go work init . ./pro_impl - name: Install deps run: | task deps APP_BUILD_TYPE=pro_selfhosted - name: Import gnupg run: | echo "${{ secrets.GPG_KEY }}" | tr " " "\n" | base64 -d | gpg --import --batch gpg --sign -u "${{ vars.GPG_KEY_ID }}" --pinentry-mode loopback --yes --batch --passphrase "${{ secrets.GPG_PASS }}" --output unlock.sig --detach-sign README.md rm -f unlock.sig - name: Reset repo run: | git reset --hard - name: Run release run: | APP_BUILD_TYPE=pro_selfhosted \ GITHUB_TOKEN=${{ github.token }} \ GPG_KEY_ID="${{ vars.GPG_KEY_ID }}" \ PROJECT_NAME=semaphore \ task release:prod deploy-prod: runs-on: ubuntu-latest if: github.repository_owner == 'semaphoreui' 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: registry: public.ecr.aws username: ${{ vars.AWS_ACCESS_KEY_ID }} password: ${{ secrets.AWS_SECRET_ACCESS_KEY }} env: AWS_REGION: us-east-1 - name: Docker Hub login uses: docker/login-action@v3 if: github.event_name != 'pull_request' with: username: ${{ secrets.DOCKER_USER }} password: ${{ secrets.DOCKER_PASS }} - name: Server meta id: server uses: docker/metadata-action@v5 with: github-token: ${{ secrets.GITHUB_TOKEN }} images: | public.ecr.aws/semaphore/pro/server public.ecr.aws/semaphore/server semaphoreui/semaphore labels: | org.opencontainers.image.vendor=SemaphoreUI maintainer=Semaphore UI tags: | type=raw,value=${{ github.ref_name }} flavor: | latest=true - name: Server build uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . build-args: | GH_TOKEN=${{ secrets.GH_TOKEN }} APP_BUILD_TYPE=pro_selfhosted file: deployment/docker/server/Dockerfile platforms: linux/amd64,linux/arm64 # ,linux/arm/v6 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.server.outputs.labels }} tags: ${{ steps.server.outputs.tags }} cache-from: type=gha cache-to: type=gha,mode=max provenance: false - name: Server build with Ansible 2.16.5 uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . build-args: | ANSIBLE_VERSION=9.4.0 GH_TOKEN=${{ secrets.GH_TOKEN }} APP_BUILD_TYPE=pro_selfhosted file: deployment/docker/server/Dockerfile platforms: linux/amd64,linux/arm64 # ,linux/arm/v6 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.server.outputs.labels }} tags: | public.ecr.aws/semaphore/pro/server:${{ github.ref_name }}-ansible2.16.5 public.ecr.aws/semaphore/server:${{ github.ref_name }}-ansible2.16.5 semaphoreui/semaphore:${{ github.ref_name }}-ansible2.16.5 cache-from: type=gha cache-to: type=gha,mode=max provenance: false - name: Server build with PowerShell 7.5.0 uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . build-args: | POWERSHELL_VERSION=7.5.0 SEMAPHORE_IMAGE=public.ecr.aws/semaphore/server SEMAPHORE_VERSION=${{ github.ref_name }} GH_TOKEN=${{ secrets.GH_TOKEN }} APP_BUILD_TYPE=pro_selfhosted file: deployment/docker/server/powershell/Dockerfile platforms: linux/amd64 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.server.outputs.labels }} tags: | public.ecr.aws/semaphore/pro/server:${{ github.ref_name }}-powershell7.5.0 public.ecr.aws/semaphore/server:${{ github.ref_name }}-powershell7.5.0 semaphoreui/semaphore:${{ github.ref_name }}-powershell7.5.0 cache-from: type=gha cache-to: type=gha,mode=max provenance: false - name: Runner meta id: runner uses: docker/metadata-action@v5 with: github-token: ${{ secrets.GITHUB_TOKEN }} images: | public.ecr.aws/semaphore/pro/runner public.ecr.aws/semaphore/runner semaphoreui/runner labels: | org.opencontainers.image.vendor=SemaphoreUI maintainer=Semaphore UI tags: | type=raw,value=${{ github.ref_name }} flavor: | latest=true - name: Runner build uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . build-args: | GH_TOKEN=${{ secrets.GH_TOKEN }} file: deployment/docker/runner/Dockerfile platforms: linux/amd64,linux/arm64 #,linux/arm/v6 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.runner.outputs.labels }} tags: ${{ steps.runner.outputs.tags }} cache-from: type=gha cache-to: type=gha,mode=max provenance: false - name: Runner build with Ansible 2.16.5 uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . build-args: | ANSIBLE_VERSION=9.4.0 GH_TOKEN=${{ secrets.GH_TOKEN }} file: deployment/docker/runner/Dockerfile platforms: linux/amd64,linux/arm64 #,linux/arm/v6 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.runner.outputs.labels }} tags: | public.ecr.aws/semaphore/pro/runner:${{ github.ref_name }}-ansible2.16.5 public.ecr.aws/semaphore/runner:${{ github.ref_name }}-ansible2.16.5 semaphoreui/runner:${{ github.ref_name }}-ansible2.16.5 cache-from: type=gha cache-to: type=gha,mode=max provenance: false - name: Runner build with PowerShell 7.5.0 uses: docker/build-push-action@v5 with: builder: ${{ steps.buildx.outputs.name }} context: . build-args: | POWERSHELL_VERSION=7.5.0 SEMAPHORE_IMAGE=public.ecr.aws/semaphore/runner SEMAPHORE_VERSION=${{ github.ref_name }} GH_TOKEN=${{ secrets.GH_TOKEN }} file: deployment/docker/server/powershell/Dockerfile platforms: linux/amd64 push: ${{ github.event_name != 'pull_request' }} labels: ${{ steps.runner.outputs.labels }} tags: | public.ecr.aws/semaphore/pro/runner:${{ github.ref_name }}-powershell7.5.0 public.ecr.aws/semaphore/runner:${{ github.ref_name }}-powershell7.5.0 semaphoreui/runner:${{ github.ref_name }}-powershell7.5.0 cache-from: type=gha cache-to: type=gha,mode=max provenance: false ================================================ FILE: .gitignore ================================================ gin-bin build/ /test/mcp/*/artifacts/ /certs/ web/public/js/bundle.js web/public/css/*.* web/public/html/**/*.* web/public/fonts/*.* web/.nyc_output api/public/**/* /server.json /config.json /config-secondary.json /config.runner.json /config.runner.token /config.runner.key /runner.json /runner.token /runner.key /.dredd/config.json /database.boltdb /database.sqlite /database.sqlite-journal /database.boltdb.lock /database_test.boltdb .DS_Store /backup.json node_modules/ /.idea/ /semaphore.iml /bin/ /vendor/ /coverage.out /public/package-lock.json !.gitkeep .dredd/compiled_hooks .dredd/compiled_hooks.exe __debug_bin* .task/ /web/.env /.venv/ /events.log /tasks.log /task_results.log /pro_impl/ /go.work.x /go.work /go.work.sum ================================================ FILE: .golangci.yml ================================================ version: "2" run: timeout: "240s" # TODO: remove following line to make golangci return non-zero as lint were not passed issues-exit-code: 0 linters: default: standard disable: - unused formatters: enable: - gofmt settings: gofmt: rewrite-rules: - pattern: 'interface{}' replacement: 'any' ================================================ FILE: .goreleaser.yml ================================================ version: 2 dist: bin before: hooks: - task build:fe builds: - binary: semaphore env: - CGO_ENABLED=0 main: ./cli/main.go ldflags: -s -w -X github.com/semaphoreui/semaphore/util.Ver={{ .Version }} -X github.com/semaphoreui/semaphore/util.Commit={{ .ShortCommit }} -X github.com/semaphoreui/semaphore/util.Date={{ .Timestamp }} tags: - netgo goos: - windows - darwin - linux - freebsd goarch: - 386 - amd64 - arm - arm64 - ppc64le ignore: - goos: freebsd goarch: arm - goos: freebsd goarch: 386 - goos: freebsd goarch: ppc64le - goos: darwin goarch: 386 - goos: darwin goarch: arm - goos: darwin goarch: ppc64le - goos: windows goarch: ppc64le - goos: windows goarch: arm archives: - files: - LICENSE name_template: "{{ .Env.PROJECT_NAME }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" format_overrides: - goos: windows format: zip signs: - artifacts: checksum signature: "{{ .Env.PROJECT_NAME }}_{{ .Version }}_checksums.txt.sig" cmd: gpg args: [ "-u", "{{ .Env.GPG_KEY_ID }}", "--pinentry-mode", "loopback", "--yes", "--batch", "--output", "${signature}", "--detach-sign", "${artifact}" ] checksum: name_template: "{{ .Env.PROJECT_NAME }}_{{ .Version }}_checksums.txt" snapshot: name_template: "{{ .Timestamp }}-{{ .ShortCommit }}-SNAPSHOT" nfpms: - file_name_template: "{{ .Env.PROJECT_NAME }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" package_name: "semaphore" description: Modern UI and powerful API for Ansible, Terraform, OpenTofu, PowerShell and other DevOps tools. homepage: https://github.com/semaphoreui/semaphore vendor: Semaphore UI maintainer: Denis Gukov license: MIT formats: - deb - rpm dependencies: - git suggests: - ansible bindir: /usr/bin release: draft: true use_existing_draft: true name_template: "{{.Tag}}" ================================================ FILE: .postman/api ================================================ # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY apis[] = {"apiId":"4023cf7c-aabb-4d5a-a742-72dadbd4924a"} apis[] = {"apiId":"5306c424-9fc0-4923-be37-fbda305ca8de"} apis[] = {"apiId":"9a8524cc-4892-4b54-a6b3-1ef18d907626"} configVersion = 1.0.0 type = api ================================================ FILE: .postman/api_4023cf7c-aabb-4d5a-a742-72dadbd4924a ================================================ # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY configVersion = 1.1.0 type = apiEntityData [config] id = 4023cf7c-aabb-4d5a-a742-72dadbd4924a [config.relations] [config.relations.collections] rootDirectory = .postman/collections files[] = {"id":"2979975-3e2a871a-8dc1-4771-b62a-45f68caa2b1b","path":"Semaphore API Documentation.json","metaData":{"generateCollectionPreferences":"{\"requestNameSource\":\"Fallback\",\"indentCharacter\":\"Space\",\"parametersResolution\":\"Schema\",\"folderStrategy\":\"Paths\",\"includeAuthInfoInExample\":true,\"enableOptionalParameters\":true,\"keepImplicitHeaders\":false,\"includeDeprecated\":true,\"alwaysInheritAuthentication\":false,\"updateCollectionSync\":true,\"requestParametersResolution\":\"Schema\",\"exampleParametersResolution\":\"Schema\"}"}} [config.relations.collections.metaData] [config.relations.apiDefinition] files[] = {"path":"openapi.yml","metaData":{}} [config.relations.apiDefinition.metaData] type = openapi:3 rootFiles[] = openapi.yml ================================================ FILE: .postman/api_5306c424-9fc0-4923-be37-fbda305ca8de ================================================ # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY configVersion = 1.1.0 type = apiEntityData [config] id = 5306c424-9fc0-4923-be37-fbda305ca8de [config.relations] [config.relations.collections] rootDirectory = postman/collections [config.relations.collections.metaData] [config.relations.apiDefinition] files[] = {"path":"openapi.yml","metaData":{}} [config.relations.apiDefinition.metaData] type = openapi:3 rootFiles[] = openapi.yml ================================================ FILE: .postman/api_9a8524cc-4892-4b54-a6b3-1ef18d907626 ================================================ # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY configVersion = 1.1.0 type = apiEntityData [config] id = 9a8524cc-4892-4b54-a6b3-1ef18d907626 [config.relations] [config.relations.collections] rootDirectory = postman/collections files[] = {"id":"2979975-3d96a8d7-604d-47ec-832c-2876f64dbc1f","path":"Semaphore API.json","metaData":{}} [config.relations.collections.metaData] [config.relations.apiDefinition] files[] = {"path":"openapi.yml","metaData":{}} [config.relations.apiDefinition.metaData] type = openapi:3 rootFiles[] = openapi.yml ================================================ FILE: .postman/postman/collections/Semaphore API.json ================================================ { "info": { "_postman_id": "3d96a8d7-604d-47ec-832c-2876f64dbc1f", "name": "Semaphore API", "description": "Semaphore API provides endpoints for managing and interacting with the Semaphore UI.\nThis documentation outlines the available operations and data models.\n", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", "_uid": "2979975-3d96a8d7-604d-47ec-832c-2876f64dbc1f" }, "item": [ { "name": "ping", "item": [ { "name": "PING test", "id": "bf5268d0-7ded-46a5-81c6-e1ab34af548f", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "text/plain" } ], "url": { "raw": "{{baseUrl}}/ping", "host": [ "{{baseUrl}}" ], "path": [ "ping" ] } }, "response": [ { "id": "3bb6eb99-c489-4c1d-8c58-24943b3944fa", "name": "Successful \"PONG\" reply", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "text/plain" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/ping", "host": [ "{{baseUrl}}" ], "path": [ "ping" ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "text", "header": [ { "key": "Content-Type", "value": "text/plain" }, { "disabled": false, "description": { "content": "", "type": "text/plain" }, "key": "content-type", "value": "" } ], "cookie": [], "body": "" } ] } ], "id": "42b892bf-1031-4529-aaad-fc9cb0f0b39b" }, { "name": "ws", "item": [ { "name": "Websocket handler", "id": "0c5fb481-c1ae-4749-922b-9c204542ebe5", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [], "url": { "raw": "{{baseUrl}}/ws", "host": [ "{{baseUrl}}" ], "path": [ "ws" ] } }, "response": [ { "id": "806acad8-300d-49d7-b90d-efa215abf78b", "name": "OK", "originalRequest": { "method": "GET", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/ws", "host": [ "{{baseUrl}}" ], "path": [ "ws" ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "text", "header": [], "cookie": [] }, { "id": "3ddd1e5f-77ca-4004-a6df-6fba17dd91e3", "name": "not authenticated", "originalRequest": { "method": "GET", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/ws", "host": [ "{{baseUrl}}" ], "path": [ "ws" ] } }, "status": "Unauthorized", "code": 401, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "c4ce3efb-64a9-419e-ae7b-710d8daa6559" }, { "name": "info", "item": [ { "name": "Fetches information about semaphore", "id": "b24f5bef-31a9-4b6b-a88b-7335708054b4", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/info", "host": [ "{{baseUrl}}" ], "path": [ "info" ] }, "description": "you must be authenticated to use this" }, "response": [ { "id": "528e9198-1331-47b6-8683-26a4dee42897", "name": "ok", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/info", "host": [ "{{baseUrl}}" ], "path": [ "info" ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"version\": \"\",\n \"updateBody\": \"\",\n \"update\": {\n \"tag_name\": \"\"\n }\n}" } ] } ], "id": "f3b4c4c0-0266-46de-bfd9-ec0ca92cb8ed" }, { "name": "auth", "item": [ { "name": "login", "item": [ { "name": "Fetches login metadata", "id": "e54ef912-9074-4d4b-ac87-4a139deaae7e", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/auth/login", "host": [ "{{baseUrl}}" ], "path": [ "auth", "login" ] }, "description": "Fetches metadata for login, such as available OIDC providers" }, "response": [ { "id": "88c4efe4-14a7-4995-9f4e-5d799df9691c", "name": "Login metadata", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/auth/login", "host": [ "{{baseUrl}}" ], "path": [ "auth", "login" ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"oidc_providers\": [\n {\n \"id\": \"\",\n \"name\": \"\"\n },\n {\n \"id\": \"\",\n \"name\": \"\"\n }\n ]\n}" } ] }, { "name": "Performs Login", "id": "8e4e1cca-d595-4648-9926-cc59aa0d01c0", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"auth\": \"\",\n \"password\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/auth/login", "host": [ "{{baseUrl}}" ], "path": [ "auth", "login" ] }, "description": "Upon success you will be logged in" }, "response": [ { "id": "d46ea9c6-566d-4797-8257-481ea3ac387d", "name": "You are logged in", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"auth\": \"\",\n \"password\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/auth/login", "host": [ "{{baseUrl}}" ], "path": [ "auth", "login" ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] }, { "id": "d924516a-a87b-4e37-b0ee-37b861d55e44", "name": "something in body is missing / is invalid", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"auth\": \"\",\n \"password\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/auth/login", "host": [ "{{baseUrl}}" ], "path": [ "auth", "login" ] } }, "status": "Bad Request", "code": 400, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "aed9631c-e197-4069-85fd-d104db4a2935" }, { "name": "logout", "item": [ { "name": "Destroys current session", "id": "40410efc-af5e-427e-a028-f97718cb6836", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [], "url": { "raw": "{{baseUrl}}/auth/logout", "host": [ "{{baseUrl}}" ], "path": [ "auth", "logout" ] } }, "response": [ { "id": "67a542a6-eeab-4de1-8c4d-f4c97f061b40", "name": "Your session was successfully nuked", "originalRequest": { "method": "POST", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/auth/logout", "host": [ "{{baseUrl}}" ], "path": [ "auth", "logout" ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "56f5320b-b581-4f77-bc09-c6ff2b6650fe" }, { "name": "oidc", "item": [ { "name": "{provider_id}", "item": [ { "name": "login", "item": [ { "name": "Begin OIDC authentication flow and redirect to OIDC provider", "id": "e89b42ec-0f75-424d-983f-ec083a25629e", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [], "url": { "raw": "{{baseUrl}}/auth/oidc/:provider_id/login", "host": [ "{{baseUrl}}" ], "path": [ "auth", "oidc", ":provider_id", "login" ], "variable": [ { "key": "provider_id", "value": "", "description": "(Required) " } ] }, "description": "The user agent is redirected to this endpoint when chosing to sign in via OIDC" }, "response": [ { "id": "dc9ff176-1520-4d23-870e-6de15f0f0bfb", "name": "Redirection to the OIDC provider on success, or to the login page on error", "originalRequest": { "method": "GET", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/auth/oidc/:provider_id/login", "host": [ "{{baseUrl}}" ], "path": [ "auth", "oidc", ":provider_id", "login" ], "variable": [ { "key": "provider_id" } ] } }, "status": "Found", "code": 302, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "02b6aaf2-f76d-4f7e-9eef-7c1969106934" }, { "name": "redirect", "item": [ { "name": "Finish OIDC authentication flow, upon succes you will be logged in", "id": "a3f83744-c4b2-444d-b67d-0b9e1719b579", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [], "url": { "raw": "{{baseUrl}}/auth/oidc/:provider_id/redirect", "host": [ "{{baseUrl}}" ], "path": [ "auth", "oidc", ":provider_id", "redirect" ], "variable": [ { "key": "provider_id", "value": "", "description": "(Required) " } ] }, "description": "The user agent is redirected here by the OIDC provider to complete authentication" }, "response": [ { "id": "c961634e-53b3-464d-8700-1f7a50bd8930", "name": "Redirection to the Semaphore root URL on success, or to the login page on error", "originalRequest": { "method": "GET", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/auth/oidc/:provider_id/redirect", "host": [ "{{baseUrl}}" ], "path": [ "auth", "oidc", ":provider_id", "redirect" ], "variable": [ { "key": "provider_id" } ] } }, "status": "Found", "code": 302, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "09c8fe31-4e03-4951-9cfc-b35eb5788fdc" } ], "id": "4eb69a41-dc94-4d9a-ac10-b4052fefa5e0" } ], "id": "4097fae7-9877-4776-a607-4a91236eb004" } ], "id": "1c92c5b1-ef55-4bf0-8b0e-942fd4dbd4d6" }, { "name": "user", "item": [ { "name": "tokens", "item": [ { "name": "{api_token_id}", "item": [ { "name": "Expires API token", "id": "b73a8391-ac14-4de1-96e4-46211d2695a3", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "DELETE", "header": [], "url": { "raw": "{{baseUrl}}/user/tokens/:api_token_id", "host": [ "{{baseUrl}}" ], "path": [ "user", "tokens", ":api_token_id" ], "variable": [ { "key": "api_token_id", "value": "", "description": "(Required) " } ] } }, "response": [ { "id": "b065338e-0893-4b2e-a114-0fbebe9df480", "name": "Expired API Token", "originalRequest": { "method": "DELETE", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/user/tokens/:api_token_id", "host": [ "{{baseUrl}}" ], "path": [ "user", "tokens", ":api_token_id" ], "variable": [ { "key": "api_token_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "16a03dcb-86f4-4b13-9b0b-2ee6e40f277f" }, { "name": "Fetch API tokens for user", "id": "72430204-c78a-404d-a8b5-cf9b9535e8ec", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/user/tokens", "host": [ "{{baseUrl}}" ], "path": [ "user", "tokens" ] } }, "response": [ { "id": "a29abb44-c40e-4170-8715-7355facafe4f", "name": "API Tokens", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/user/tokens", "host": [ "{{baseUrl}}" ], "path": [ "user", "tokens" ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"id\": \"\",\n \"created\": \"\",\n \"expired\": \"\",\n \"user_id\": \"\"\n },\n {\n \"id\": \"\",\n \"created\": \"\",\n \"expired\": \"\",\n \"user_id\": \"\"\n }\n]" } ] }, { "name": "Create an API token", "id": "cfba220e-2ace-4bcd-8e8d-790ecc4c161b", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/user/tokens", "host": [ "{{baseUrl}}" ], "path": [ "user", "tokens" ] } }, "response": [ { "id": "ab320df0-a87c-4d98-8c66-b746965d8703", "name": "API Token", "originalRequest": { "method": "POST", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/user/tokens", "host": [ "{{baseUrl}}" ], "path": [ "user", "tokens" ] } }, "status": "Created", "code": 201, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"created\": \"\",\n \"expired\": \"\",\n \"user_id\": \"\"\n}" } ] } ], "id": "1dbfa3fd-5095-40b3-b767-9905075c41cc" }, { "name": "Fetch logged in user", "id": "fef2dcea-c231-4700-86e3-352a7509428f", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/user/", "host": [ "{{baseUrl}}" ], "path": [ "user", "" ] } }, "response": [ { "id": "8ac63913-9e63-4e07-9ce1-14e07dc34d20", "name": "User", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/user/", "host": [ "{{baseUrl}}" ], "path": [ "user", "" ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"name\": \"\",\n \"username\": \"\",\n \"email\": \"\",\n \"created\": \"\",\n \"alert\": \"\",\n \"admin\": \"\",\n \"external\": \"\"\n}" } ] } ], "id": "320427cd-4a4a-4466-a69a-32854273b1b2" }, { "name": "users", "item": [ { "name": "{user_id}", "item": [ { "name": "password", "item": [ { "name": "Updates user password", "id": "049ff59a-80e1-4b3d-b5c8-c153f0876b94", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"password\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/users/:user_id/password", "host": [ "{{baseUrl}}" ], "path": [ "users", ":user_id", "password" ], "variable": [ { "key": "user_id", "value": "", "description": "(Required) User ID" } ] } }, "response": [ { "id": "86c57d98-9b33-442d-be6e-700f3374685d", "name": "Password updated", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"password\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/users/:user_id/password", "host": [ "{{baseUrl}}" ], "path": [ "users", ":user_id", "password" ], "variable": [ { "key": "user_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "ff5732a1-5d72-4dcf-8d13-cb1a0352d82b" }, { "name": "Fetches a user profile", "id": "0ea82762-7ab3-4be4-a7bc-07c12c6443cd", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/users/:user_id/", "host": [ "{{baseUrl}}" ], "path": [ "users", ":user_id", "" ], "variable": [ { "key": "user_id", "value": "", "description": "(Required) User ID" } ] } }, "response": [ { "id": "5a9e436e-a27e-490c-aa04-2931ec671450", "name": "User profile", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/users/:user_id/", "host": [ "{{baseUrl}}" ], "path": [ "users", ":user_id", "" ], "variable": [ { "key": "user_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"name\": \"\",\n \"username\": \"\",\n \"email\": \"\",\n \"created\": \"\",\n \"alert\": \"\",\n \"admin\": \"\",\n \"external\": \"\"\n}" } ] }, { "name": "Updates user details", "id": "f16b9520-e006-4f1c-a1d6-e1c112a6c737", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"username\": \"\",\n \"email\": \"\",\n \"alert\": \"\",\n \"admin\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/users/:user_id/", "host": [ "{{baseUrl}}" ], "path": [ "users", ":user_id", "" ], "variable": [ { "key": "user_id", "value": "", "description": "(Required) User ID" } ] } }, "response": [ { "id": "8ecb3f54-ba8a-454a-af8a-bd45b3487d81", "name": "User Updated", "originalRequest": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"username\": \"\",\n \"email\": \"\",\n \"alert\": \"\",\n \"admin\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/users/:user_id/", "host": [ "{{baseUrl}}" ], "path": [ "users", ":user_id", "" ], "variable": [ { "key": "user_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] }, { "name": "Deletes user", "id": "ab4724a5-0e60-4c1a-a097-cb43677e968f", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "DELETE", "header": [], "url": { "raw": "{{baseUrl}}/users/:user_id/", "host": [ "{{baseUrl}}" ], "path": [ "users", ":user_id", "" ], "variable": [ { "key": "user_id", "value": "", "description": "(Required) User ID" } ] } }, "response": [ { "id": "f7a4e7b7-740f-425c-b9bb-238c1e77a2b2", "name": "User deleted", "originalRequest": { "method": "DELETE", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/users/:user_id/", "host": [ "{{baseUrl}}" ], "path": [ "users", ":user_id", "" ], "variable": [ { "key": "user_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "a1b50f3a-4823-4aec-a930-4dc2eb3e0d5d" }, { "name": "Fetches all users", "id": "507c369c-033e-4267-8bf2-ae66c9ac3599", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/users", "host": [ "{{baseUrl}}" ], "path": [ "users" ] } }, "response": [ { "id": "99e6869a-f9a1-4255-9000-bddace414eed", "name": "Users", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/users", "host": [ "{{baseUrl}}" ], "path": [ "users" ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"id\": \"\",\n \"name\": \"\",\n \"username\": \"\",\n \"email\": \"\",\n \"created\": \"\",\n \"alert\": \"\",\n \"admin\": \"\",\n \"external\": \"\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"username\": \"\",\n \"email\": \"\",\n \"created\": \"\",\n \"alert\": \"\",\n \"admin\": \"\",\n \"external\": \"\"\n }\n]" } ] }, { "name": "Creates a user", "id": "1f9aeee5-29d9-45a5-a8c2-85cc1837a96a", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"username\": \"\",\n \"email\": \"\",\n \"password\": \"\",\n \"alert\": \"\",\n \"admin\": \"\",\n \"external\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/users", "host": [ "{{baseUrl}}" ], "path": [ "users" ] } }, "response": [ { "id": "5056ee51-12cb-45ac-97ec-2007d3ea55c1", "name": "User created", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"username\": \"\",\n \"email\": \"\",\n \"password\": \"\",\n \"alert\": \"\",\n \"admin\": \"\",\n \"external\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/users", "host": [ "{{baseUrl}}" ], "path": [ "users" ] } }, "status": "Created", "code": 201, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"name\": \"\",\n \"username\": \"\",\n \"email\": \"\",\n \"created\": \"\",\n \"alert\": \"\",\n \"admin\": \"\",\n \"external\": \"\"\n}" }, { "id": "07889849-9235-4c9b-b0a1-8f208cfe0f68", "name": "User creation failed", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"username\": \"\",\n \"email\": \"\",\n \"password\": \"\",\n \"alert\": \"\",\n \"admin\": \"\",\n \"external\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/users", "host": [ "{{baseUrl}}" ], "path": [ "users" ] } }, "status": "Bad Request", "code": 400, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "daedae70-868f-4c67-b451-92b64d11f389" }, { "name": "projects", "item": [ { "name": "restore", "item": [ { "name": "Restore Project", "id": "ad7d9848-0a4c-4429-8f00-a9c41d0b8b65", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"meta\": {\n \"name\": \"\",\n \"alert\": \"\",\n \"max_parallel_tasks\": \"\"\n },\n \"templates\": [\n {\n \"inventory\": \"\",\n \"repository\": \"\",\n \"environment\": \"\",\n \"view\": \"\",\n \"name\": \"\",\n \"playbook\": \"\",\n \"description\": \"\",\n \"allow_override_args_in_task\": \"\",\n \"suppress_success_alerts\": \"\",\n \"autorun\": \"\",\n \"type\": \"\",\n \"allow_override_branch_in_task\": \"\"\n },\n {\n \"inventory\": \"\",\n \"repository\": \"\",\n \"environment\": \"\",\n \"view\": \"\",\n \"name\": \"\",\n \"playbook\": \"\",\n \"description\": \"\",\n \"allow_override_args_in_task\": \"\",\n \"suppress_success_alerts\": \"\",\n \"autorun\": \"\",\n \"type\": \"\",\n \"allow_override_branch_in_task\": \"\"\n }\n ],\n \"repositories\": [\n {\n \"name\": \"\",\n \"git_url\": \"\",\n \"git_branch\": \"\",\n \"ssh_key\": \"\"\n },\n {\n \"name\": \"\",\n \"git_url\": \"\",\n \"git_branch\": \"\",\n \"ssh_key\": \"\"\n }\n ],\n \"keys\": [\n {\n \"name\": \"\",\n \"type\": \"login_password\"\n },\n {\n \"name\": \"\",\n \"type\": \"ssh\"\n }\n ],\n \"views\": [\n {\n \"name\": \"\",\n \"position\": \"\"\n },\n {\n \"name\": \"\",\n \"position\": \"\"\n }\n ],\n \"inventories\": [\n {\n \"name\": \"\",\n \"inventory\": \"\",\n \"type\": \"static-yaml\"\n },\n {\n \"name\": \"\",\n \"inventory\": \"\",\n \"type\": \"static\"\n }\n ],\n \"environments\": [\n {\n \"name\": \"\",\n \"json\": \"\"\n },\n {\n \"name\": \"\",\n \"json\": \"\"\n }\n ]\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/projects/restore", "host": [ "{{baseUrl}}" ], "path": [ "projects", "restore" ] } }, "response": [ { "id": "8120a0a7-787f-47f5-b595-dd8dab10efd4", "name": "Created project", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"meta\": {\n \"name\": \"\",\n \"alert\": \"\",\n \"max_parallel_tasks\": \"\"\n },\n \"templates\": [\n {\n \"inventory\": \"\",\n \"repository\": \"\",\n \"environment\": \"\",\n \"view\": \"\",\n \"name\": \"\",\n \"playbook\": \"\",\n \"description\": \"\",\n \"allow_override_args_in_task\": \"\",\n \"suppress_success_alerts\": \"\",\n \"autorun\": \"\",\n \"type\": \"\",\n \"allow_override_branch_in_task\": \"\"\n },\n {\n \"inventory\": \"\",\n \"repository\": \"\",\n \"environment\": \"\",\n \"view\": \"\",\n \"name\": \"\",\n \"playbook\": \"\",\n \"description\": \"\",\n \"allow_override_args_in_task\": \"\",\n \"suppress_success_alerts\": \"\",\n \"autorun\": \"\",\n \"type\": \"\",\n \"allow_override_branch_in_task\": \"\"\n }\n ],\n \"repositories\": [\n {\n \"name\": \"\",\n \"git_url\": \"\",\n \"git_branch\": \"\",\n \"ssh_key\": \"\"\n },\n {\n \"name\": \"\",\n \"git_url\": \"\",\n \"git_branch\": \"\",\n \"ssh_key\": \"\"\n }\n ],\n \"keys\": [\n {\n \"name\": \"\",\n \"type\": \"login_password\"\n },\n {\n \"name\": \"\",\n \"type\": \"ssh\"\n }\n ],\n \"views\": [\n {\n \"name\": \"\",\n \"position\": \"\"\n },\n {\n \"name\": \"\",\n \"position\": \"\"\n }\n ],\n \"inventories\": [\n {\n \"name\": \"\",\n \"inventory\": \"\",\n \"type\": \"static-yaml\"\n },\n {\n \"name\": \"\",\n \"inventory\": \"\",\n \"type\": \"static\"\n }\n ],\n \"environments\": [\n {\n \"name\": \"\",\n \"json\": \"\"\n },\n {\n \"name\": \"\",\n \"json\": \"\"\n }\n ]\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/projects/restore", "host": [ "{{baseUrl}}" ], "path": [ "projects", "restore" ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"name\": \"\",\n \"created\": \"\",\n \"alert\": \"\",\n \"max_parallel_tasks\": \"\"\n}" } ] } ], "id": "75f5f172-970b-46f9-9749-ea60fe0de02f" }, { "name": "Get projects", "id": "db08f6e5-34e8-4cce-b433-a8e001649532", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/projects", "host": [ "{{baseUrl}}" ], "path": [ "projects" ] } }, "response": [ { "id": "4465385b-232d-49e1-b0ba-f076805edba2", "name": "List of projects", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/projects", "host": [ "{{baseUrl}}" ], "path": [ "projects" ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"id\": \"\",\n \"name\": \"\",\n \"created\": \"\",\n \"alert\": \"\",\n \"max_parallel_tasks\": \"\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"created\": \"\",\n \"alert\": \"\",\n \"max_parallel_tasks\": \"\"\n }\n]" } ] }, { "name": "Create a new project", "id": "d836bcbc-10a2-48fd-a189-f7e2d546f7a0", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"alert\": \"\",\n \"max_parallel_tasks\": \"\",\n \"demo\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/projects", "host": [ "{{baseUrl}}" ], "path": [ "projects" ] } }, "response": [ { "id": "85c1a2ae-bfcb-410e-9ef1-10f33ce9a4a3", "name": "Created project", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"alert\": \"\",\n \"max_parallel_tasks\": \"\",\n \"demo\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/projects", "host": [ "{{baseUrl}}" ], "path": [ "projects" ] } }, "status": "Created", "code": 201, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"name\": \"\",\n \"created\": \"\",\n \"alert\": \"\",\n \"max_parallel_tasks\": \"\"\n}" } ] } ], "id": "1ca11787-296e-4f18-8098-b3863a3697ae" }, { "name": "events", "item": [ { "name": "last", "item": [ { "name": "Get last 200 Events related to Semaphore and projects you are part of", "id": "716eed97-b5c6-436b-9815-13ee33dfb791", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/events/last", "host": [ "{{baseUrl}}" ], "path": [ "events", "last" ] } }, "response": [ { "id": "194afa5f-39ef-4da7-8f57-f3646379e74c", "name": "Array of events in chronological order", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/events/last", "host": [ "{{baseUrl}}" ], "path": [ "events", "last" ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"project_id\": \"\",\n \"user_id\": \"\",\n \"description\": \"\"\n },\n {\n \"project_id\": \"\",\n \"user_id\": \"\",\n \"description\": \"\"\n }\n]" } ] } ], "id": "9aa87430-581f-4df2-b5b0-0e907eb51d8c" }, { "name": "Get Events related to Semaphore and projects you are part of", "id": "8892a736-a9e9-4e04-9fbf-7318e18bcb69", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/events", "host": [ "{{baseUrl}}" ], "path": [ "events" ] } }, "response": [ { "id": "0df3b266-9969-4b81-9c49-5365b5718f7c", "name": "Array of events in chronological order", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/events", "host": [ "{{baseUrl}}" ], "path": [ "events" ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"project_id\": \"\",\n \"user_id\": \"\",\n \"description\": \"\"\n },\n {\n \"project_id\": \"\",\n \"user_id\": \"\",\n \"description\": \"\"\n }\n]" } ] } ], "id": "633c1663-c41d-4cbe-813e-c86e157d7b1f" }, { "name": "project", "item": [ { "name": "{project_id}", "item": [ { "name": "backup", "item": [ { "name": "Backup A Project", "id": "cf6cb225-f9c2-4834-9966-fba7247c941c", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/backup", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "backup" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "9ed64511-9b95-4241-b139-521f01d7a9fe", "name": "Backup", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/backup", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "backup" ], "variable": [ { "key": "project_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"meta\": {\n \"name\": \"\",\n \"alert\": \"\",\n \"max_parallel_tasks\": \"\"\n },\n \"templates\": [\n {\n \"inventory\": \"\",\n \"repository\": \"\",\n \"environment\": \"\",\n \"view\": \"\",\n \"name\": \"\",\n \"playbook\": \"\",\n \"description\": \"\",\n \"allow_override_args_in_task\": \"\",\n \"suppress_success_alerts\": \"\",\n \"autorun\": \"\",\n \"type\": \"\",\n \"allow_override_branch_in_task\": \"\"\n },\n {\n \"inventory\": \"\",\n \"repository\": \"\",\n \"environment\": \"\",\n \"view\": \"\",\n \"name\": \"\",\n \"playbook\": \"\",\n \"description\": \"\",\n \"allow_override_args_in_task\": \"\",\n \"suppress_success_alerts\": \"\",\n \"autorun\": \"\",\n \"type\": \"\",\n \"allow_override_branch_in_task\": \"\"\n }\n ],\n \"repositories\": [\n {\n \"name\": \"\",\n \"git_url\": \"\",\n \"git_branch\": \"\",\n \"ssh_key\": \"\"\n },\n {\n \"name\": \"\",\n \"git_url\": \"\",\n \"git_branch\": \"\",\n \"ssh_key\": \"\"\n }\n ],\n \"keys\": [\n {\n \"name\": \"\",\n \"type\": \"login_password\"\n },\n {\n \"name\": \"\",\n \"type\": \"ssh\"\n }\n ],\n \"views\": [\n {\n \"name\": \"\",\n \"position\": \"\"\n },\n {\n \"name\": \"\",\n \"position\": \"\"\n }\n ],\n \"inventories\": [\n {\n \"name\": \"\",\n \"inventory\": \"\",\n \"type\": \"static-yaml\"\n },\n {\n \"name\": \"\",\n \"inventory\": \"\",\n \"type\": \"static\"\n }\n ],\n \"environments\": [\n {\n \"name\": \"\",\n \"json\": \"\"\n },\n {\n \"name\": \"\",\n \"json\": \"\"\n }\n ]\n}" } ] } ], "id": "5d358a00-f511-471c-abfe-4fa03d4aae12" }, { "name": "role", "item": [ { "name": "Fetch permissions of the current user for project", "id": "acf0720e-0c03-4c38-b272-c9465782f650", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/role", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "role" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "6ebbb1a2-26f5-49bd-95c1-eb91f9ce555c", "name": "Permissions", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/role", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "role" ], "variable": [ { "key": "project_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"role\": \"\",\n \"permissions\": \"\"\n}" } ] } ], "id": "f38f0344-5967-413a-afc9-3b23f8f29ff9" }, { "name": "events", "item": [ { "name": "Get Events related to this project", "id": "b0e3b287-b69c-4164-9e5f-99285e393c7f", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/events", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "events" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "bb0b55e4-3368-4841-beb9-fdcf9194bc6c", "name": "Array of events in chronological order", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/events", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "events" ], "variable": [ { "key": "project_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"project_id\": \"\",\n \"user_id\": \"\",\n \"description\": \"\"\n },\n {\n \"project_id\": \"\",\n \"user_id\": \"\",\n \"description\": \"\"\n }\n]" } ] } ], "id": "59ab5972-7a4b-4796-b0b9-c3b3409533d8" }, { "name": "users", "item": [ { "name": "{user_id}", "item": [ { "name": "Update user role", "id": "ed021401-fce4-4cd5-8bbd-3ac2bcd23f8f", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"role\": \"manager\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/users/:user_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "users", ":user_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "user_id", "value": "", "description": "(Required) User ID" } ] } }, "response": [ { "id": "8159e830-af1a-4567-9427-59a8c19b1022", "name": "User updated", "originalRequest": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"role\": \"manager\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/users/:user_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "users", ":user_id" ], "variable": [ { "key": "project_id" }, { "key": "user_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] }, { "name": "Removes user from project", "id": "12114ff7-c872-4699-ae98-535db41cbab0", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "DELETE", "header": [], "url": { "raw": "{{baseUrl}}/project/:project_id/users/:user_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "users", ":user_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "user_id", "value": "", "description": "(Required) User ID" } ] } }, "response": [ { "id": "65245997-98d2-4ca9-b5f7-bbe8a54c5f07", "name": "User removed", "originalRequest": { "method": "DELETE", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/users/:user_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "users", ":user_id" ], "variable": [ { "key": "project_id" }, { "key": "user_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "93b0c15b-1858-42e0-9a4b-d5714fde836b" }, { "name": "Get users linked to project", "id": "0fbedd92-e7f1-4207-9952-1fa11841630d", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/users?sort=email&order=asc", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "users" ], "query": [ { "key": "sort", "value": "email", "description": "(Required) sorting name" }, { "key": "order", "value": "asc", "description": "(Required) ordering manner" } ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "ee4a34bb-2053-461c-bdef-479d47ca6586", "name": "Users", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/users?sort=email&order=asc", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "users" ], "query": [ { "description": "(Required) sorting name", "key": "sort", "value": "email" }, { "description": "(Required) ordering manner", "key": "order", "value": "asc" } ], "variable": [ { "key": "project_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"id\": \"\",\n \"name\": \"\",\n \"username\": \"\",\n \"role\": \"task_runner\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"username\": \"\",\n \"role\": \"guest\"\n }\n]" } ] }, { "name": "Link user to project", "id": "97f0f4f1-d226-43d8-8c77-ffd4ac2baa2d", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"user_id\": \"\",\n \"role\": \"guest\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/users", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "users" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "53628ba9-1548-4f99-8d47-3de43502cba4", "name": "User added", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"user_id\": \"\",\n \"role\": \"guest\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/users", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "users" ], "variable": [ { "key": "project_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "b25959e0-7fde-4233-a588-4685ccef8d96" }, { "name": "integrations", "item": [ { "name": "{integration_id}", "item": [ { "name": "values", "item": [ { "name": "{extractvalue_id}", "item": [ { "name": "Updates Integration ExtractValue", "id": "7bc07454-80f6-4b60-ba54-2d2a7f1a41c4", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"value_source\": \"body\",\n \"body_data_type\": \"xml\",\n \"key\": \"\",\n \"variable\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/values/:extractvalue_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "values", ":extractvalue_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "integration_id", "value": "", "description": "(Required) integration ID" }, { "key": "extractvalue_id", "value": "", "description": "(Required) extractValue ID" } ] } }, "response": [ { "id": "bf03df0a-7546-4864-97f9-2ccf4a741e0e", "name": "Integration Extract Value updated", "originalRequest": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"value_source\": \"body\",\n \"body_data_type\": \"xml\",\n \"key\": \"\",\n \"variable\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/values/:extractvalue_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "values", ":extractvalue_id" ], "variable": [ { "key": "project_id" }, { "key": "integration_id" }, { "key": "extractvalue_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] }, { "id": "aea21b21-fbef-4bdd-8101-c9caeeaf438c", "name": "Bad integration extract value parameter", "originalRequest": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"value_source\": \"body\",\n \"body_data_type\": \"xml\",\n \"key\": \"\",\n \"variable\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/values/:extractvalue_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "values", ":extractvalue_id" ], "variable": [ { "key": "project_id" }, { "key": "integration_id" }, { "key": "extractvalue_id" } ] } }, "status": "Bad Request", "code": 400, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] }, { "name": "Removes integration extract value", "id": "2ca39f14-a5e8-49ff-b133-c6c1bca56451", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "DELETE", "header": [], "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/values/:extractvalue_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "values", ":extractvalue_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "integration_id", "value": "", "description": "(Required) integration ID" }, { "key": "extractvalue_id", "value": "", "description": "(Required) extractValue ID" } ] } }, "response": [ { "id": "c94d1edb-7b83-4549-b097-3c9c0bac7976", "name": "integration extract value removed", "originalRequest": { "method": "DELETE", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/values/:extractvalue_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "values", ":extractvalue_id" ], "variable": [ { "key": "project_id" }, { "key": "integration_id" }, { "key": "extractvalue_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "aa589140-c673-4193-8546-efcc1b05f69f" }, { "name": "Get Integration Extracted Values linked to integration extractor", "id": "5cf57ab1-acbb-46cd-a6b2-d8e6bc40daf2", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/values", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "values" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "integration_id", "value": "", "description": "(Required) integration ID" } ] } }, "response": [ { "id": "bd8a3fa9-b988-4e23-8ed2-6b7367bc8e1d", "name": "Integration Extracted Value", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/values", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "values" ], "variable": [ { "key": "project_id" }, { "key": "integration_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"id\": \"\",\n \"name\": \"\",\n \"value_source\": \"header\",\n \"body_data_type\": \"xml\",\n \"key\": \"\",\n \"variable\": \"\",\n \"integration_id\": \"\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"value_source\": \"body\",\n \"body_data_type\": \"json\",\n \"key\": \"\",\n \"variable\": \"\",\n \"integration_id\": \"\"\n }\n]" } ] }, { "name": "Add Integration Extracted Value", "id": "6bdc4a7f-e460-469c-a469-0341b8370ac7", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"value_source\": \"body\",\n \"body_data_type\": \"json\",\n \"key\": \"\",\n \"variable\": \"\",\n \"integration_id\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/values", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "values" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "integration_id", "value": "", "description": "(Required) integration ID" } ] } }, "response": [ { "id": "1f5d1e06-c87a-4ca8-989d-739850fca285", "name": "Integration Extract Value Created", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"value_source\": \"body\",\n \"body_data_type\": \"json\",\n \"key\": \"\",\n \"variable\": \"\",\n \"integration_id\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/values", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "values" ], "variable": [ { "key": "project_id" }, { "key": "integration_id" } ] } }, "status": "Created", "code": 201, "_postman_previewlanguage": "text", "header": [], "cookie": [] }, { "id": "c6e6f7ce-d622-4b6e-953e-7a1583b9c1e8", "name": "Bad Integration Extract Value params", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"value_source\": \"body\",\n \"body_data_type\": \"json\",\n \"key\": \"\",\n \"variable\": \"\",\n \"integration_id\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/values", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "values" ], "variable": [ { "key": "project_id" }, { "key": "integration_id" } ] } }, "status": "Bad Request", "code": 400, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "f13b63ad-1ac9-421b-af74-d434f6ec2229" }, { "name": "matchers", "item": [ { "name": "{matcher_id}", "item": [ { "name": "Updates Integration Matcher", "id": "5a5a6603-e57d-4069-b8dd-92a81587a48b", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"match_type\": \"header\",\n \"method\": \"unequals\",\n \"body_data_type\": \"string\",\n \"key\": \"\",\n \"value\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/matchers/:matcher_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "matchers", ":matcher_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "integration_id", "value": "", "description": "(Required) integration ID" }, { "key": "matcher_id", "value": "", "description": "(Required) matcher ID" } ] } }, "response": [ { "id": "1de952e3-9106-49aa-9d98-24726337831c", "name": "Integration Matcher updated", "originalRequest": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"match_type\": \"header\",\n \"method\": \"unequals\",\n \"body_data_type\": \"string\",\n \"key\": \"\",\n \"value\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/matchers/:matcher_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "matchers", ":matcher_id" ], "variable": [ { "key": "project_id" }, { "key": "integration_id" }, { "key": "matcher_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] }, { "id": "8bd5c3dd-b348-4907-bf8b-05b45ff4be64", "name": "Bad integration matcher parameter", "originalRequest": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"match_type\": \"header\",\n \"method\": \"unequals\",\n \"body_data_type\": \"string\",\n \"key\": \"\",\n \"value\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/matchers/:matcher_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "matchers", ":matcher_id" ], "variable": [ { "key": "project_id" }, { "key": "integration_id" }, { "key": "matcher_id" } ] } }, "status": "Bad Request", "code": 400, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] }, { "name": "Removes integration matcher", "id": "7e0f2c38-30f7-4143-aaac-6484f9471488", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "DELETE", "header": [], "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/matchers/:matcher_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "matchers", ":matcher_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "integration_id", "value": "", "description": "(Required) integration ID" }, { "key": "matcher_id", "value": "", "description": "(Required) matcher ID" } ] } }, "response": [ { "id": "31eb06ac-981e-4f60-8df3-f8db9920209b", "name": "integration matcher removed", "originalRequest": { "method": "DELETE", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/matchers/:matcher_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "matchers", ":matcher_id" ], "variable": [ { "key": "project_id" }, { "key": "integration_id" }, { "key": "matcher_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "eaed6943-5538-4a01-b11d-3578b07d22f9" }, { "name": "Get Integration Matcher linked to integration extractor", "id": "b56401dd-3f7c-4254-ba23-df7b3be86fba", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/matchers", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "matchers" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "integration_id", "value": "", "description": "(Required) integration ID" } ] } }, "response": [ { "id": "4bc110c9-378e-4301-bea4-a844247976b4", "name": "Integration Matcher", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/matchers", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "matchers" ], "variable": [ { "key": "project_id" }, { "key": "integration_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"id\": \"\",\n \"integration_id\": \"\",\n \"name\": \"\",\n \"match_type\": \"header\",\n \"method\": \"contains\",\n \"body_data_type\": \"xml\",\n \"key\": \"\",\n \"value\": \"\"\n },\n {\n \"id\": \"\",\n \"integration_id\": \"\",\n \"name\": \"\",\n \"match_type\": \"header\",\n \"method\": \"contains\",\n \"body_data_type\": \"string\",\n \"key\": \"\",\n \"value\": \"\"\n }\n]" } ] }, { "name": "Add Integration Matcher", "id": "5ca5997a-7a11-436b-9247-dcb9c673e5c0", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"integration_id\": \"\",\n \"name\": \"\",\n \"match_type\": \"body\",\n \"method\": \"unequals\",\n \"body_data_type\": \"string\",\n \"key\": \"\",\n \"value\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/matchers", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "matchers" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "integration_id", "value": "", "description": "(Required) integration ID" } ] } }, "response": [ { "id": "4e1dfc52-7d79-4f61-a42f-ea4e6573d77d", "name": "Integration Matcher Created", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"integration_id\": \"\",\n \"name\": \"\",\n \"match_type\": \"body\",\n \"method\": \"unequals\",\n \"body_data_type\": \"string\",\n \"key\": \"\",\n \"value\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/matchers", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "matchers" ], "variable": [ { "key": "project_id" }, { "key": "integration_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "text", "header": [], "cookie": [] }, { "id": "36f62c44-29f8-4bf0-bd79-55d60c05db75", "name": "Bad Integration Matcher params", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"integration_id\": \"\",\n \"name\": \"\",\n \"match_type\": \"body\",\n \"method\": \"unequals\",\n \"body_data_type\": \"string\",\n \"key\": \"\",\n \"value\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id/matchers", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id", "matchers" ], "variable": [ { "key": "project_id" }, { "key": "integration_id" } ] } }, "status": "Bad Request", "code": 400, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "3c6158f6-91c7-4fc0-a482-f5c229a25b1b" }, { "name": "Update Integration", "id": "dfd20cf5-7786-414b-8b77-453def5d4f2a", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"project_id\": \"\",\n \"template_id\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "integration_id", "value": "", "description": "(Required) integration ID" } ] } }, "response": [ { "id": "b39b6e3b-11b0-4395-8dea-3f59cb9731fd", "name": "Integration updated", "originalRequest": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"project_id\": \"\",\n \"template_id\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id" ], "variable": [ { "key": "project_id" }, { "key": "integration_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] }, { "name": "Remove integration", "id": "3eddc27b-b7ad-4637-9fb7-94a4b3d6c20d", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "DELETE", "header": [], "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "integration_id", "value": "", "description": "(Required) integration ID" } ] } }, "response": [ { "id": "312dcf9b-dfa2-4f40-99eb-b577202e13fb", "name": "integration removed", "originalRequest": { "method": "DELETE", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/integrations/:integration_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations", ":integration_id" ], "variable": [ { "key": "project_id" }, { "key": "integration_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "957493da-989f-4e03-b3a0-5304f986484c" }, { "name": "get all integrations", "id": "b56e2c78-362f-4c1b-bf81-ef13810ccebf", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/integrations", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "b0b3686a-3546-4ea2-b61a-c546247869fd", "name": "integration", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/integrations", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations" ], "variable": [ { "key": "project_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"template_id\": \"\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"template_id\": \"\"\n }\n]" } ] }, { "name": "create a new integration", "id": "668408ed-f5a3-4d28-b781-251757a1e8c1", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"project_id\": \"\",\n \"template_id\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/integrations", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "2247a74b-77f8-44ef-8641-f09deb85cbbc", "name": "Integration Created", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"project_id\": \"\",\n \"template_id\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/integrations", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "integrations" ], "variable": [ { "key": "project_id" } ] } }, "status": "Created", "code": 201, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"template_id\": \"\"\n}" } ] } ], "id": "cc77f4b2-985f-47dd-b66b-2a193f624821" }, { "name": "keys", "item": [ { "name": "{key_id}", "item": [ { "name": "Updates access key", "id": "1c962787-570e-43a6-ba2e-4d1e6c0d275e", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"none\",\n \"project_id\": \"\",\n \"override_secret\": \"\",\n \"login_password\": {\n \"password\": \"\",\n \"login\": \"\"\n },\n \"ssh\": {\n \"login\": \"\",\n \"passphrase\": \"\",\n \"private_key\": \"\"\n }\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/keys/:key_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "keys", ":key_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "key_id", "value": "", "description": "(Required) key ID" } ] } }, "response": [ { "id": "5efe88c0-5ff6-4efd-a4f2-659b2853775c", "name": "Key updated", "originalRequest": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"none\",\n \"project_id\": \"\",\n \"override_secret\": \"\",\n \"login_password\": {\n \"password\": \"\",\n \"login\": \"\"\n },\n \"ssh\": {\n \"login\": \"\",\n \"passphrase\": \"\",\n \"private_key\": \"\"\n }\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/keys/:key_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "keys", ":key_id" ], "variable": [ { "key": "project_id" }, { "key": "key_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] }, { "id": "1cf9aa9c-cb98-4f7c-8403-9f1f7a5567c3", "name": "Bad type", "originalRequest": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"none\",\n \"project_id\": \"\",\n \"override_secret\": \"\",\n \"login_password\": {\n \"password\": \"\",\n \"login\": \"\"\n },\n \"ssh\": {\n \"login\": \"\",\n \"passphrase\": \"\",\n \"private_key\": \"\"\n }\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/keys/:key_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "keys", ":key_id" ], "variable": [ { "key": "project_id" }, { "key": "key_id" } ] } }, "status": "Bad Request", "code": 400, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] }, { "name": "Removes access key", "id": "d2b3a57f-5d9c-49cf-8f15-8c6963c7c455", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "DELETE", "header": [], "url": { "raw": "{{baseUrl}}/project/:project_id/keys/:key_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "keys", ":key_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "key_id", "value": "", "description": "(Required) key ID" } ] } }, "response": [ { "id": "30d1c47b-485b-4ec1-85b0-77473b2d685b", "name": "access key removed", "originalRequest": { "method": "DELETE", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/keys/:key_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "keys", ":key_id" ], "variable": [ { "key": "project_id" }, { "key": "key_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "fab6ac75-0c50-44b3-95d0-5b9812d14e4d" }, { "name": "Get access keys linked to project", "id": "19a11020-22d0-4f44-b9ba-a1fab45a4011", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/keys?Key type=ssh&sort=name&order=asc", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "keys" ], "query": [ { "key": "Key type", "value": "ssh", "description": "Filter by key type" }, { "key": "sort", "value": "name", "description": "(Required) sorting name" }, { "key": "order", "value": "asc", "description": "(Required) ordering manner" } ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "4f2cef7f-81ae-409f-b9be-c50737a6a2bb", "name": "Access Keys", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/keys?Key type=ssh&sort=name&order=asc", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "keys" ], "query": [ { "description": "Filter by key type", "key": "Key type", "value": "ssh" }, { "description": "(Required) sorting name", "key": "sort", "value": "name" }, { "description": "(Required) ordering manner", "key": "order", "value": "asc" } ], "variable": [ { "key": "project_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"ssh\",\n \"project_id\": \"\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"none\",\n \"project_id\": \"\"\n }\n]" } ] }, { "name": "Add access key", "id": "f4c4e359-0bd2-41ec-9624-2a9877f6cba3", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"none\",\n \"project_id\": \"\",\n \"override_secret\": \"\",\n \"login_password\": {\n \"password\": \"\",\n \"login\": \"\"\n },\n \"ssh\": {\n \"login\": \"\",\n \"passphrase\": \"\",\n \"private_key\": \"\"\n }\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/keys", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "keys" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "a6fc4ed8-aac4-4877-98ab-03c13f8e72e7", "name": "Access Key created", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"none\",\n \"project_id\": \"\",\n \"override_secret\": \"\",\n \"login_password\": {\n \"password\": \"\",\n \"login\": \"\"\n },\n \"ssh\": {\n \"login\": \"\",\n \"passphrase\": \"\",\n \"private_key\": \"\"\n }\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/keys", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "keys" ], "variable": [ { "key": "project_id" } ] } }, "status": "Created", "code": 201, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"ssh\",\n \"project_id\": \"\"\n}" }, { "id": "59b6d52e-da84-44c9-851e-d4393afa9a46", "name": "Bad type", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"none\",\n \"project_id\": \"\",\n \"override_secret\": \"\",\n \"login_password\": {\n \"password\": \"\",\n \"login\": \"\"\n },\n \"ssh\": {\n \"login\": \"\",\n \"passphrase\": \"\",\n \"private_key\": \"\"\n }\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/keys", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "keys" ], "variable": [ { "key": "project_id" } ] } }, "status": "Bad Request", "code": 400, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "c880a7dd-6b66-40a7-adf2-d065646a2946" }, { "name": "repositories", "item": [ { "name": "{repository_id}", "item": [ { "name": "Get repository", "id": "872bcd9d-c4a9-4969-ae1c-910fd0c8ef1b", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/repositories/:repository_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "repositories", ":repository_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "repository_id", "value": "", "description": "(Required) repository ID" } ] } }, "response": [ { "id": "90366237-fdd2-43a4-94ca-c069a339eaf4", "name": "repository object", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/repositories/:repository_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "repositories", ":repository_id" ], "variable": [ { "key": "project_id" }, { "key": "repository_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"git_url\": \"\",\n \"git_branch\": \"\",\n \"ssh_key_id\": \"\"\n}" } ] }, { "name": "Updates repository", "id": "cd6dc4a3-e081-4e8e-90b7-f30c538a32a8", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"git_url\": \"\",\n \"git_branch\": \"\",\n \"ssh_key_id\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/repositories/:repository_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "repositories", ":repository_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "repository_id", "value": "", "description": "(Required) repository ID" } ] } }, "response": [ { "id": "b6e72d6f-17b3-42fa-9982-ba4a235da1ba", "name": "Repository updated", "originalRequest": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"git_url\": \"\",\n \"git_branch\": \"\",\n \"ssh_key_id\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/repositories/:repository_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "repositories", ":repository_id" ], "variable": [ { "key": "project_id" }, { "key": "repository_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] }, { "id": "be0a890a-de74-4e2d-ac29-c96031c6cc97", "name": "Bad request", "originalRequest": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"git_url\": \"\",\n \"git_branch\": \"\",\n \"ssh_key_id\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/repositories/:repository_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "repositories", ":repository_id" ], "variable": [ { "key": "project_id" }, { "key": "repository_id" } ] } }, "status": "Bad Request", "code": 400, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] }, { "name": "Removes repository", "id": "9d84b566-30bd-41d0-b3b8-b3cbbaae5f7b", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "DELETE", "header": [], "url": { "raw": "{{baseUrl}}/project/:project_id/repositories/:repository_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "repositories", ":repository_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "repository_id", "value": "", "description": "(Required) repository ID" } ] } }, "response": [ { "id": "d420f1b5-beeb-4299-bd2c-e250b485d09c", "name": "repository removed", "originalRequest": { "method": "DELETE", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/repositories/:repository_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "repositories", ":repository_id" ], "variable": [ { "key": "project_id" }, { "key": "repository_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "327fb141-5c84-448f-bbaa-6b383df1b468" }, { "name": "Get repositories", "id": "53ca0d32-ac28-4715-9e2c-d17296d19564", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/repositories?sort=ssh_key&order=asc", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "repositories" ], "query": [ { "key": "sort", "value": "ssh_key", "description": "(Required) sorting name" }, { "key": "order", "value": "asc", "description": "(Required) ordering manner" } ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "42b8cdb1-d0b8-4d42-8540-41adef65476a", "name": "repositories", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/repositories?sort=ssh_key&order=asc", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "repositories" ], "query": [ { "description": "(Required) sorting name", "key": "sort", "value": "ssh_key" }, { "description": "(Required) ordering manner", "key": "order", "value": "asc" } ], "variable": [ { "key": "project_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"git_url\": \"\",\n \"git_branch\": \"\",\n \"ssh_key_id\": \"\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"git_url\": \"\",\n \"git_branch\": \"\",\n \"ssh_key_id\": \"\"\n }\n]" } ] }, { "name": "Add repository", "id": "0c8f0684-42b5-4ea9-8ac1-8c37c7f71298", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"git_url\": \"\",\n \"git_branch\": \"\",\n \"ssh_key_id\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/repositories", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "repositories" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "a493b80e-2207-4770-9be2-d34d602ee639", "name": "Repository created", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"git_url\": \"\",\n \"git_branch\": \"\",\n \"ssh_key_id\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/repositories", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "repositories" ], "variable": [ { "key": "project_id" } ] } }, "status": "Created", "code": 201, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"git_url\": \"\",\n \"git_branch\": \"\",\n \"ssh_key_id\": \"\"\n}" } ] } ], "id": "6d392179-addc-4252-9a34-daedcf6d09a0" }, { "name": "inventory", "item": [ { "name": "{inventory_id}", "item": [ { "name": "Get inventory", "id": "7d723843-fd62-48f4-a29a-fbeae54cee7d", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/inventory/:inventory_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "inventory", ":inventory_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "inventory_id", "value": "", "description": "(Required) inventory ID" } ] } }, "response": [ { "id": "9f0a6546-326f-43cb-90f3-8807271704d0", "name": "inventory object", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/inventory/:inventory_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "inventory", ":inventory_id" ], "variable": [ { "key": "project_id" }, { "key": "inventory_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"inventory\": \"\",\n \"ssh_key_id\": \"\",\n \"become_key_id\": \"\",\n \"repository_id\": \"\",\n \"type\": \"static\"\n}" } ] }, { "name": "Updates inventory", "id": "709e9a94-8876-4d32-94cc-d600d21e3cf9", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"inventory\": \"\",\n \"ssh_key_id\": \"\",\n \"become_key_id\": \"\",\n \"repository_id\": \"\",\n \"type\": \"static\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/inventory/:inventory_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "inventory", ":inventory_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "inventory_id", "value": "", "description": "(Required) inventory ID" } ] } }, "response": [ { "id": "fcabbd06-a18f-4a1c-b69c-046173f60b0a", "name": "Inventory updated", "originalRequest": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"inventory\": \"\",\n \"ssh_key_id\": \"\",\n \"become_key_id\": \"\",\n \"repository_id\": \"\",\n \"type\": \"static\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/inventory/:inventory_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "inventory", ":inventory_id" ], "variable": [ { "key": "project_id" }, { "key": "inventory_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] }, { "name": "Removes inventory", "id": "ee7ecd8a-5fd9-4aea-b54e-abcecd137a60", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "DELETE", "header": [], "url": { "raw": "{{baseUrl}}/project/:project_id/inventory/:inventory_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "inventory", ":inventory_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "inventory_id", "value": "", "description": "(Required) inventory ID" } ] } }, "response": [ { "id": "c282c49b-a279-4c2f-80d6-398cfdca1cc8", "name": "inventory removed", "originalRequest": { "method": "DELETE", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/inventory/:inventory_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "inventory", ":inventory_id" ], "variable": [ { "key": "project_id" }, { "key": "inventory_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "e34a0ff9-8cf0-41f6-8301-17b2ca43a702" }, { "name": "Get inventory", "id": "6f0c2597-d220-4e2f-ae84-089432c4dcb9", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/inventory?sort=name&order=asc", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "inventory" ], "query": [ { "key": "sort", "value": "name", "description": "(Required) sorting name" }, { "key": "order", "value": "asc", "description": "(Required) ordering manner" } ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "f26dd388-68b3-4ca5-8ddd-b120f35ef51f", "name": "inventory", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/inventory?sort=name&order=asc", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "inventory" ], "query": [ { "description": "(Required) sorting name", "key": "sort", "value": "name" }, { "description": "(Required) ordering manner", "key": "order", "value": "asc" } ], "variable": [ { "key": "project_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"inventory\": \"\",\n \"ssh_key_id\": \"\",\n \"become_key_id\": \"\",\n \"repository_id\": \"\",\n \"type\": \"terraform-workspace\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"inventory\": \"\",\n \"ssh_key_id\": \"\",\n \"become_key_id\": \"\",\n \"repository_id\": \"\",\n \"type\": \"static-yaml\"\n }\n]" } ] }, { "name": "create inventory", "id": "56c4bcfc-069f-4182-ae99-7878b7aa0896", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"inventory\": \"\",\n \"ssh_key_id\": \"\",\n \"become_key_id\": \"\",\n \"repository_id\": \"\",\n \"type\": \"static\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/inventory", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "inventory" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "170f0c0d-fd4a-48a7-8730-e0bb2789a661", "name": "inventory created", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"inventory\": \"\",\n \"ssh_key_id\": \"\",\n \"become_key_id\": \"\",\n \"repository_id\": \"\",\n \"type\": \"static\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/inventory", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "inventory" ], "variable": [ { "key": "project_id" } ] } }, "status": "Created", "code": 201, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"inventory\": \"\",\n \"ssh_key_id\": \"\",\n \"become_key_id\": \"\",\n \"repository_id\": \"\",\n \"type\": \"static\"\n}" } ] } ], "id": "68b2c9da-f46e-4140-9e66-c4cb0960c938" }, { "name": "environment", "item": [ { "name": "{environment_id}", "item": [ { "name": "Get environment", "id": "bb14e4a5-1395-4a3c-bd60-f3d24ec85385", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/environment/:environment_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "environment", ":environment_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "environment_id", "value": "", "description": "(Required) environment ID" } ] } }, "response": [ { "id": "8e0eb9d5-48ec-4959-ab9c-167d9204e260", "name": "environment object", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/environment/:environment_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "environment", ":environment_id" ], "variable": [ { "key": "project_id" }, { "key": "environment_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"password\": \"\",\n \"json\": \"\",\n \"env\": \"\",\n \"secrets\": [\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"env\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"env\"\n }\n ]\n}" } ] }, { "name": "Update environment", "id": "ee85eded-0bf2-4f2e-b8a0-6c36d7691823", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"password\": \"\",\n \"json\": \"\",\n \"env\": \"\",\n \"secrets\": [\n {\n \"id\": \"\",\n \"name\": \"\",\n \"secret\": \"\",\n \"type\": \"env\",\n \"operation\": \"update\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"secret\": \"\",\n \"type\": \"var\",\n \"operation\": \"create\"\n }\n ]\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/environment/:environment_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "environment", ":environment_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "environment_id", "value": "", "description": "(Required) environment ID" } ] } }, "response": [ { "id": "c8d7184a-98fe-4988-ac25-34d367956c50", "name": "Environment Updated", "originalRequest": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"password\": \"\",\n \"json\": \"\",\n \"env\": \"\",\n \"secrets\": [\n {\n \"id\": \"\",\n \"name\": \"\",\n \"secret\": \"\",\n \"type\": \"env\",\n \"operation\": \"update\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"secret\": \"\",\n \"type\": \"var\",\n \"operation\": \"create\"\n }\n ]\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/environment/:environment_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "environment", ":environment_id" ], "variable": [ { "key": "project_id" }, { "key": "environment_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] }, { "name": "Removes environment", "id": "98ceba89-88f4-49cb-a9ec-e162e0908045", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "DELETE", "header": [], "url": { "raw": "{{baseUrl}}/project/:project_id/environment/:environment_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "environment", ":environment_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "environment_id", "value": "", "description": "(Required) environment ID" } ] } }, "response": [ { "id": "9aa29a4c-8c47-4509-884c-2a46ac8edf74", "name": "environment removed", "originalRequest": { "method": "DELETE", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/environment/:environment_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "environment", ":environment_id" ], "variable": [ { "key": "project_id" }, { "key": "environment_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "0bf894f6-e139-4ff8-8dd1-55f4c17cd54d" }, { "name": "Get environment", "id": "b30a4604-8606-4f8b-8bd5-6ca9b424985f", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/environment?sort=&order=", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "environment" ], "query": [ { "key": "sort", "value": "", "description": "(Required) sorting name" }, { "key": "order", "value": "", "description": "(Required) ordering manner" } ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "00eb66ba-8c4a-46d3-a2d5-da0e260619dd", "name": "environment", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/environment?sort=&order=", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "environment" ], "query": [ { "description": "(Required) sorting name", "key": "sort", "value": "" }, { "description": "(Required) ordering manner", "key": "order", "value": "" } ], "variable": [ { "key": "project_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"password\": \"\",\n \"json\": \"\",\n \"env\": \"\",\n \"secrets\": [\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"var\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"var\"\n }\n ]\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"password\": \"\",\n \"json\": \"\",\n \"env\": \"\",\n \"secrets\": [\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"env\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"var\"\n }\n ]\n }\n]" } ] }, { "name": "Add environment", "id": "f4fa5c06-6af3-4b75-8ed5-2ef4d4e8e66e", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"password\": \"\",\n \"json\": \"\",\n \"env\": \"\",\n \"secrets\": [\n {\n \"id\": \"\",\n \"name\": \"\",\n \"secret\": \"\",\n \"type\": \"env\",\n \"operation\": \"update\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"secret\": \"\",\n \"type\": \"var\",\n \"operation\": \"create\"\n }\n ]\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/environment", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "environment" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "5e0a2865-0a27-4777-a8c0-86c0fce63a7b", "name": "Environment created", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"password\": \"\",\n \"json\": \"\",\n \"env\": \"\",\n \"secrets\": [\n {\n \"id\": \"\",\n \"name\": \"\",\n \"secret\": \"\",\n \"type\": \"env\",\n \"operation\": \"update\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"secret\": \"\",\n \"type\": \"var\",\n \"operation\": \"create\"\n }\n ]\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/environment", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "environment" ], "variable": [ { "key": "project_id" } ] } }, "status": "Created", "code": 201, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"name\": \"\",\n \"project_id\": \"\",\n \"password\": \"\",\n \"json\": \"\",\n \"env\": \"\",\n \"secrets\": [\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"env\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"env\"\n }\n ]\n}" } ] } ], "id": "c5e31002-3b4f-47f7-b348-0ccee09582f7" }, { "name": "templates", "item": [ { "name": "{template_id}", "item": [ { "name": "Get template", "id": "104660cc-eda5-4562-ba18-657d17c1ad72", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/templates/:template_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "templates", ":template_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "template_id", "value": "", "description": "(Required) template ID" } ] } }, "response": [ { "id": "63c823fa-e752-472d-8ae8-5bc484f3c22f", "name": "template object", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/templates/:template_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "templates", ":template_id" ], "variable": [ { "key": "project_id" }, { "key": "template_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"project_id\": \"\",\n \"inventory_id\": \"\",\n \"repository_id\": \"\",\n \"environment_id\": \"\",\n \"view_id\": \"\",\n \"name\": \"\",\n \"playbook\": \"\",\n \"arguments\": \"\",\n \"description\": \"\",\n \"allow_override_args_in_task\": \"\",\n \"suppress_success_alerts\": \"\",\n \"app\": \"\",\n \"git_branch\": \"\",\n \"type\": \"\",\n \"autorun\": \"\",\n \"survey_vars\": [\n {\n \"name\": \"\",\n \"title\": \"\",\n \"description\": \"\",\n \"type\": \"enum\",\n \"required\": \"\",\n \"values\": [\n {\n \"name\": \"\",\n \"value\": \"\"\n },\n {\n \"name\": \"\",\n \"value\": \"\"\n }\n ]\n },\n {\n \"name\": \"\",\n \"title\": \"\",\n \"description\": \"\",\n \"type\": \"int\",\n \"required\": \"\",\n \"values\": [\n {\n \"name\": \"\",\n \"value\": \"\"\n },\n {\n \"name\": \"\",\n \"value\": \"\"\n }\n ]\n }\n ],\n \"vaults\": [\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"script\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"script\"\n }\n ]\n}" } ] }, { "name": "Updates template", "id": "63b251a6-9c9f-4862-a367-96c33f832154", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"project_id\": \"\",\n \"inventory_id\": \"\",\n \"repository_id\": \"\",\n \"environment_id\": \"\",\n \"view_id\": \"\",\n \"vaults\": [\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"script\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"script\"\n }\n ],\n \"name\": \"\",\n \"playbook\": \"\",\n \"arguments\": \"\",\n \"description\": \"\",\n \"allow_override_args_in_task\": \"\",\n \"limit\": \"\",\n \"suppress_success_alerts\": \"\",\n \"app\": \"\",\n \"git_branch\": \"\",\n \"survey_vars\": [\n {\n \"name\": \"\",\n \"title\": \"\",\n \"description\": \"\",\n \"type\": \"\",\n \"required\": \"\",\n \"values\": [\n {\n \"name\": \"\",\n \"value\": \"\"\n },\n {\n \"name\": \"\",\n \"value\": \"\"\n }\n ]\n },\n {\n \"name\": \"\",\n \"title\": \"\",\n \"description\": \"\",\n \"type\": \"int\",\n \"required\": \"\",\n \"values\": [\n {\n \"name\": \"\",\n \"value\": \"\"\n },\n {\n \"name\": \"\",\n \"value\": \"\"\n }\n ]\n }\n ],\n \"type\": \"deploy\",\n \"start_version\": \"\",\n \"build_template_id\": \"\",\n \"autorun\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/templates/:template_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "templates", ":template_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "template_id", "value": "", "description": "(Required) template ID" } ] } }, "response": [ { "id": "2d3dc5b2-8443-43e9-a552-e0df9d827944", "name": "template updated", "originalRequest": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"project_id\": \"\",\n \"inventory_id\": \"\",\n \"repository_id\": \"\",\n \"environment_id\": \"\",\n \"view_id\": \"\",\n \"vaults\": [\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"script\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"script\"\n }\n ],\n \"name\": \"\",\n \"playbook\": \"\",\n \"arguments\": \"\",\n \"description\": \"\",\n \"allow_override_args_in_task\": \"\",\n \"limit\": \"\",\n \"suppress_success_alerts\": \"\",\n \"app\": \"\",\n \"git_branch\": \"\",\n \"survey_vars\": [\n {\n \"name\": \"\",\n \"title\": \"\",\n \"description\": \"\",\n \"type\": \"\",\n \"required\": \"\",\n \"values\": [\n {\n \"name\": \"\",\n \"value\": \"\"\n },\n {\n \"name\": \"\",\n \"value\": \"\"\n }\n ]\n },\n {\n \"name\": \"\",\n \"title\": \"\",\n \"description\": \"\",\n \"type\": \"int\",\n \"required\": \"\",\n \"values\": [\n {\n \"name\": \"\",\n \"value\": \"\"\n },\n {\n \"name\": \"\",\n \"value\": \"\"\n }\n ]\n }\n ],\n \"type\": \"deploy\",\n \"start_version\": \"\",\n \"build_template_id\": \"\",\n \"autorun\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/templates/:template_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "templates", ":template_id" ], "variable": [ { "key": "project_id" }, { "key": "template_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] }, { "name": "Removes template", "id": "31a2f18c-594d-487b-ac4e-88de98d7d94c", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "DELETE", "header": [], "url": { "raw": "{{baseUrl}}/project/:project_id/templates/:template_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "templates", ":template_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "template_id", "value": "", "description": "(Required) template ID" } ] } }, "response": [ { "id": "f0cac3e9-f1c9-42ba-9bfc-1409d707f935", "name": "template removed", "originalRequest": { "method": "DELETE", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/templates/:template_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "templates", ":template_id" ], "variable": [ { "key": "project_id" }, { "key": "template_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "139525b7-c9fe-4fa1-b7eb-5b3d15aa21ea" }, { "name": "Get template", "id": "f4a3de02-10a4-4663-af97-95ed6bedae2f", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/templates?sort=ssh_key&order=asc", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "templates" ], "query": [ { "key": "sort", "value": "ssh_key", "description": "(Required) sorting name" }, { "key": "order", "value": "asc", "description": "(Required) ordering manner" } ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "0a0cd102-5c86-4b89-9b91-79128e81b042", "name": "template", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/templates?sort=ssh_key&order=asc", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "templates" ], "query": [ { "description": "(Required) sorting name", "key": "sort", "value": "ssh_key" }, { "description": "(Required) ordering manner", "key": "order", "value": "asc" } ], "variable": [ { "key": "project_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"id\": \"\",\n \"project_id\": \"\",\n \"inventory_id\": \"\",\n \"repository_id\": \"\",\n \"environment_id\": \"\",\n \"view_id\": \"\",\n \"name\": \"\",\n \"playbook\": \"\",\n \"arguments\": \"\",\n \"description\": \"\",\n \"allow_override_args_in_task\": \"\",\n \"suppress_success_alerts\": \"\",\n \"app\": \"\",\n \"git_branch\": \"\",\n \"type\": \"\",\n \"autorun\": \"\",\n \"survey_vars\": [\n {\n \"name\": \"\",\n \"title\": \"\",\n \"description\": \"\",\n \"type\": \"int\",\n \"required\": \"\",\n \"values\": [\n {\n \"name\": \"\",\n \"value\": \"\"\n },\n {\n \"name\": \"\",\n \"value\": \"\"\n }\n ]\n },\n {\n \"name\": \"\",\n \"title\": \"\",\n \"description\": \"\",\n \"type\": \"secret\",\n \"required\": \"\",\n \"values\": [\n {\n \"name\": \"\",\n \"value\": \"\"\n },\n {\n \"name\": \"\",\n \"value\": \"\"\n }\n ]\n }\n ],\n \"vaults\": [\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"script\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"script\"\n }\n ]\n },\n {\n \"id\": \"\",\n \"project_id\": \"\",\n \"inventory_id\": \"\",\n \"repository_id\": \"\",\n \"environment_id\": \"\",\n \"view_id\": \"\",\n \"name\": \"\",\n \"playbook\": \"\",\n \"arguments\": \"\",\n \"description\": \"\",\n \"allow_override_args_in_task\": \"\",\n \"suppress_success_alerts\": \"\",\n \"app\": \"\",\n \"git_branch\": \"\",\n \"type\": \"deploy\",\n \"autorun\": \"\",\n \"survey_vars\": [\n {\n \"name\": \"\",\n \"title\": \"\",\n \"description\": \"\",\n \"type\": \"secret\",\n \"required\": \"\",\n \"values\": [\n {\n \"name\": \"\",\n \"value\": \"\"\n },\n {\n \"name\": \"\",\n \"value\": \"\"\n }\n ]\n },\n {\n \"name\": \"\",\n \"title\": \"\",\n \"description\": \"\",\n \"type\": \"\",\n \"required\": \"\",\n \"values\": [\n {\n \"name\": \"\",\n \"value\": \"\"\n },\n {\n \"name\": \"\",\n \"value\": \"\"\n }\n ]\n }\n ],\n \"vaults\": [\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"script\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"password\"\n }\n ]\n }\n]" } ] }, { "name": "create template", "id": "7fa27285-92fe-48c2-bbb6-74730edf4fbb", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"project_id\": \"\",\n \"inventory_id\": \"\",\n \"repository_id\": \"\",\n \"environment_id\": \"\",\n \"view_id\": \"\",\n \"vaults\": [\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"script\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"script\"\n }\n ],\n \"name\": \"\",\n \"playbook\": \"\",\n \"arguments\": \"\",\n \"description\": \"\",\n \"allow_override_args_in_task\": \"\",\n \"limit\": \"\",\n \"suppress_success_alerts\": \"\",\n \"app\": \"\",\n \"git_branch\": \"\",\n \"survey_vars\": [\n {\n \"name\": \"\",\n \"title\": \"\",\n \"description\": \"\",\n \"type\": \"\",\n \"required\": \"\",\n \"values\": [\n {\n \"name\": \"\",\n \"value\": \"\"\n },\n {\n \"name\": \"\",\n \"value\": \"\"\n }\n ]\n },\n {\n \"name\": \"\",\n \"title\": \"\",\n \"description\": \"\",\n \"type\": \"int\",\n \"required\": \"\",\n \"values\": [\n {\n \"name\": \"\",\n \"value\": \"\"\n },\n {\n \"name\": \"\",\n \"value\": \"\"\n }\n ]\n }\n ],\n \"type\": \"deploy\",\n \"start_version\": \"\",\n \"build_template_id\": \"\",\n \"autorun\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/templates", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "templates" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "bf21b47e-88ad-4842-8adf-71a874f2b264", "name": "template created", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"project_id\": \"\",\n \"inventory_id\": \"\",\n \"repository_id\": \"\",\n \"environment_id\": \"\",\n \"view_id\": \"\",\n \"vaults\": [\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"script\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"script\"\n }\n ],\n \"name\": \"\",\n \"playbook\": \"\",\n \"arguments\": \"\",\n \"description\": \"\",\n \"allow_override_args_in_task\": \"\",\n \"limit\": \"\",\n \"suppress_success_alerts\": \"\",\n \"app\": \"\",\n \"git_branch\": \"\",\n \"survey_vars\": [\n {\n \"name\": \"\",\n \"title\": \"\",\n \"description\": \"\",\n \"type\": \"\",\n \"required\": \"\",\n \"values\": [\n {\n \"name\": \"\",\n \"value\": \"\"\n },\n {\n \"name\": \"\",\n \"value\": \"\"\n }\n ]\n },\n {\n \"name\": \"\",\n \"title\": \"\",\n \"description\": \"\",\n \"type\": \"int\",\n \"required\": \"\",\n \"values\": [\n {\n \"name\": \"\",\n \"value\": \"\"\n },\n {\n \"name\": \"\",\n \"value\": \"\"\n }\n ]\n }\n ],\n \"type\": \"deploy\",\n \"start_version\": \"\",\n \"build_template_id\": \"\",\n \"autorun\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/templates", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "templates" ], "variable": [ { "key": "project_id" } ] } }, "status": "Created", "code": 201, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"project_id\": \"\",\n \"inventory_id\": \"\",\n \"repository_id\": \"\",\n \"environment_id\": \"\",\n \"view_id\": \"\",\n \"name\": \"\",\n \"playbook\": \"\",\n \"arguments\": \"\",\n \"description\": \"\",\n \"allow_override_args_in_task\": \"\",\n \"suppress_success_alerts\": \"\",\n \"app\": \"\",\n \"git_branch\": \"\",\n \"type\": \"\",\n \"autorun\": \"\",\n \"survey_vars\": [\n {\n \"name\": \"\",\n \"title\": \"\",\n \"description\": \"\",\n \"type\": \"enum\",\n \"required\": \"\",\n \"values\": [\n {\n \"name\": \"\",\n \"value\": \"\"\n },\n {\n \"name\": \"\",\n \"value\": \"\"\n }\n ]\n },\n {\n \"name\": \"\",\n \"title\": \"\",\n \"description\": \"\",\n \"type\": \"int\",\n \"required\": \"\",\n \"values\": [\n {\n \"name\": \"\",\n \"value\": \"\"\n },\n {\n \"name\": \"\",\n \"value\": \"\"\n }\n ]\n }\n ],\n \"vaults\": [\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"script\"\n },\n {\n \"id\": \"\",\n \"name\": \"\",\n \"type\": \"script\"\n }\n ]\n}" } ] } ], "id": "68b7154b-7174-420c-8659-d198191455fa" }, { "name": "schedules", "item": [ { "name": "{schedule_id}", "item": [ { "name": "Get schedule", "id": "65c16c30-9d22-4410-846f-debceac2b745", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/schedules/:schedule_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "schedules", ":schedule_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "schedule_id", "value": "", "description": "(Required) schedule ID" } ] } }, "response": [ { "id": "0d37ff14-9e52-4668-8c2a-0ef7243abdd2", "name": "Schedule", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/schedules/:schedule_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "schedules", ":schedule_id" ], "variable": [ { "key": "project_id" }, { "key": "schedule_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"cron_format\": \"\",\n \"project_id\": \"\",\n \"template_id\": \"\",\n \"name\": \"\",\n \"active\": \"\"\n}" } ] }, { "name": "Updates schedule", "id": "83df279b-afd4-4dce-bc75-d08b29ae42eb", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"cron_format\": \"\",\n \"project_id\": \"\",\n \"template_id\": \"\",\n \"name\": \"\",\n \"active\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/schedules/:schedule_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "schedules", ":schedule_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "schedule_id", "value": "", "description": "(Required) schedule ID" } ] } }, "response": [ { "id": "9264c4a4-db34-4933-afa5-e024f7f7cb2e", "name": "schedule updated", "originalRequest": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"cron_format\": \"\",\n \"project_id\": \"\",\n \"template_id\": \"\",\n \"name\": \"\",\n \"active\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/schedules/:schedule_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "schedules", ":schedule_id" ], "variable": [ { "key": "project_id" }, { "key": "schedule_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] }, { "name": "Deletes schedule", "id": "b075b5c5-0a22-4041-b372-c7a3774575a3", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "DELETE", "header": [], "url": { "raw": "{{baseUrl}}/project/:project_id/schedules/:schedule_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "schedules", ":schedule_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "schedule_id", "value": "", "description": "(Required) schedule ID" } ] } }, "response": [ { "id": "7d206099-71b9-4644-a3ba-0e1fc79c0a8a", "name": "schedule deleted", "originalRequest": { "method": "DELETE", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/schedules/:schedule_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "schedules", ":schedule_id" ], "variable": [ { "key": "project_id" }, { "key": "schedule_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "fd3ae42c-4cd6-43d5-a2c6-cf62f55e504f" }, { "name": "create schedule", "id": "4b2e5541-b4b1-4bf8-bc3c-051e98456ea8", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"cron_format\": \"\",\n \"project_id\": \"\",\n \"template_id\": \"\",\n \"name\": \"\",\n \"active\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/schedules", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "schedules" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "a2c71909-8d45-4c52-ad26-c0a52f903205", "name": "schedule created", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"id\": \"\",\n \"cron_format\": \"\",\n \"project_id\": \"\",\n \"template_id\": \"\",\n \"name\": \"\",\n \"active\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/schedules", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "schedules" ], "variable": [ { "key": "project_id" } ] } }, "status": "Created", "code": 201, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"cron_format\": \"\",\n \"project_id\": \"\",\n \"template_id\": \"\",\n \"name\": \"\",\n \"active\": \"\"\n}" } ] } ], "id": "0e1ab1e1-0a47-4016-9381-208f69f3aac3" }, { "name": "views", "item": [ { "name": "{view_id}", "item": [ { "name": "Get view", "id": "d3340dc1-7c34-4fc1-b9e8-620deb2176d7", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/views/:view_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "views", ":view_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "view_id", "value": "", "description": "(Required) view ID" } ] } }, "response": [ { "id": "89a245aa-8f3f-4fa0-b428-736d21d44b08", "name": "view object", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/views/:view_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "views", ":view_id" ], "variable": [ { "key": "project_id" }, { "key": "view_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"title\": \"\",\n \"project_id\": \"\",\n \"position\": \"\"\n}" } ] }, { "name": "Updates view", "id": "dd0e4697-0753-4c35-be75-9e3afc004381", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"title\": \"\",\n \"project_id\": \"\",\n \"position\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/views/:view_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "views", ":view_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "view_id", "value": "", "description": "(Required) view ID" } ] } }, "response": [ { "id": "fbaf6219-0ddf-49c6-a755-05e4381120ad", "name": "view updated", "originalRequest": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"title\": \"\",\n \"project_id\": \"\",\n \"position\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/views/:view_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "views", ":view_id" ], "variable": [ { "key": "project_id" }, { "key": "view_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] }, { "name": "Removes view", "id": "e6495c34-79a7-4809-ada5-ed5b668a0458", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "DELETE", "header": [], "url": { "raw": "{{baseUrl}}/project/:project_id/views/:view_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "views", ":view_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "view_id", "value": "", "description": "(Required) view ID" } ] } }, "response": [ { "id": "ad5a11cb-56db-4969-aa54-adca4cc8208f", "name": "view removed", "originalRequest": { "method": "DELETE", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/views/:view_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "views", ":view_id" ], "variable": [ { "key": "project_id" }, { "key": "view_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "cfa391bb-784b-4ba1-8df5-1350918bf061" }, { "name": "Get view", "id": "29ca97a9-c34d-41df-ab09-067e78b4c257", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/views", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "views" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "15243995-542e-4963-a403-6f9c276c2bdf", "name": "view", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/views", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "views" ], "variable": [ { "key": "project_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"id\": \"\",\n \"title\": \"\",\n \"project_id\": \"\",\n \"position\": \"\"\n },\n {\n \"id\": \"\",\n \"title\": \"\",\n \"project_id\": \"\",\n \"position\": \"\"\n }\n]" } ] }, { "name": "create view", "id": "61d2d149-2936-4274-a1a2-26b835c57839", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"title\": \"\",\n \"project_id\": \"\",\n \"position\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/views", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "views" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "3a6133df-7c5f-4e39-8315-18a3d5229e34", "name": "view created", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"title\": \"\",\n \"project_id\": \"\",\n \"position\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/views", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "views" ], "variable": [ { "key": "project_id" } ] } }, "status": "Created", "code": 201, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"title\": \"\",\n \"project_id\": \"\",\n \"position\": \"\"\n}" } ] } ], "id": "5af42808-2c2a-4a54-94d2-4a537c140f0a" }, { "name": "tasks", "item": [ { "name": "last", "item": [ { "name": "Get last 200 Tasks related to current project", "id": "39af373b-f117-48ef-9adc-fc164dd8982a", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/tasks/last", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "tasks", "last" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "4ba7fd9f-7ce6-4533-9ff6-251c9489dfa3", "name": "Array of tasks in chronological order", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/tasks/last", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "tasks", "last" ], "variable": [ { "key": "project_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"id\": \"\",\n \"template_id\": \"\",\n \"status\": \"\",\n \"debug\": \"\",\n \"playbook\": \"\",\n \"environment\": \"\",\n \"secret\": \"\",\n \"limit\": \"\",\n \"message\": \"\"\n },\n {\n \"id\": \"\",\n \"template_id\": \"\",\n \"status\": \"\",\n \"debug\": \"\",\n \"playbook\": \"\",\n \"environment\": \"\",\n \"secret\": \"\",\n \"limit\": \"\",\n \"message\": \"\"\n }\n]" } ] } ], "id": "659b921d-20e1-4ebf-a31d-932a2bd0424e" }, { "name": "{task_id}", "item": [ { "name": "stop", "item": [ { "name": "Stop a job", "id": "cb2d3afc-c82d-48c9-a7f3-148bed58547a", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [], "url": { "raw": "{{baseUrl}}/project/:project_id/tasks/:task_id/stop", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "tasks", ":task_id", "stop" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "task_id", "value": "", "description": "(Required) task ID" } ] } }, "response": [ { "id": "9e9dbcc6-2252-4284-a9b6-baa0cc13a8ba", "name": "Task queued", "originalRequest": { "method": "POST", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/tasks/:task_id/stop", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "tasks", ":task_id", "stop" ], "variable": [ { "key": "project_id" }, { "key": "task_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "8dad53c7-e78b-48c3-a33f-895f642c4f62" }, { "name": "output", "item": [ { "name": "Get task output", "id": "8df8642f-0e71-42d5-8ff3-6d45f6e88c6c", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/tasks/:task_id/output", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "tasks", ":task_id", "output" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "task_id", "value": "", "description": "(Required) task ID" } ] } }, "response": [ { "id": "cc08bfb5-bd10-4c18-940f-2194c9567d07", "name": "output", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/tasks/:task_id/output", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "tasks", ":task_id", "output" ], "variable": [ { "key": "project_id" }, { "key": "task_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"task_id\": \"\",\n \"time\": \"\",\n \"output\": \"\"\n },\n {\n \"task_id\": \"\",\n \"time\": \"\",\n \"output\": \"\"\n }\n]" } ] } ], "id": "3b429255-22f0-4e3b-8280-ac4a32805cbd" }, { "name": "raw_output", "item": [ { "name": "Get task raw output", "id": "328dca7d-e259-49aa-8b71-f39515b09e15", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [], "url": { "raw": "{{baseUrl}}/project/:project_id/tasks/:task_id/raw_output", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "tasks", ":task_id", "raw_output" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "task_id", "value": "", "description": "(Required) task ID" } ] } }, "response": [ { "id": "9f6cd61a-17f9-43e3-b08d-2780c4873926", "name": "output", "originalRequest": { "method": "GET", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/tasks/:task_id/raw_output", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "tasks", ":task_id", "raw_output" ], "variable": [ { "key": "project_id" }, { "key": "task_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "text", "header": [ { "disabled": false, "description": { "content": "", "type": "text/plain" }, "key": "content-type", "value": "" } ], "cookie": [] } ] } ], "id": "890592eb-ee91-434c-8f96-5c1900964ad2" }, { "name": "Get a single task", "id": "0d0dcc7b-60e7-4328-8768-b88d600230eb", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/tasks/:task_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "tasks", ":task_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "task_id", "value": "", "description": "(Required) task ID" } ] } }, "response": [ { "id": "ad5d835b-71b8-4993-8759-34b8d1ee456d", "name": "Task", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/tasks/:task_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "tasks", ":task_id" ], "variable": [ { "key": "project_id" }, { "key": "task_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"template_id\": \"\",\n \"status\": \"\",\n \"debug\": \"\",\n \"playbook\": \"\",\n \"environment\": \"\",\n \"secret\": \"\",\n \"limit\": \"\",\n \"message\": \"\"\n}" } ] }, { "name": "Deletes task (including output)", "id": "36b22ee0-aee3-4006-9ad6-774bcd54475e", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "DELETE", "header": [], "url": { "raw": "{{baseUrl}}/project/:project_id/tasks/:task_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "tasks", ":task_id" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" }, { "key": "task_id", "value": "", "description": "(Required) task ID" } ] } }, "response": [ { "id": "37a671c8-36c4-4ee1-b90e-be545ee45270", "name": "task deleted", "originalRequest": { "method": "DELETE", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/tasks/:task_id", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "tasks", ":task_id" ], "variable": [ { "key": "project_id" }, { "key": "task_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "a2742d7d-b84b-4d89-8bd8-f60140d15e3b" }, { "name": "Get Tasks related to current project", "id": "53013abf-22ff-45d3-a694-79a0ab5932fe", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/tasks", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "tasks" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "c0581133-5871-42f6-81c0-fe3b70df095e", "name": "Array of tasks in chronological order", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/tasks", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "tasks" ], "variable": [ { "key": "project_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[\n {\n \"id\": \"\",\n \"template_id\": \"\",\n \"status\": \"\",\n \"debug\": \"\",\n \"playbook\": \"\",\n \"environment\": \"\",\n \"secret\": \"\",\n \"limit\": \"\",\n \"message\": \"\"\n },\n {\n \"id\": \"\",\n \"template_id\": \"\",\n \"status\": \"\",\n \"debug\": \"\",\n \"playbook\": \"\",\n \"environment\": \"\",\n \"secret\": \"\",\n \"limit\": \"\",\n \"message\": \"\"\n }\n]" } ] }, { "name": "Starts a job", "id": "fd7ac7eb-1a40-497e-8526-c4c43b8e5be8", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"template_id\": \"\",\n \"debug\": \"\",\n \"dry_run\": \"\",\n \"diff\": \"\",\n \"playbook\": \"\",\n \"environment\": \"\",\n \"limit\": \"\",\n \"git_branch\": \"\",\n \"message\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/tasks", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "tasks" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "10c09cae-bebc-4b76-b187-a081e5edef44", "name": "Task queued", "originalRequest": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/json" }, { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"template_id\": \"\",\n \"debug\": \"\",\n \"dry_run\": \"\",\n \"diff\": \"\",\n \"playbook\": \"\",\n \"environment\": \"\",\n \"limit\": \"\",\n \"git_branch\": \"\",\n \"message\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/tasks", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "tasks" ], "variable": [ { "key": "project_id" } ] } }, "status": "Created", "code": 201, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"template_id\": \"\",\n \"status\": \"\",\n \"debug\": \"\",\n \"playbook\": \"\",\n \"environment\": \"\",\n \"secret\": \"\",\n \"limit\": \"\",\n \"message\": \"\"\n}" } ] } ], "id": "24236c3c-5fc9-4524-9502-a96042d391a5" }, { "name": "Fetch project", "id": "6c6b380d-104e-422a-841d-5c9aab5ab00d", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "7b9968a9-8ab3-4f69-84fa-e1ecacaeefcc", "name": "Project", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "" ], "variable": [ { "key": "project_id" } ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "{\n \"id\": \"\",\n \"name\": \"\",\n \"created\": \"\",\n \"alert\": \"\",\n \"max_parallel_tasks\": \"\"\n}" } ] }, { "name": "Update project", "id": "064758e8-1c30-47bf-9168-c5752e6c9ae5", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"alert\": \"\",\n \"max_parallel_tasks\": \"\",\n \"demo\": \"\",\n \"id\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "0e61e34d-a322-481a-9b4a-390a6059671f", "name": "Project saved", "originalRequest": { "method": "PUT", "header": [ { "key": "Content-Type", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "body": { "mode": "raw", "raw": "{\n \"name\": \"\",\n \"alert\": \"\",\n \"max_parallel_tasks\": \"\",\n \"demo\": \"\",\n \"id\": \"\"\n}", "options": { "raw": { "headerFamily": "json", "language": "json" } } }, "url": { "raw": "{{baseUrl}}/project/:project_id/", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "" ], "variable": [ { "key": "project_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] }, { "name": "Delete project", "id": "bb3f76a1-0ed3-49de-8098-3c12b65d7d34", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "DELETE", "header": [], "url": { "raw": "{{baseUrl}}/project/:project_id/", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "" ], "variable": [ { "key": "project_id", "value": "", "description": "(Required) Project ID" } ] } }, "response": [ { "id": "cf929784-e50f-42d5-9990-28f3b9a5168e", "name": "Project deleted", "originalRequest": { "method": "DELETE", "header": [ { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/project/:project_id/", "host": [ "{{baseUrl}}" ], "path": [ "project", ":project_id", "" ], "variable": [ { "key": "project_id" } ] } }, "status": "No Content", "code": 204, "_postman_previewlanguage": "text", "header": [], "cookie": [] } ] } ], "id": "e62e02fc-6769-4246-ac10-4edc9bcdc04d" } ], "id": "27661c38-165d-4b9b-91d0-1885b8130bfc" }, { "name": "apps", "item": [ { "name": "Get apps", "id": "6664f5e2-42f6-45a3-9b8a-69a751c94922", "protocolProfileBehavior": { "disableBodyPruning": true }, "request": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" } ], "url": { "raw": "{{baseUrl}}/apps", "host": [ "{{baseUrl}}" ], "path": [ "apps" ] } }, "response": [ { "id": "f6fb260d-d723-4803-87f8-fed63798520f", "name": "Apps", "originalRequest": { "method": "GET", "header": [ { "key": "Accept", "value": "application/json" }, { "description": "Added as a part of security scheme: apikey", "key": "Authorization", "value": "" } ], "url": { "raw": "{{baseUrl}}/apps", "host": [ "{{baseUrl}}" ], "path": [ "apps" ] } }, "status": "OK", "code": 200, "_postman_previewlanguage": "json", "header": [ { "key": "Content-Type", "value": "application/json" } ], "cookie": [], "body": "[]" } ] } ], "id": "a972b945-e9f9-40e6-b26a-1d5d056a0cac" } ], "auth": { "type": "bearer", "bearer": [ { "key": "token", "value": "{{apiKey}}", "type": "string" } ] }, "event": [ { "listen": "prerequest", "script": { "id": "50738f66-a31b-4749-8930-0bc96ba6c2ae", "type": "text/javascript", "packages": {}, "exec": [ "" ] } }, { "listen": "test", "script": { "id": "88b8e162-2675-4bda-8bc9-245a51b2ad29", "type": "text/javascript", "packages": {}, "exec": [ "" ] } } ], "variable": [ { "id": "ccc1b51e-356a-429b-aaa8-082bc2487fad", "key": "baseUrl", "value": "http://localhost:3000/api" } ] } ================================================ FILE: .vscode/launch.json ================================================ { "version": "0.2.0", "configurations": [ { "name": "Launch Server", "type": "go", "request": "launch", "mode": "auto", "program": "./cli/main.go", "args": ["server", "--config", "${workspaceFolder}/.devcontainer/config.json"], "cwd": "${workspaceFolder}", "env": { "PATH": "${workspaceFolder}/.venv/bin:${env:PATH}", "SEMAPHORE_ADMIN_PASSWORD": "test123" } }, { "name": "Launch Server with remote Runner", "type": "go", "request": "launch", "mode": "auto", "program": "./cli/main.go", "args": ["server", "--config", "${workspaceFolder}/.devcontainer/config.json"], "cwd": "${workspaceFolder}", "env": { "PATH": "${workspaceFolder}/.venv/bin:${env:PATH}", "SEMAPHORE_USE_REMOTE_RUNNER": "true" } }, { "name": "Launch Runner", "type": "go", "request": "launch", "mode": "auto", "program": "./cli/main.go", "args": ["runner", "start", "--config", "${workspaceFolder}/.devcontainer/config-runner.json", "--log-level", "debug"], "cwd": "${workspaceFolder}", "env": { "PATH": "${workspaceFolder}/.venv/bin:${env:PATH}" } }, { "name": "Launch Dredd Tests without Server", "type": "pwa-node", "request": "launch", "runtimeExecutable": "task", "args": ["dredd:test:local"], "cwd": "${workspaceFolder}", "console": "integratedTerminal" }, { "name": "Launch Server (LOCAL)", "type": "go", "request": "launch", "mode": "auto", "program": "./cli/main.go", "args": ["server", "--config", "./config.json"], "cwd": "${workspaceFolder}", "env": { "DEBUG_DELAY": "1s" } }, ] } ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project maintainer at denis@semaphoreui.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ ================================================ FILE: CONTRIBUTING.md ================================================ ## Pull Requests When creating a pull-request you should: - __Open an issue first:__ Confirm that the change or feature will be accepted - __Update API documentation:__ If your pull-request adding/modifying an API request, make sure you update the Swagger documentation (`api-docs.yml`) - __Run API Tests:__ If your pull request modifies the API make sure you run the integration tests using **dredd**. ## Installation in a development environment - Check out the `develop` branch - [Install Go](https://golang.org/doc/install). Go must be >= v1.21 for all the tools we use to work - Install MySQL / MariaDB (Optional) - Install node.js 1) Set up `GOPATH` * Set `GOPATH` in your shell (for example, in your `.bashrc` or `.zshrc`): ```bash export GOPATH=$HOME/go export PATH=$PATH:$GOPATH/bin ``` * Create required directory and switch to it: ```bash mkdir -p $GOPATH/src/github.com/semaphoreui cd $GOPATH/src/github.com/semaphoreui ``` 2) Clone semaphore (with submodules) ``` git clone --recursive git@github.com:semaphoreui/semaphore.git && cd semaphore ``` 3) Install dev dependencies ``` go install github.com/go-task/task/v3/cmd/task@latest task deps ``` Windows users will additionally need to manually install goreleaser from https://github.com/goreleaser/goreleaser/releases 4) Create database if you want to use MySQL (Semaphore also supports SQLite, it doesn't require additional action) ``` echo "create database semaphore;" | mysql -uroot -p ``` 5) Compile, set up & run ``` task build go run cli/main.go setup go run cli/main.go service --config ./config.json ``` Open [localhost:3000](http://localhost:3000) Note: for Windows, you may need [Cygwin](https://www.cygwin.com/) to run certain commands because the [reflex](github.com/cespare/reflex) package probably doesn't work on Windows. You may encounter issues when running `task watch`, but running `task build` etc... will still be OK. ## Integration tests Dredd is used for API integration tests, if you alter the API in any way you must make sure that the information in the api docs matches the responses. As Dredd and the application database config may differ it expects it's own config.json in the .dredd folder. ### How to run Dredd tests locally 1) Build Dredd hooks: ```bash task dredd:hooks ``` 2) Install Dredd globally ```bash npm install -g dredd ``` 3) Create `./dredd/config.json` for Dredd. It must contain database connection same as used in Semaphore server. You can use any supported database dialect for tests. For example BoltDB. ```json { "bolt": { "host": "/tmp/database.boltdb" }, "dialect": "bolt" } ``` 4) Start Semaphore server (add `--config` option if required): 5) ```bash ./bin/semaphore server ``` 5) Start Dredd tests ``` dredd --config ./.dredd/dredd.local.yml ``` ## Goland debug configuration image ## Manual testing with using Semaphore MCP and Cursor agent 1. Install Semaphore MCP ```bash pipx install semaphore-mcp ``` Upgrade: ```bash pipx upgrade semaphore-mcp ``` 2. Install Cursor Agent CLI ```bash curl https://cursor.com/install -fsSL | bash ``` You can check the agent using command: ```bash cursor-agent --version ``` 3. Set up MCP server for Cursor Add following block to `~/.cursor/mcp.json`: ```json { "mcpServers": { "semaphore": { "command": "semaphore-mcp", "args": [], "env": { "SEMAPHORE_URL": "http://localhost:3000", "SEMAPHORE_API_TOKEN": "" } } } } ``` 4. Run tests ```bash cd tests/manual ./run.sh ``` ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2021 Denis Gukov Copyright (c) 2014-2021 Castaway Labs 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. ================================================ FILE: README.md ================================================ # Semaphore UI Modern UI for Ansible, Terraform/OpenTofu/Terragrunt, PowerShell and other DevOps tools. [![roadmap](https://img.shields.io/badge/roadmap-gray?style=for-the-badge&logo=github)](https://github.com/orgs/semaphoreui/projects/11) [![telegram](https://img.shields.io/badge/discord_community-510b80?style=for-the-badge&logo=discord)](https://discord.gg/5R6k7hNGcH) [![youtube](https://img.shields.io/badge/youtube_channel-red?style=for-the-badge&logo=youtube)](https://www.youtube.com/@semaphoreui) ![responsive-ui-phone1](https://user-images.githubusercontent.com/914224/134777345-8789d9e4-ff0d-439c-b80e-ddc56b74fcee.png) If your project has grown and deploying from the terminal is no longer feasible, then Semaphore UI is the tool you need. ## Gratitude Thank you, [Stefan](https://github.com/stefanux) and [steadfasterX](https://github.com/steadfasterX), for supporting the project. Your support is invaluable. Thank you, [Thomas](https://github.com/tboerger) and [Brian](https://github.com/Omicron7), for your excellent contributions. You solved issues that no one else would have taken on. ## What is Semaphore UI? Semaphore UI is a modern web interface for managing popular DevOps tools. Semaphore UI allows you to: * Easily run Ansible playbooks, Terraform and OpenTofu code, as well as Bash and PowerShell scripts. * Receive notifications about failed tasks. * Control access to your deployment system. ## Key Concepts 1. **Projects** is a collection of related resources, configurations, and tasks. 2. **Task Templates** are reusable definitions of tasks that can be executed on demand or scheduled. 3. **Task** is a specific instance of a job or operation executed by Semaphore. 4. **Schedules** allow you to automate task execution at specified times or intervals. 5. **Inventory** is a collection of target hosts (servers, virtual machines, containers, etc.) on which tasks will be executed. 6. **Variable Group** refers to a configuration context that holds sensitive information such as environment variables and secrets used by tasks during execution. ## Getting Started You can install Semaphore using the following methods: * [Docker](https://semaphoreui.com/install/docker) * Deploy a VM from a marketplace: * [AWS](https://aws.amazon.com/marketplace/pp/prodview-xavlsdkqybxtq) * [DigitalOcean](https://marketplace.digitalocean.com/apps/semaphore?refcode=b55d7c0077b8&action=deploy) * [Vultr](https://www.vultr.com/marketplace/apps/semaphore) * [Yandex Cloud](https://yandex.cloud/ru/marketplace/products/fastlix/semaphore) * [Snap](http://snapcraft.io/semaphore) * [Binary file](https://semaphoreui.com/install/binary) * [Debian or RPM package](https://semaphoreui.com/install/binary) ### Docker The most popular way to install Semaphore is via Docker. ``` docker run -p 3000:3000 --name semaphore \ -e SEMAPHORE_DB_DIALECT=bolt \ -e SEMAPHORE_ADMIN=admin \ -e SEMAPHORE_ADMIN_PASSWORD=changeme \ -e SEMAPHORE_ADMIN_NAME=Admin \ -e SEMAPHORE_ADMIN_EMAIL=admin@localhost \ -d semaphoreui/semaphore:latest ``` We recommend using the [Container Configurator](https://semaphoreui.com/install/docker/) to get the ideal Docker configuration for Semaphore. ### Other Installation Methods For more installation options, visit our [Installation page](https://semaphoreui.com/install). ## Documentation * [User Guide](https://docs.semaphoreui.com) * [API Reference](https://semaphoreui.com/api-docs) * [Postman Collection](https://www.postman.com/semaphoreui) ## Awesome Semaphore A curated list of awesome things related to Semaphore UI. * [Ebdruplab — Ansible Collections](https://github.com/Ebdruplab/ansible-collection_ebdruplab) — Ansible modules and a role for managing Semaphore. * [SemaphoreUI MCP Server](https://github.com/cloin/semaphore-mcp) — A Model Context Protocol (MCP) server that provides AI assistants with powerful automation capabilities for SemaphoreUI. * [Terraform SemaphoreUI Provider](https://github.com/CruGlobal/terraform-provider-semaphoreui) — Manage Semaphore UI resources using Terraform. * [PSSemaphore](https://github.com/robinmalik/PSSemaphore) — A PowerShell module designed to work against the Ansible Semaphore REST API. [//]: # (* [Ansible UI Semaphore](https://github.com/morbidick/ansible-role-semaphore) — Ansible role to install and configure the Ansible UI Semaphore.) ## Contribution * [Contribution Guide](https://github.com/semaphoreui/semaphore/blob/develop/CONTRIBUTING.md) * [Dev Container](https://codespaces.new/semaphoreui/semaphore) (default user `admin` / `changeme`) ## License MIT © [Denis Gukov](https://github.com/fiftin) ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions | Version | Supported | | ------- | ------------------ | | 2.14.x | :white_check_mark: | | 2.13.x | :white_check_mark: | | < 2.13 | :x: | ## Reporting a Vulnerability If you believe you’ve found a security vulnerability in Semaphore UI, we encourage you to let us know as soon as possible. Please email us at security@semaphoreui.com with: - A clear description of the vulnerability - Steps to reproduce the issue - Any related logs, screenshots, or payloads We take security seriously and will respond as quickly as possible. We aim to confirm receipt within 1 business day and provide a full response within 7 business days. We ask that you **do not publicly disclose** the issue until we’ve had a chance to investigate and release a fix. ## Scope This policy applies to: - Semaphore UI (self-hosted) - Official installers, containers, and packages distributed through our GitHub or website This policy does **not** apply to third-party plugins or custom modifications. ================================================ FILE: TERRAFORM_ARGS_IMPROVEMENT.md ================================================ # Terraform Multi-Stage Arguments Support ## Overview Enhanced the argument handling system to support stage-specific CLI arguments for Terraform tasks. This allows providing different arguments for different Terraform stages (init, plan, apply) which is essential for complex Terraform workflows. ## What Changed ### 1. LocalAppRunningArgs Structure (`db_lib/LocalApp.go`) Unified to use map-based arguments with "default" key for backward compatibility: ```go type LocalAppRunningArgs struct { CliArgs map[string][]string // Stage-specific args (e.g., "init", "apply", "default") EnvironmentVars []string Inputs map[string]string TaskParams any TemplateParams any Callback func(*os.Process) } ``` **Key Change**: Array format arguments are automatically converted to map format with key "default". ### 2. Argument Parsing (`services/tasks/LocalJob.go`) Added `convertArgsJSONIfArray()` and `getCLIArgsMap()` functions that: - `convertArgsJSONIfArray()`: Checks JSON format and converts array format to map with "default" key **in-place** - `getCLIArgsMap()`: Parses arguments as map format (after conversion) - Array format is automatically converted to map format at runtime - Supports both Template and Task level arguments - Ensures consistent map-based interface throughout the system ### 3. Terraform Argument Processing (`services/tasks/LocalJob.go`) Updated `getTerraformArgs()` to: - Return map format only (unified interface) - Merge template and task arguments at the stage level - Apply common args (destroy, vars, secrets) to all stages - Ensure at least "default" stage exists with common args ### 4. TerraformApp Enhancements (`db_lib/TerraformApp.go`) Modified Terraform execution to: - Accept stage-specific init args during installation - Use different args for plan and apply stages - Fall back to "default" key when specific stage not defined - New method `InstallRequirementsWithInitArgs()` for init customization ### 5. LocalJob Orchestration (`services/tasks/LocalJob.go`) Enhanced `Run()` method to: - Get args before prepareRun for Terraform apps - Pass init-specific args during installation - Provide plan/apply-specific args during execution - Convert all args to unified map format with "default" key for non-Terraform apps ## Usage Examples ### Legacy Format (Still Supported) Array format arguments are automatically converted to map with "default" key: ```json { "arguments": ["-var", "environment=production"] } ``` **Internally converted to:** ```json { "arguments": { "default": ["-var", "environment=production"] } } ``` ### New Map Format Stage-specific arguments for different Terraform operations: ```json { "arguments": { "init": ["-upgrade"], "plan": ["-var", "foo=bar"], "apply": ["-var", "foo=baz"] } } ``` ### Real-World Example Template with stage-specific configurations: ```json { "template": { "arguments": { "init": ["-backend-config=bucket=my-bucket"], "plan": ["-out=tfplan"], "apply": ["tfplan"] } } } ``` Task override combining with template args: ```json { "task": { "arguments": { "init": ["-reconfigure"], "apply": ["-auto-approve"] } } } ``` Result: Arguments are merged per stage - **init**: `-backend-config=bucket=my-bucket`, `-reconfigure` - **plan**: `-out=tfplan` - **apply**: `tfplan`, `-auto-approve` ## Backward Compatibility ✅ **100% Backward Compatible** - Existing array format continues to work - No changes required to existing templates/tasks - Array format arguments are used for all stages when no map is provided - Gradual migration path available ## Implementation Details ### Stage-Specific Argument Flow 1. **Parse Phase**: Arguments parsed as array or map from JSON 2. **Merge Phase**: Template and task args merged at stage level 3. **Common Args**: Environment vars, secrets, and destroy flag added to all stages 4. **Execution Phase**: Appropriate args used for each stage (init, plan, apply) ### Key Functions - `getCLIArgsMap()`: Parses both formats from JSON - `getTerraformArgs()`: Builds stage-specific argument maps - `prepareRunTerraform()`: Passes init args to Terraform installation - `TerraformApp.Run()`: Uses plan/apply-specific args during execution ### Supported Stages - **init**: Used during `terraform init` (via InstallRequirements) - **plan**: Used during `terraform plan` - **apply**: Used during `terraform apply` - **default**: Used as fallback when specific stage not defined ### Stage Resolution Order (Terraform) For each stage, arguments are resolved in this order: 1. Stage-specific key (e.g., "init", "plan", "apply") 2. Fall back to "default" key if stage-specific not found 3. Empty array if neither exists ### Backward Compatibility Details **Array Format → Map Conversion:** - **Runtime Conversion**: Array `["-var", "foo=bar"]` is converted to `{"default": ["-var", "foo=bar"]}` **in-place** when the task runs - **No Database Changes**: Original JSON remains stored as array, conversion happens only during task execution - **Transparent**: Users don't see the conversion, it happens automatically - Ansible and Shell apps: Always use "default" key - Terraform apps: Use stage-specific keys, fall back to "default" ## Testing The implementation has been validated with: - Successful build of entire project - No linter errors - Backward compatibility verified - Both array and map formats tested ## Benefits 1. **Flexibility**: Different args for different Terraform stages 2. **Security**: Keep sensitive args only in specific stages 3. **Efficiency**: Optimize each stage independently 4. **Clarity**: Clear separation of stage-specific configurations 5. **Compatibility**: Works alongside existing array format ## Migration Path ### Phase 1: Keep using array format (no changes needed) ```json {"arguments": ["-var", "foo=bar"]} ``` **Result:** Automatically converted to `{"default": ["-var", "foo=bar"]}` internally ### Phase 2: Migrate to map format for multi-stage tasks ```json {"arguments": {"init": ["-upgrade"], "apply": ["-var", "foo=bar"]}} ``` **Result:** Uses stage-specific args for init and apply ### Phase 3: Mix default and stage-specific for flexibility ```json { "arguments": { "default": ["-var", "common=value"], "init": ["-upgrade"], "apply": ["-parallelism=20"] } } ``` **Result:** plan stage uses "default" args, init and apply use their specific args ### Phase 4: Leverage full stage-specific capabilities ```json { "arguments": { "init": ["-backend-config=..."], "plan": ["-out=tfplan", "-var-file=prod.tfvars"], "apply": ["tfplan", "-parallelism=20"] } } ``` **Result:** Complete control over each stage independently ================================================ FILE: Taskfile.yml ================================================ version: "3" vars: DOCKER_ORG: semaphoreui DOCKER_SERVER: semaphore DOCKER_RUNNER: runner DOCKER_CMD: docker tasks: all: desc: Install, test and build Semaphore for local architecture cmds: - task: deps - task: test - task: build vars: GOOS: "" GOARCH: "" APP_BUILD_TYPE: "" deps: desc: Install all build dependencies cmds: - task: deps:tools - task: deps:be - task: deps:fe deps:tools: desc: Installs required tools to build and publish vars: SWAGGER_VERSION: v0.30.5 GORELEASER_VERSION: v2.11.2 GOLINTER_VERSION: v1.57.2 cmds: # - go install github.com/go-swagger/go-swagger/cmd/swagger@{{ .SWAGGER_VERSION }} - go install github.com/goreleaser/goreleaser/v2@{{ .GORELEASER_VERSION }} # - go install github.com/golangci/golangci-lint/cmd/golangci-lint@{{ .GOLINTER_VERSION }} dir: /tmp deps:be: desc: Vendor application dependencies cmds: - go {{ if eq .APP_BUILD_TYPE "pro_selfhosted" }}work{{ else }}mod{{ end }} vendor deps:fe: desc: Installs nodejs requirements dir: web cmds: - npm install build: desc: Build a full set of release binaries and packages cmds: - task: build:fe - task: build:be build:debug: desc: Build DEBUG server binary cmds: - >- env CGO_ENABLED=0 GOOS={{ .GOOS }} GOARCH={{ .GOARCH }} go build -o bin/semaphore{{ if eq OS "windows" }}.exe{{ end }} -tags "netgo" -gcflags="all=-N -l" -ldflags "-X {{ .IMPORT }}/util.Ver={{ .VERSION }} -X {{ .IMPORT }}/util.Commit={{ .SHA }} -X {{ .IMPORT }}/util.Date={{ .DATE }}" ./cli vars: TAG: sh: git name-rev --name-only --tags --no-undefined HEAD 2>/dev/null || git rev-parse --abbrev-ref HEAD SHA: sh: git log --pretty=format:'%h' -n 1 VERSION: "{{ if eq .GITHUB_REF_TYPE \"tag\" }}{{ .GITHUB_REF_NAME }}{{ else }}{{ .TAG }}{{ end }}" DATE: "{{ now | unixEpoch }}" IMPORT: "github.com/semaphoreui/semaphore" build:fe: desc: Build VueJS project dir: web sources: - src/*.* - src/**/*.* - public/index.html - public/favicon.ico - package.json - package-lock.json - babel.config.js - vue.config.js generates: - ../api/public/css/*.css - ../api/public/js/*.js - ../api/public/index.html - ../api/public/favicon.ico cmds: - >- {{ if eq OS "windows" }}set VUE_APP_BUILD_TYPE={{ .APP_BUILD_TYPE }} && {{ else }}env VUE_APP_BUILD_TYPE={{ .APP_BUILD_TYPE }}{{ end }} npm run build build:be: desc: Build server binary cmds: - >- {{ if eq OS "windows" }}set CGO_ENABLED=0 && GOOS={{ .GOOS }} && GOARCH={{ .GOARCH }} && {{ else }}env CGO_ENABLED=0 GOOS={{ .GOOS }} GOARCH={{ .GOARCH }}{{ end }} go build -o bin/semaphore{{ if eq OS "windows" }}.exe{{ end }} -tags "netgo" -ldflags "-s -w -X {{ .IMPORT }}/util.Ver={{ .VERSION }} -X {{ .IMPORT }}/util.Commit={{ .SHA }} -X {{ .IMPORT }}/util.Date={{ .DATE }}" ./cli vars: TAG: sh: git name-rev --name-only --tags --no-undefined HEAD 2>/dev/null || git rev-parse --abbrev-ref HEAD SHA: sh: git log --pretty=format:'%h' -n 1 VERSION: "{{ if eq .GITHUB_REF_TYPE \"tag\" }}{{ .GITHUB_REF_NAME }}{{ else }}{{ .TAG }}{{ end }}" DATE: "{{ now | unixEpoch }}" IMPORT: "github.com/semaphoreui/semaphore" lint: cmds: - task: lint:fe - task: lint:be lint:fe: dir: web cmds: - npm run lint lint:be: cmds: - golangci-lint run - swagger validate ./api-docs.yml test: cmds: # - task: test:fe - task: test:be test:fe: dir: web cmds: - npm run test:unit test:be: desc: Run go code tests cmds: - go test -v -coverprofile=coverage.out ./... dredd:goodman: desc: Installs goodman which is required by dredd cmds: - go install github.com/snikch/goodman/cmd/goodman@latest dredd:deps: desc: Installs dredd dep for integration testing dir: web cmds: - npm install dredd@13.1.2 dredd:hooks: desc: Compile required dredd hooks built dir: ./.dredd/hooks cmds: - go build -o ../compiled_hooks{{ if eq OS "windows" }}.exe{{ end }} dredd:test: desc: Run end to end test for API with dredd cmds: - ./web/node_modules/.bin/dredd --config .dredd/dredd.testing.yml dredd:test:local: desc: Run end to end test for API with dredd cmds: - ./web/node_modules/.bin/dredd --config .dredd/dredd.local.yml release:prod: desc: Create and publish a release cmds: - goreleaser release:test: desc: Create a local test release cmds: - goreleaser --auto-snapshot --clean --skip=sign docker:test: desc: Test containers by building, running, testing and deleting them deps: - task: docker:deps cmds: - task: docker:build vars: tag: test - task: docker:goss - task: docker:lint - "{{ .DOCKER_CMD }} rmi {{ .DOCKER_ORG }}/{{ .DOCKER_SERVER }}:test" - "{{ .DOCKER_CMD }} rmi {{ .DOCKER_ORG }}/{{ .DOCKER_RUNNER }}:test" docker:lint: desc: Lint all dockerfiles based on Hadolint deps: - task: docker:deps cmds: - task: docker:lint:server - task: docker:lint:runner docker:lint:server: desc: Lint server dockerfile based on Hadolint dir: deployment/docker/server cmds: - hadolint Dockerfile --ignore DL3018 docker:lint:runner: desc: Lint runner dockerfile based on Hadolint dir: deployment/docker/runner cmds: - hadolint Dockerfile --ignore DL3018 docker:goss: desc: Check if container contains defined files deps: - task: docker:deps cmds: - task: docker:goss:server - task: docker:goss:runner docker:goss:server: desc: Check if server contains defined files dir: deployment/docker/server env: GOSS_FILES_STRATEGY: cp cmds: - dgoss run -it "{{ .DOCKER_ORG }}/{{ .DOCKER_SERVER }}:test" docker:goss:runner: desc: Check if runner contains defined files dir: deployment/docker/runner env: GOSS_FILES_STRATEGY: cp cmds: - dgoss run -it "{{ .DOCKER_ORG }}/{{ .DOCKER_RUNNER }}:test" docker:build: desc: Build all defined images for Semaphore vars: tag: "{{ if .tag }}{{ .tag }}{{ else }}latest{{ end }}" cmds: - task: docker:build:server vars: tag: "{{ .tag }}" - task: docker:build:runner vars: tag: "{{ .tag }}" docker:build:debug: desc: Build an DEBUG image for Semaphore server vars: tag: "debug" cmds: - "{{ .DOCKER_CMD }} build -f deployment/docker/debug/Dockerfile -t {{ .DOCKER_ORG }}/{{ .DOCKER_SERVER }}:{{ .tag }} ." docker:build:server: desc: Build an image for Semaphore server vars: tag: "{{ if .tag }}{{ .tag }}{{ else }}latest{{ end }}" cmds: - "{{ .DOCKER_CMD }} build -f deployment/docker/server/Dockerfile -t {{ .DOCKER_ORG }}/{{ .DOCKER_SERVER }}:{{ .tag }} ." docker:build:runner: desc: Build an image for Semaphore runner vars: tag: "{{ if .tag }}{{ .tag }}{{ else }}latest{{ end }}" cmds: - "{{ .DOCKER_CMD }} build -f deployment/docker/runner/Dockerfile -t {{ .DOCKER_ORG }}/{{ .DOCKER_RUNNER }}:{{ .tag }} ." docker:push: desc: Push the images to registry cmds: - docker push {{ .DOCKER_ORG }}/{{ .DOCKER_SERVER }}:{{ .tag }} - docker push {{ .DOCKER_ORG }}/{{ .DOCKER_RUNNER }}:{{ .tag }} docker:deps: desc: Install docker testing dependencies vars: INSTALL_PATH: '{{ .INSTALL_PATH | default "/usr/local/bin" }}' REQUIRE_SUDO: '{{ .REQUIRE_SUDO | default "true" }}' cmds: - task: docker:deps:hadolint vars: INSTALL_PATH: "{{ .INSTALL_PATH }}" REQUIRE_SUDO: "{{ .REQUIRE_SUDO }}" - task: docker:deps:goss vars: INSTALL_PATH: "{{ .INSTALL_PATH }}" REQUIRE_SUDO: "{{ .REQUIRE_SUDO }}" - task: docker:deps:dgoss vars: INSTALL_PATH: "{{ .INSTALL_PATH }}" REQUIRE_SUDO: "{{ .REQUIRE_SUDO }}" docker:deps:hadolint: platforms: - linux/amd64 - linux/arm64 - darwin/amd64 - darwin/arm64 vars: HADOLINT_VERSION: v2.10.0 status: - test -f "{{ .INSTALL_PATH }}/hadolint" cmds: - '{{ if eq .REQUIRE_SUDO "true" }}sudo {{ end }}curl -sSL https://github.com/hadolint/hadolint/releases/download/{{ .HADOLINT_VERSION }}/hadolint-{{ if eq OS "linux" }}Linux{{ end }}{{ if eq OS "darwin" }}Darwin{{ end }}-{{ if eq ARCH "amd64" }}x86_64{{ else }}{{ ARCH }}{{ end }} -o {{ .INSTALL_PATH }}/hadolint' - '{{ if eq .REQUIRE_SUDO "true" }}sudo {{ end }}chmod +x {{ .INSTALL_PATH }}/hadolint' docker:deps:goss: platforms: - linux - darwin vars: GOSS_VERSION: v0.3.5 status: - test -f "{{ .INSTALL_PATH }}/goss" cmds: - '{{ if eq .REQUIRE_SUDO "true" }}sudo {{ end }}curl -sSL https://github.com/aelsabbahy/goss/releases/download/{{ .GOSS_VERSION }}/goss-{{ OS }}-{{ ARCH }} -o {{ .INSTALL_PATH }}/goss' - '{{ if eq .REQUIRE_SUDO "true" }}sudo {{ end }}chmod +x {{ .INSTALL_PATH }}/goss' docker:deps:dgoss: platforms: - linux - darwin vars: GOSS_VERSION: v0.3.5 status: - test -f "{{ .INSTALL_PATH }}/dgoss" cmds: - '{{ if eq .REQUIRE_SUDO "true" }}sudo {{ end }}curl -sSL https://raw.githubusercontent.com/aelsabbahy/goss/{{ .GOSS_VERSION }}/extras/dgoss/dgoss -o {{ .INSTALL_PATH }}/dgoss' - '{{ if eq .REQUIRE_SUDO "true" }}sudo {{ end }}chmod +x {{ .INSTALL_PATH }}/dgoss' ================================================ FILE: api/api_test.go ================================================ package api import ( "github.com/semaphoreui/semaphore/util" "net/http" "net/http/httptest" "testing" ) func TestApiPing(t *testing.T) { util.Config = &util.ConfigType{ Debugging: &util.DebuggingConfig{}, } req, _ := http.NewRequest("GET", "/api/ping", nil) rr := httptest.NewRecorder() r := Route( nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, ) r.ServeHTTP(rr, req) if rr.Code != 200 { t.Errorf("Response code should be 200 %d", rr.Code) } } ================================================ FILE: api/apps.go ================================================ package api import ( "encoding/json" "errors" "fmt" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/conv" "github.com/semaphoreui/semaphore/util" "net/http" "reflect" "sort" ) func validateAppID(str string) error { return nil } func appMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { appID, err := helpers.GetStrParam("app_id", w, r) if err != nil { helpers.WriteErrorStatus(w, err.Error(), http.StatusBadRequest) return } if err := validateAppID(appID); err != nil { helpers.WriteErrorStatus(w, err.Error(), http.StatusBadRequest) return } r = helpers.SetContextValue(r, "app_id", appID) next.ServeHTTP(w, r) }) } func getApps(w http.ResponseWriter, r *http.Request) { type app struct { util.App ID string `json:"id"` } apps := make([]app, 0) for k, a := range util.Config.Apps { apps = append(apps, app{ App: a, ID: k, }) } sort.Slice(apps, func(i, j int) bool { return apps[i].Priority > apps[j].Priority }) helpers.WriteJSON(w, http.StatusOK, apps) } func getApp(w http.ResponseWriter, r *http.Request) { appID := helpers.GetFromContext(r, "app_id").(string) app, ok := util.Config.Apps[appID] if !ok { helpers.WriteErrorStatus(w, "app not found", http.StatusNotFound) return } helpers.WriteJSON(w, http.StatusOK, app) } func deleteApp(w http.ResponseWriter, r *http.Request) { appID := helpers.GetFromContext(r, "app_id").(string) store := helpers.Store(r) err := store.DeleteOptions("apps." + appID) if err != nil && !errors.Is(err, db.ErrNotFound) { helpers.WriteErrorStatus(w, err.Error(), http.StatusInternalServerError) return } delete(util.Config.Apps, appID) w.WriteHeader(http.StatusNoContent) } func setAppOption(store db.Store, appID string, field string, val any) error { key := "apps." + appID + "." + field if val == nil { return store.DeleteOptions(key) } v := fmt.Sprintf("%v", val) if err := store.SetOption(key, v); err != nil { return err } opts := make(map[string]string) opts[key] = v options := db.ConvertFlatToNested(opts) _ = util.AssignMapToStruct(options, util.Config) return nil } func setApp(w http.ResponseWriter, r *http.Request) { appID := helpers.GetFromContext(r, "app_id").(string) store := helpers.Store(r) var app util.App if !helpers.Bind(w, r, &app) { return } options := conv.StructToFlatMap(app) for k, v := range options { t := reflect.TypeOf(v) if v != nil { switch t.Kind() { case reflect.String: if v == "" { v = nil } case reflect.Slice, reflect.Array: newV, err := json.Marshal(v) if err != nil { helpers.WriteErrorStatus(w, err.Error(), http.StatusInternalServerError) return } v = string(newV) if v == "[]" { v = nil } default: } } if err := setAppOption(store, appID, k, v); err != nil { helpers.WriteErrorStatus(w, err.Error(), http.StatusInternalServerError) return } } w.WriteHeader(http.StatusNoContent) } func setAppActive(w http.ResponseWriter, r *http.Request) { appID := helpers.GetFromContext(r, "app_id").(string) store := helpers.Store(r) var body struct { Active bool `json:"active"` } if !helpers.Bind(w, r, &body) { helpers.WriteErrorStatus(w, "Invalid request body", http.StatusBadRequest) return } if err := setAppOption(store, appID, "active", body.Active); err != nil { helpers.WriteErrorStatus(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/apps_test.go ================================================ package api import ( "fmt" "github.com/semaphoreui/semaphore/pkg/conv" "testing" ) func TestStructToMap(t *testing.T) { type Address struct { City string `json:"city"` State string `json:"state"` } type Person struct { Name string `json:"name"` Age int `json:"age"` Email string `json:"email"` Active bool `json:"active"` Address Address `json:"address"` } // Create an instance of the struct p := Person{ Name: "John Doe", Age: 30, Email: "johndoe@example.com", Active: true, Address: Address{ City: "New York", State: "NY", }, } // Convert the struct to a flat map flatMap := conv.StructToFlatMap(&p) if flatMap["address.city"] != "New York" { t.Fail() } // Print the map fmt.Println(flatMap) } ================================================ FILE: api/auth.go ================================================ package api import ( "errors" "net/http" "strings" "time" "github.com/pquerna/otp" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" proApi "github.com/semaphoreui/semaphore/pro/api" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" "github.com/pquerna/otp/totp" ) func getSession(r *http.Request) (*db.Session, bool) { // fetch session from cookie cookie, err := r.Cookie("semaphore") if err != nil { return nil, false } value := make(map[string]any) if err = util.Cookie.Decode("semaphore", cookie.Value, &value); err != nil { //w.WriteHeader(http.StatusUnauthorized) return nil, false } user, ok := value["user"] sessionVal, okSession := value["session"] if !ok || !okSession { //w.WriteHeader(http.StatusUnauthorized) return nil, false } userID := user.(int) sessionID := sessionVal.(int) // fetch session session, err := helpers.Store(r).GetSession(userID, sessionID) if err != nil { //w.WriteHeader(http.StatusUnauthorized) return nil, false } if time.Since(session.LastActive).Hours() > 7*24 { // more than week old unused session // destroy. if err = helpers.Store(r).ExpireSession(userID, sessionID); err != nil { // it is internal error, it doesn't concern the user log.Error(err) } return nil, false } return &session, true } type totpRequestBody struct { Passcode string `json:"passcode"` } type totpRecoveryRequestBody struct { RecoveryCode string `json:"recovery_code"` } // recoverySession handles the recovery of a user session using a recovery code. // It validates the recovery code provided by the user and, if valid, verifies the session. // If the recovery code is invalid or recovery is not allowed, it returns an appropriate HTTP status code. // // HTTP Request: // - Method: POST // - Body: JSON object containing the recovery code (e.g., {"recovery_code": "code"}). // // Responses: // - 204 No Content: Recovery successful, session verified. // - 400 Bad Request: Invalid request body or user does not have TOTP enabled. // - 401 Unauthorized: Invalid recovery code or session not found. // - 403 Forbidden: TOTP recovery is disabled. // - 500 Internal Server Error: An unexpected error occurred. // // Preconditions: // - The session must exist and be valid. // - TOTP recovery must be enabled in the configuration. // // Parameters: // - w: The HTTP response writer. // - r: The HTTP request. func recoverySession(w http.ResponseWriter, r *http.Request) { session, ok := getSession(r) if !ok { w.WriteHeader(http.StatusUnauthorized) return } switch session.VerificationMethod { case db.SessionVerificationTotp: if !util.Config.Auth.Totp.Enabled || !util.Config.Auth.Totp.AllowRecovery { helpers.WriteErrorStatus(w, "TOTP_DISABLED", http.StatusForbidden) return } var body totpRecoveryRequestBody if !helpers.Bind(w, r, &body) { w.WriteHeader(http.StatusBadRequest) return } store := helpers.Store(r) user, err := store.GetUser(session.UserID) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } if user.Totp == nil { w.WriteHeader(http.StatusBadRequest) return } if !util.VerifyRecoveryCode(body.RecoveryCode, user.Totp.RecoveryHash) { helpers.WriteErrorStatus(w, "INVALID_RECOVERY_CODE", http.StatusUnauthorized) return } err = store.DeleteTotpVerification(user.ID, user.Totp.ID) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } err = store.VerifySession(session.UserID, session.ID) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) case db.SessionVerificationNone: w.WriteHeader(http.StatusNoContent) return default: w.WriteHeader(http.StatusInternalServerError) } } func verifySession(w http.ResponseWriter, r *http.Request) { session, ok := getSession(r) if !ok { w.WriteHeader(http.StatusUnauthorized) return } switch session.VerificationMethod { case db.SessionVerificationEmail: proApi.VerifySessionByEmail(session, w, r) return case db.SessionVerificationTotp: if !util.Config.Auth.Totp.Enabled { helpers.WriteErrorStatus(w, "TOTP_DISABLED", http.StatusForbidden) return } var body totpRequestBody if !helpers.Bind(w, r, &body) { w.WriteHeader(http.StatusBadRequest) return } user, err := helpers.Store(r).GetUser(session.UserID) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } key, err := otp.NewKeyFromURL(user.Totp.URL) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } if !totp.Validate(body.Passcode, key.Secret()) { helpers.WriteErrorStatus(w, "INVALID_PASSCODE", http.StatusUnauthorized) return } err = helpers.Store(r).VerifySession(session.UserID, session.ID) if err != nil { helpers.WriteError(w, err) return } case db.SessionVerificationNone: w.WriteHeader(http.StatusNoContent) return default: w.WriteHeader(http.StatusInternalServerError) } } func authenticationHandler(w http.ResponseWriter, r *http.Request) (ok bool, req *http.Request) { var userID int req = r authHeader := strings.ToLower(r.Header.Get("authorization")) if len(authHeader) > 0 && strings.Contains(authHeader, "bearer") { token, err := helpers.Store(r).GetAPIToken(strings.Replace(authHeader, "bearer ", "", 1)) if err != nil { if !errors.Is(err, db.ErrNotFound) { log.Error(err) } w.WriteHeader(http.StatusUnauthorized) return } userID = token.UserID } else { session, found := getSession(r) if !found { w.WriteHeader(http.StatusUnauthorized) return } if !session.IsVerified() { switch session.VerificationMethod { case db.SessionVerificationEmail: helpers.WriteErrorStatus(w, "EMAIL_OTP_REQUIRED", http.StatusUnauthorized) case db.SessionVerificationTotp: helpers.WriteErrorStatus(w, "TOTP_REQUIRED", http.StatusUnauthorized) default: helpers.WriteErrorStatus(w, "SESSION_NOT_VERIFIED", http.StatusUnauthorized) } return } userID = session.UserID if err := helpers.Store(r).TouchSession(userID, session.ID); err != nil { log.Error(err) w.WriteHeader(http.StatusUnauthorized) return } } user, err := helpers.Store(r).GetUser(userID) if err != nil { if !errors.Is(err, db.ErrNotFound) { // internal error log.Error(err) } w.WriteHeader(http.StatusUnauthorized) return } ok = true req = helpers.SetContextValue(r, "user", &user) return } // nolint: gocyclo func authentication(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ok, r := authenticationHandler(w, r) if ok { next.ServeHTTP(w, r) } }) } // nolint: gocyclo func authenticationWithStore(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { store := helpers.Store(r) var ok bool db.StoreSession(store, r.URL.String(), func() { ok, r = authenticationHandler(w, r) }) if ok { next.ServeHTTP(w, r) } }) } func adminMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "user").(*db.User) if !user.Admin { w.WriteHeader(http.StatusForbidden) return } next.ServeHTTP(w, r) }) } ================================================ FILE: api/cache.go ================================================ package api import ( "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" "net/http" ) func clearCache(w http.ResponseWriter, r *http.Request) { currentUser := helpers.GetFromContext(r, "user").(*db.User) if !currentUser.Admin { helpers.WriteJSON(w, http.StatusForbidden, map[string]string{ "error": "User must be admin", }) return } err := util.Config.ClearTmpDir() if err != nil { log.Error(err) helpers.WriteJSON(w, http.StatusInternalServerError, map[string]string{ "error": "Can not clear cache", }) return } w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/debug/gc.go ================================================ package debug import ( "net/http" "runtime" ) func GC(w http.ResponseWriter, r *http.Request) { runtime.GC() w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/debug/pprof.go ================================================ package debug import ( "net/http" "os" "path" "runtime/pprof" "strconv" "time" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" ) func Dump(w http.ResponseWriter, r *http.Request) { if util.Config.Debugging.PprofDumpDir == "" { w.WriteHeader(http.StatusBadRequest) return } f, err := os.Create(path.Join(util.Config.Debugging.PprofDumpDir, "mem-"+strconv.Itoa(int(time.Now().Unix()))+".prof")) if err != nil { log.WithError(err).WithFields(log.Fields{ "context": "pprof", }).Error("error creating mem.prof") http.Error(w, err.Error(), http.StatusInternalServerError) return } defer f.Close() err = pprof.WriteHeapProfile(f) if err != nil { log.WithError(err).WithFields(log.Fields{ "context": "pprof", }).Error("Failed to write memory profile") http.Error(w, err.Error(), http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/events.go ================================================ package api import ( "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "net/http" ) // nolint: gocyclo func getEvents(w http.ResponseWriter, r *http.Request, limit int) { user := helpers.GetFromContext(r, "user").(*db.User) projectObj, exists := helpers.GetOkFromContext(r, "project") var err error var events []db.Event if exists { project := projectObj.(db.Project) if !user.Admin { // check permissions to view events _, err = helpers.Store(r).GetProjectUser(project.ID, user.ID) } if err != nil { helpers.WriteError(w, err) return } events, err = helpers.Store(r).GetEvents(project.ID, db.RetrieveQueryParams{Count: limit}) } else { events, err = helpers.Store(r).GetUserEvents(user.ID, db.RetrieveQueryParams{Count: limit}) } if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, events) } func getLastEvents(w http.ResponseWriter, r *http.Request) { getEvents(w, r, 200) } func getAllEvents(w http.ResponseWriter, r *http.Request) { getEvents(w, r, 0) } ================================================ FILE: api/helpers/context.go ================================================ package helpers import ( "context" "net/http" "github.com/semaphoreui/semaphore/db" ) func GetFromContext(r *http.Request, key string) any { return r.Context().Value(key) } func GetOkFromContext(r *http.Request, key string) (res any, ok bool) { res = r.Context().Value(key) return res, res != nil } func SetContextValue(r *http.Request, key string, value any) *http.Request { ctx := r.Context() ctx = context.WithValue(ctx, key, value) return r.WithContext(ctx) } func UserFromContext(r *http.Request) *db.User { return GetFromContext(r, "user").(*db.User) } func GetGlobalRole(r *http.Request) db.Role { return GetFromContext(r, "role").(db.Role) } ================================================ FILE: api/helpers/event_log.go ================================================ package helpers import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pro_interfaces" log "github.com/sirupsen/logrus" "net/http" ) type EventLogItem struct { IntegrationID int UserID int ProjectID int ObjectType db.EventObjectType ObjectID int Description string } type EventLogType string const ( EventLogCreate EventLogType = "create" EventLogUpdate EventLogType = "update" EventLogDelete EventLogType = "delete" ) func EventLog(r *http.Request, action EventLogType, item EventLogItem) { event := db.Event{ ObjectType: &item.ObjectType, ObjectID: &item.ObjectID, Description: &item.Description, } if item.IntegrationID > 0 { event.IntegrationID = &item.IntegrationID } if item.UserID > 0 { event.UserID = &item.UserID } if item.ProjectID > 0 { event.ProjectID = &item.ProjectID } logFields := event.ToFields() logFields["action"] = string(action) if _, err := Store(r).CreateEvent(event); err != nil { log.WithFields(logFields).Error("Failed to store event") } logWriter := GetFromContext(r, "log_writer").(pro_interfaces.LogWriteService) if err := logWriter.WriteEventLog(pro_interfaces.EventLogRecord{ Action: string(action), ProjectID: event.ProjectID, UserID: event.UserID, IntegrationID: event.IntegrationID, Description: event.Description, }); err != nil { log.WithFields(logFields).Error("Failed to store event in log file") } } ================================================ FILE: api/helpers/helpers.go ================================================ package helpers import ( "encoding/json" "net/http" "strings" "github.com/semaphoreui/semaphore/db" ) func Store(r *http.Request) db.Store { return GetFromContext(r, "store").(db.Store) } func isXHR(w http.ResponseWriter, r *http.Request) bool { accept := r.Header.Get("Accept") return !strings.Contains(accept, "text/html") } // H just a string-to-anything map type H map[string]any // Bind decodes json into object func Bind(w http.ResponseWriter, r *http.Request, out any) bool { err := json.NewDecoder(r.Body).Decode(out) if err != nil { w.WriteHeader(http.StatusBadRequest) } return err == nil } ================================================ FILE: api/helpers/helpers_test.go ================================================ package helpers import ( "net/http" "net/http/httptest" "os" "testing" "time" "github.com/gorilla/mux" ) // SetTestDelay sets a delay for testing slow network conditions func SetTestDelay(delay time.Duration) func() { originalDelay := os.Getenv("DEBUG_DELAY") os.Setenv("DEBUG_DELAY", delay.String()) return func() { if originalDelay == "" { os.Unsetenv("DEBUG_DELAY") } else { os.Setenv("DEBUG_DELAY", originalDelay) } } } func TestGetIntParam(t *testing.T) { req, _ := http.NewRequest("GET", "/test/123", nil) rr := httptest.NewRecorder() r := mux.NewRouter() r.HandleFunc("/test/{test_id}", mockParam) r.ServeHTTP(rr, req) if rr.Code != 200 { t.Errorf("Response code should be 200 %d", rr.Code) } } func mockParam(w http.ResponseWriter, r *http.Request) { _, err := GetIntParam("test_id", w, r) if err != nil { return } w.WriteHeader(200) } ================================================ FILE: api/helpers/query_params.go ================================================ package helpers import ( "github.com/semaphoreui/semaphore/db" "net/url" "slices" "strconv" ) func QueryParamsForProps(url *url.URL, props db.ObjectProps) (params db.RetrieveQueryParams) { sortBy := "" if url.Query().Get("sort") != "" { i := slices.Index(props.SortableColumns, url.Query().Get("sort")) if i != -1 { sortBy = props.SortableColumns[i] } } params = db.RetrieveQueryParams{ SortBy: sortBy, SortInverted: url.Query().Get("order") == "desc", } return } func QueryParams(url *url.URL) db.RetrieveQueryParams { return db.RetrieveQueryParams{ SortBy: url.Query().Get("sort"), SortInverted: url.Query().Get("order") == "desc", } } func QueryParamsWithOwner(url *url.URL, props db.ObjectProps) db.RetrieveQueryParams { res := QueryParamsForProps(url, props) hasOwnerFilter := false for _, ownership := range props.Ownerships { s := url.Query().Get(ownership.ReferringColumnSuffix) if s == "" { continue } id, err2 := strconv.Atoi(s) if err2 != nil { continue } res.Ownership.SetOwnerID(*ownership, id) hasOwnerFilter = true } if !hasOwnerFilter { res.Ownership.WithoutOwnerOnly = true } return res } ================================================ FILE: api/helpers/route_params.go ================================================ package helpers import ( "fmt" "net/http" "strconv" "github.com/gorilla/mux" ) // GetStrParam fetches a parameter from the route variables as an integer // redirects to a 404 or writes bad request state depending on error state func GetStrParam(name string, w http.ResponseWriter, r *http.Request) (string, error) { strParam, ok := mux.Vars(r)[name] if !ok { if !isXHR(w, r) { http.Redirect(w, r, "/404", http.StatusFound) } else { w.WriteHeader(http.StatusBadRequest) } return "", fmt.Errorf("parameter missed") } return strParam, nil } func HasParam(name string, r *http.Request) bool { _, ok := mux.Vars(r)[name] return ok } // GetIntParam fetches a parameter from the route variables as an integer // redirects to a 404 or writes bad request state depending on error state func GetIntParam(name string, w http.ResponseWriter, r *http.Request) (int, error) { intParam, err := strconv.Atoi(mux.Vars(r)[name]) if err != nil { if !isXHR(w, r) { http.Redirect(w, r, "/404", http.StatusFound) } else { w.WriteHeader(http.StatusBadRequest) } return 0, err } return intParam, nil } ================================================ FILE: api/helpers/write_response.go ================================================ package helpers import ( "encoding/json" "errors" "net/http" "runtime/debug" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/common_errors" log "github.com/sirupsen/logrus" ) // WriteJSON writes object as JSON func WriteJSON(w http.ResponseWriter, code int, out any) { w.Header().Set("content-type", "application/json") w.WriteHeader(code) if out == nil { return } if err := json.NewEncoder(w).Encode(out); err != nil { log.Error(err) debug.PrintStack() } } func WriteErrorStatus(w http.ResponseWriter, err string, code int) { WriteJSON(w, code, map[string]string{ "error": err, }) } func WriteError(w http.ResponseWriter, err error) { if errors.Is(err, common_errors.ErrInvalidSubscription) { WriteErrorStatus(w, "You have no subscription.", http.StatusForbidden) return } if errors.Is(err, db.ErrNotFound) { w.WriteHeader(http.StatusNotFound) return } if errors.Is(err, db.ErrInvalidOperation) { WriteErrorStatus(w, err.Error(), http.StatusConflict) return } var validationError *db.ValidationError var userVisibleError *common_errors.UserVisibleError switch { case errors.As(err, &userVisibleError): WriteErrorStatus(w, userVisibleError.Error(), http.StatusBadRequest) case errors.As(err, &validationError): WriteErrorStatus(w, validationError.Error(), http.StatusBadRequest) default: log.Error(err) debug.PrintStack() w.WriteHeader(http.StatusBadRequest) } } ================================================ FILE: api/integration.go ================================================ package api import ( "crypto/hmac" "crypto/sha256" "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "github.com/semaphoreui/semaphore/pkg/conv" "github.com/semaphoreui/semaphore/services/server" task2 "github.com/semaphoreui/semaphore/services/tasks" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" log "github.com/sirupsen/logrus" "github.com/thedevsaddam/gojsonq/v2" ) // isValidHmacPayload checks if the GitHub payload's hash fits with // the hash computed by GitHub sent as a header func isValidHmacPayload(secret, headerHash string, payload []byte, prefix string) bool { hash := hmacHashPayload(secret, payload) if !strings.HasPrefix(headerHash, prefix) { return false } headerHash = headerHash[len(prefix):] return hmac.Equal( []byte(hash), []byte(headerHash), ) } // hmacHashPayload computes the hash of payload's body according to the webhook's secret token // see https://developer.github.com/webhooks/securing/#validating-payloads-from-github // returning the hash as a hexadecimal string func hmacHashPayload(secret string, payloadBody []byte) string { hm := hmac.New(sha256.New, []byte(secret)) hm.Write(payloadBody) sum := hm.Sum(nil) return fmt.Sprintf("%x", sum) } type IntegrationController struct { integrationService server.IntegrationService } func NewIntegrationController(integrationService server.IntegrationService) *IntegrationController { return &IntegrationController{ integrationService: integrationService, } } func (c *IntegrationController) ReceiveIntegration(w http.ResponseWriter, r *http.Request) { var err error integrationAlias, err := helpers.GetStrParam("integration_alias", w, r) if err != nil { log.Error(err) return } log.Info(fmt.Sprintf("Receiving Integration from: %s", r.RemoteAddr)) store := helpers.Store(r) integrations, level, err := store.GetIntegrationsByAlias(integrationAlias) if err != nil { log.Error(err) return } log.Info(fmt.Sprintf("%d integrations found for alias %s", len(integrations), integrationAlias)) projects := make(map[int]db.Project) var payload []byte payload, err = io.ReadAll(r.Body) if err != nil { log.Error(err) return } for _, integration := range integrations { project, ok := projects[integration.ProjectID] if !ok { project, err = store.GetProject(integrations[0].ProjectID) if err != nil { log.Error(err) return } projects[integration.ProjectID] = project } if integration.ProjectID != project.ID { log.WithFields(log.Fields{ "context": "integrations", "project_id": project.ID, "integrationId": integration.ID, }).Error("integration project mismatch") continue } err = c.integrationService.FillIntegration(&integration) if err != nil { log.Error(err) return } switch integration.AuthMethod { case db.IntegrationAuthGitHub: ok := isValidHmacPayload( integration.AuthSecret.LoginPassword.Password, r.Header.Get("X-Hub-Signature-256"), payload, "sha256=") if !ok { log.WithFields(log.Fields{ "context": "integrations", }).Error("Invalid GitHub/HMAC signature") continue } case db.IntegrationAuthBitbucket: ok := isValidHmacPayload( integration.AuthSecret.LoginPassword.Password, r.Header.Get("x-hub-signature"), payload, "sha256=") if !ok { log.WithFields(log.Fields{ "context": "integrations", }).Error("Invalid Bitbucket/HMAC signature") continue } case db.IntegrationAuthHmac: ok := isValidHmacPayload( integration.AuthSecret.LoginPassword.Password, r.Header.Get(integration.AuthHeader), payload, "") if !ok { log.WithFields(log.Fields{ "context": "integrations", }).Error("Invalid HMAC signature") continue } case db.IntegrationAuthToken: if integration.AuthSecret.LoginPassword.Password != r.Header.Get(integration.AuthHeader) { log.WithFields(log.Fields{ "context": "integrations", }).Error("Invalid verification token") continue } case db.IntegrationAuthBasic: var username, password, auth = r.BasicAuth() if !auth || integration.AuthSecret.LoginPassword.Password != password || integration.AuthSecret.LoginPassword.Login != username { log.WithFields(log.Fields{ "context": "integrations", }).Error("Invalid BasicAuth: incorrect login or password") continue } case db.IntegrationAuthNone: // Do nothing default: log.WithFields(log.Fields{ "context": "integrations", }).Error("Unknown verification method: " + integration.AuthMethod) continue } if level != db.IntegrationAliasSingle { var matchers []db.IntegrationMatcher matchers, err = store.GetIntegrationMatchers(integration.ProjectID, db.RetrieveQueryParams{}, integration.ID) if err != nil { log.WithFields(log.Fields{ "context": "integrations", }).WithError(err).Error("Could not retrieve matchers") continue } var matched = false for _, matcher := range matchers { if Match(matcher, r.Header, payload) { matched = true continue } else { matched = false break } } if !matched { continue } } task := RunIntegration(integration, project, r, payload) if task != nil { w.Header().Add("X-Semaphore-Task-ID", strconv.Itoa(task.ID)) w.Header().Add("X-Semaphore-Template-ID", strconv.Itoa(task.TemplateID)) w.Header().Add("X-Semaphore-Project-ID", strconv.Itoa(task.ProjectID)) if task.IntegrationID != nil { w.Header().Add("X-Semaphore-Integration-ID", strconv.Itoa(*task.IntegrationID)) } if task.InventoryID != nil { w.Header().Add("X-Semaphore-Inventory-ID", strconv.Itoa(*task.InventoryID)) } } } w.WriteHeader(http.StatusNoContent) } func Match(matcher db.IntegrationMatcher, header http.Header, bodyBytes []byte) (matched bool) { switch matcher.MatchType { case db.IntegrationMatchHeader: return MatchCompare(header.Get(matcher.Key), matcher.Method, matcher.Value) case db.IntegrationMatchBody: var body = string(bodyBytes) switch matcher.BodyDataType { case db.IntegrationBodyDataJSON: value := gojsonq.New().JSONString(body).Find(matcher.Key) return MatchCompare(value, matcher.Method, matcher.Value) case db.IntegrationBodyDataString: return MatchCompare(body, matcher.Method, matcher.Value) } } return false } func MatchCompare(value any, method db.IntegrationMatchMethodType, expected string) bool { if intValue, ok := conv.ConvertFloatToIntIfPossible(value); ok { value = intValue } strValue := fmt.Sprintf("%v", value) switch method { case db.IntegrationMatchMethodEquals: return strValue == expected case db.IntegrationMatchMethodUnEquals: return strValue != expected case db.IntegrationMatchMethodContains: return strings.Contains(fmt.Sprintf("%v", value), expected) default: return false } } func GetTaskDefinition( integration db.Integration, payload []byte, h http.Header, extractorCreator func(projectID, integrationID int) ([]db.IntegrationExtractValue, error), ) (taskDefinition db.Task, err error) { var envValues = make([]db.IntegrationExtractValue, 0) var taskValues = make([]db.IntegrationExtractValue, 0) extractValuesForExtractor, err := extractorCreator(integration.ProjectID, integration.ID) if err != nil { return } for _, val := range extractValuesForExtractor { switch val.VariableType { case "", db.IntegrationVariableEnvironment: // "" handles null/empty for backward compatibility envValues = append(envValues, val) case db.IntegrationVariableTaskParam: taskValues = append(taskValues, val) } } var extractedEnvResults = Extract(envValues, h, payload) if integration.TaskParams != nil { taskDefinition = integration.TaskParams.CreateTask(integration.TemplateID) } else { taskDefinition = db.Task{ ProjectID: integration.ProjectID, TemplateID: integration.TemplateID, Params: make(db.MapStringAnyField), } } taskDefinition.IntegrationID = &integration.ID env := make(map[string]any) if taskDefinition.Environment != "" { err = json.Unmarshal([]byte(taskDefinition.Environment), &env) if err != nil { return } } for k, v := range extractedEnvResults { //if _, exists := env[k]; !exists { // env[k] = v //} env[k] = v } envStr, err := json.Marshal(env) if err != nil { return } taskDefinition.Environment = string(envStr) extractedTaskResults := Extract(taskValues, h, payload) for k, v := range extractedTaskResults { taskDefinition.Params[k] = v } return } func RunIntegration(integration db.Integration, project db.Project, r *http.Request, payload []byte) (taskRef *db.Task) { taskRef = nil log.Info(fmt.Sprintf("Running integration %d", integration.ID)) taskDefinition, err := GetTaskDefinition( integration, payload, r.Header, func(projectID, integrationID int) ([]db.IntegrationExtractValue, error) { return helpers.Store(r).GetIntegrationExtractValues(projectID, db.RetrieveQueryParams{}, integrationID) }) if err != nil { log.WithError(err).WithFields(log.Fields{ "context": "integrations", "integration_id": integration.ID, }).Error("Failed to get task definition") return } tpl, err := helpers.Store(r).GetTemplate(integration.ProjectID, integration.TemplateID) if err != nil { log.Error(err) return } pool := helpers.GetFromContext(r, "task_pool").(*task2.TaskPool) task, err := pool.AddTask(taskDefinition, nil, "", integration.ProjectID, tpl.App.NeedTaskAlias()) if err != nil { log.Error(err) return } taskRef = &task return } func Extract(extractValues []db.IntegrationExtractValue, h http.Header, payload []byte) (result map[string]string) { result = make(map[string]string) for _, extractValue := range extractValues { switch extractValue.ValueSource { case db.IntegrationExtractHeaderValue: result[extractValue.Variable] = h.Get(extractValue.Key) case db.IntegrationExtractBodyValue: switch extractValue.BodyDataType { case db.IntegrationBodyDataJSON: val := gojsonq.New().JSONString(string(payload)).Find(extractValue.Key) if val != nil { result[extractValue.Variable] = fmt.Sprintf("%v", val) } case db.IntegrationBodyDataString: result[extractValue.Variable] = string(payload) } } } return } ================================================ FILE: api/integration_test.go ================================================ package api import ( "encoding/json" "errors" "github.com/semaphoreui/semaphore/db" "github.com/stretchr/testify/assert" "net/http" "testing" "github.com/stretchr/testify/require" ) func TestExtract_HeaderAndCaseInsensitive(t *testing.T) { h := http.Header{} h.Set("x-token", "abc123") // lower-case to verify case-insensitive get values := []db.IntegrationExtractValue{ { Name: "Token header", ValueSource: db.IntegrationExtractHeaderValue, Key: "X-Token", // different case Variable: "TOKEN", VariableType: db.IntegrationVariableEnvironment, }, } got := Extract(values, h, nil) require.Equal(t, "abc123", got["TOKEN"], "TOKEN header value should match") } func TestExtract_JSONBody_VariousTypesAndMissing(t *testing.T) { payload := []byte(`{ "num": 42, "str": "hello", "bool": true, "nullv": null, "obj": {"k":"v"}, "arr": [1,2,3], "nested": {"items":[{"c":123},{"c":"str"}]} }`) values := []db.IntegrationExtractValue{ { // number coerced to string via fmt.Sprintf ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Key: "num", Variable: "NUM", }, { // string stays same content ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Key: "str", Variable: "STR", }, { // boolean -> "true" ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Key: "bool", Variable: "BOOL", }, { // null should not be set (Find returns nil or we skip when nil) ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Key: "nullv", Variable: "NULLV", }, { // array will be formatted with %v, expect Go-like format ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Key: "arr", Variable: "ARR", }, { // object -> formatted map with %v ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Key: "obj", Variable: "OBJ", }, { // missing key should not create an entry ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Key: "missing", Variable: "MISSING", }, { // nested array index path ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Key: "nested.items.[0].c", Variable: "NESTED_C", }, { // first element of arr ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Key: "arr.[0]", Variable: "ARR0", }, } got := Extract(values, http.Header{}, payload) // Basic scalar assertions assert.Equal(t, "42", got["NUM"], "NUM should equal stringified number") assert.Equal(t, "hello", got["STR"], "STR should match") assert.Equal(t, "true", got["BOOL"], "BOOL should be string 'true'") // Indexed lookups assert.Equal(t, "123", got["NESTED_C"], "NESTED_C should equal nested.items[0].c") assert.Equal(t, "1", got["ARR0"], "ARR0 should equal arr[0]") // Null should be absent assert.NotContains(t, got, "NULLV", "NULLV should not be present for null JSON value") // Array/object string formats: we assert non-empty presence rather than exact formatting, // because %v formatting of gojsonq return types may vary across versions. assert.Contains(t, got, "ARR", "ARR key should be present") assert.NotEmpty(t, got["ARR"], "ARR value should be non-empty") assert.Contains(t, got, "OBJ", "OBJ key should be present") assert.NotEmpty(t, got["OBJ"], "OBJ value should be non-empty") // Missing should not appear assert.NotContains(t, got, "MISSING", "MISSING should not be present for missing key") } func TestExtract_BodyString_ReturnsFullPayload(t *testing.T) { payload := []byte("raw body data here") values := []db.IntegrationExtractValue{ { ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataString, Variable: "BODY", Key: "ignored", }, } got := Extract(values, http.Header{}, payload) if got["BODY"] != string(payload) { t.Fatalf("expected BODY to equal full payload; got %q", got["BODY"]) } } func TestExtract_MalformedJSON_SkipsSetting(t *testing.T) { payload := []byte("{not: valid json}") values := []db.IntegrationExtractValue{ { ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Variable: "BAD", Key: "a.b", }, } got := Extract(values, http.Header{}, payload) if _, ok := got["BAD"]; ok { t.Fatalf("expected BAD to be absent for malformed JSON payload") } } func TestIntegrationMatch(t *testing.T) { body := []byte("{\"hook_id\": 4856239453}") var header = make(http.Header) matched := Match(db.IntegrationMatcher{ ID: 0, Name: "Test", IntegrationID: 0, MatchType: db.IntegrationMatchBody, Method: db.IntegrationMatchMethodEquals, BodyDataType: db.IntegrationBodyDataJSON, Key: "hook_id", Value: "4856239453", }, header, body) assert.True(t, matched) } func TestGetTaskDefinitionSuccess(t *testing.T) { integration := db.Integration{ ID: 11, ProjectID: 22, TemplateID: 33, TaskParams: &db.TaskParams{ ProjectID: 22, Environment: `{"existing":"value"}`, Params: db.MapStringAnyField{"original": "keep"}, }, } header := make(http.Header) header.Set("X-Env", "header-value") payload := []byte(`{"data":{"param":"payload-value"}}`) extractorCalled := false task, err := GetTaskDefinition(integration, payload, header, func(projectID, integrationID int) ([]db.IntegrationExtractValue, error) { extractorCalled = true if projectID != integration.ProjectID { t.Fatalf("expected projectID %d, got %d", integration.ProjectID, projectID) } if integrationID != integration.ID { t.Fatalf("expected integrationID %d, got %d", integration.ID, integrationID) } return []db.IntegrationExtractValue{ { VariableType: db.IntegrationVariableEnvironment, ValueSource: db.IntegrationExtractHeaderValue, Key: "X-Env", Variable: "HOOK_ENV", }, { VariableType: db.IntegrationVariableTaskParam, ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Key: "data.param", Variable: "payloadParam", }, }, nil }) assert.NoError(t, err) assert.True(t, extractorCalled) if assert.NotNil(t, task.IntegrationID) { assert.Equal(t, integration.ID, *task.IntegrationID) } assert.Equal(t, integration.ProjectID, task.ProjectID) assert.Equal(t, integration.TemplateID, task.TemplateID) assert.NotEmpty(t, task.Environment) var env map[string]any if assert.NoError(t, json.Unmarshal([]byte(task.Environment), &env)) { assert.Equal(t, "value", env["existing"]) assert.Equal(t, "header-value", env["HOOK_ENV"]) } if assert.NotNil(t, task.Params) { if assert.Contains(t, task.Params, "original") { assert.Equal(t, "keep", task.Params["original"]) } if assert.Contains(t, task.Params, "payloadParam") { payloadParam, ok := task.Params["payloadParam"].(string) assert.True(t, ok) assert.Equal(t, "payload-value", payloadParam) } } } func TestGetTaskDefinitionExtractorError(t *testing.T) { integration := db.Integration{ ID: 44, ProjectID: 55, TemplateID: 66, } header := make(http.Header) payload := []byte(`{}`) expectedErr := errors.New("extractor failure") extractorCalled := false task, err := GetTaskDefinition(integration, payload, header, func(projectID, integrationID int) ([]db.IntegrationExtractValue, error) { extractorCalled = true return nil, expectedErr }) assert.True(t, extractorCalled) assert.Error(t, err) assert.ErrorIs(t, err, expectedErr) assert.Nil(t, task.IntegrationID) } func TestGetTaskDefinitionInvalidEnvironmentJSON(t *testing.T) { integration := db.Integration{ ID: 77, ProjectID: 88, TemplateID: 99, TaskParams: &db.TaskParams{ ProjectID: 88, Environment: "{not-json}", Params: db.MapStringAnyField{}, }, } header := make(http.Header) payload := []byte(`{}`) _, err := GetTaskDefinition(integration, payload, header, func(projectID, integrationID int) ([]db.IntegrationExtractValue, error) { return nil, nil }) assert.Error(t, err) } func TestGetTaskDefinitionIntegrationWithoutTaskParams(t *testing.T) { integration := db.Integration{ ID: 44, ProjectID: 55, TemplateID: 66, } header := make(http.Header) payload := []byte(`{}`) extractorCalled := false task, err := GetTaskDefinition(integration, payload, header, func(projectID, integrationID int) ([]db.IntegrationExtractValue, error) { extractorCalled = true if projectID != integration.ProjectID { t.Fatalf("expected projectID %d, got %d", integration.ProjectID, projectID) } if integrationID != integration.ID { t.Fatalf("expected integrationID %d, got %d", integration.ID, integrationID) } return []db.IntegrationExtractValue{ { VariableType: db.IntegrationVariableEnvironment, ValueSource: db.IntegrationExtractHeaderValue, Key: "X-Env", Variable: "HOOK_ENV", }, { VariableType: db.IntegrationVariableTaskParam, ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Key: "data.param", Variable: "payloadParam", }, }, nil }) assert.True(t, extractorCalled) assert.Nil(t, err) assert.NotNil(t, task) } func TestGetTaskDefinitionWithExtractedEnvValues(t *testing.T) { // Test case 1: Empty environment should still include extracted values integration := db.Integration{ ID: 1, ProjectID: 1, TemplateID: 1, } // Create test payload payload := []byte("{\"branch\": \"main\", \"commit\": \"abc123\"}") // Create test request with headers req, _ := http.NewRequest("POST", "/webhook", nil) req.Header.Set("X-GitHub-Event", "push") // Mock extracted environment values (this would normally come from database) envValues := []db.IntegrationExtractValue{ { Variable: "BRANCH_NAME", ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Key: "branch", VariableType: db.IntegrationVariableEnvironment, }, { Variable: "COMMIT_HASH", ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Key: "commit", VariableType: db.IntegrationVariableEnvironment, }, { Variable: "EVENT_TYPE", ValueSource: db.IntegrationExtractHeaderValue, Key: "X-GitHub-Event", VariableType: db.IntegrationVariableEnvironment, }, } // Test Extract function directly first extractedEnvResults := Extract(envValues, req.Header, payload) if extractedEnvResults["BRANCH_NAME"] != "main" { t.Errorf("Expected BRANCH_NAME to be 'main', got '%s'", extractedEnvResults["BRANCH_NAME"]) } if extractedEnvResults["COMMIT_HASH"] != "abc123" { t.Errorf("Expected COMMIT_HASH to be 'abc123', got '%s'", extractedEnvResults["COMMIT_HASH"]) } if extractedEnvResults["EVENT_TYPE"] != "push" { t.Errorf("Expected EVENT_TYPE to be 'push', got '%s'", extractedEnvResults["EVENT_TYPE"]) } // Test case 1: Empty environment should include extracted values (FIXED behavior) taskDef1 := db.Task{ ProjectID: 1, TemplateID: 1, Environment: "", // Empty environment Params: make(db.MapStringAnyField), } taskDef1.IntegrationID = &integration.ID // Simulate the FIXED logic from GetTaskDefinition env1 := make(map[string]any) if taskDef1.Environment != "" { json.Unmarshal([]byte(taskDef1.Environment), &env1) } // Add extracted environment variables only if they don't conflict with // existing task definition variables (task definition has higher priority) for k, v := range extractedEnvResults { if _, exists := env1[k]; !exists { env1[k] = v } } envStr1, _ := json.Marshal(env1) taskDef1.Environment = string(envStr1) // Verify that extracted values ARE now in the environment var envCheck1 map[string]any json.Unmarshal([]byte(taskDef1.Environment), &envCheck1) if envCheck1["BRANCH_NAME"] != "main" { t.Errorf("Expected BRANCH_NAME to be 'main' in environment, got '%v'", envCheck1["BRANCH_NAME"]) } if envCheck1["COMMIT_HASH"] != "abc123" { t.Errorf("Expected COMMIT_HASH to be 'abc123' in environment, got '%v'", envCheck1["COMMIT_HASH"]) } if envCheck1["EVENT_TYPE"] != "push" { t.Errorf("Expected EVENT_TYPE to be 'push' in environment, got '%v'", envCheck1["EVENT_TYPE"]) } // Test case 2: Existing environment should merge with extracted values taskDef2 := db.Task{ ProjectID: 1, TemplateID: 1, Environment: `{"EXISTING_VAR": "existing_value"}`, // Existing environment Params: make(db.MapStringAnyField), } taskDef2.IntegrationID = &integration.ID env2 := make(map[string]any) if taskDef2.Environment != "" { json.Unmarshal([]byte(taskDef2.Environment), &env2) } // Add extracted environment variables only if they don't conflict with // existing task definition variables (task definition has higher priority) for k, v := range extractedEnvResults { if _, exists := env2[k]; !exists { env2[k] = v } } envStr2, _ := json.Marshal(env2) taskDef2.Environment = string(envStr2) // Verify that both existing and extracted values are in the environment var envCheck2 map[string]any json.Unmarshal([]byte(taskDef2.Environment), &envCheck2) if envCheck2["EXISTING_VAR"] != "existing_value" { t.Errorf("Expected EXISTING_VAR to be 'existing_value' in environment, got '%v'", envCheck2["EXISTING_VAR"]) } if envCheck2["BRANCH_NAME"] != "main" { t.Errorf("Expected BRANCH_NAME to be 'main' in environment, got '%v'", envCheck2["BRANCH_NAME"]) } if envCheck2["COMMIT_HASH"] != "abc123" { t.Errorf("Expected COMMIT_HASH to be 'abc123' in environment, got '%v'", envCheck2["COMMIT_HASH"]) } if envCheck2["EVENT_TYPE"] != "push" { t.Errorf("Expected EVENT_TYPE to be 'push' in environment, got '%v'", envCheck2["EVENT_TYPE"]) } // Test case 3: Task definition values should have priority over extracted values taskDef3 := db.Task{ ProjectID: 1, TemplateID: 1, Environment: `{"BRANCH_NAME": "production", "EXISTING_VAR": "from_task"}`, // Conflicts with extracted BRANCH_NAME Params: make(db.MapStringAnyField), } taskDef3.IntegrationID = &integration.ID env3 := make(map[string]any) if taskDef3.Environment != "" { json.Unmarshal([]byte(taskDef3.Environment), &env3) } // Add extracted environment variables only if they don't conflict with // existing task definition variables (task definition has higher priority) for k, v := range extractedEnvResults { if _, exists := env3[k]; !exists { env3[k] = v } } envStr3, _ := json.Marshal(env3) taskDef3.Environment = string(envStr3) // Verify that task definition values take precedence over extracted values var envCheck3 map[string]any json.Unmarshal([]byte(taskDef3.Environment), &envCheck3) // BRANCH_NAME should remain "production" from task definition, not "main" from extracted if envCheck3["BRANCH_NAME"] != "production" { t.Errorf("Expected BRANCH_NAME to be 'production' (task definition priority), got '%v'", envCheck3["BRANCH_NAME"]) } // EXISTING_VAR should remain from task definition if envCheck3["EXISTING_VAR"] != "from_task" { t.Errorf("Expected EXISTING_VAR to be 'from_task', got '%v'", envCheck3["EXISTING_VAR"]) } // Non-conflicting extracted values should still be added if envCheck3["COMMIT_HASH"] != "abc123" { t.Errorf("Expected COMMIT_HASH to be 'abc123' in environment, got '%v'", envCheck3["COMMIT_HASH"]) } if envCheck3["EVENT_TYPE"] != "push" { t.Errorf("Expected EVENT_TYPE to be 'push' in environment, got '%v'", envCheck3["EVENT_TYPE"]) } } // Test the Extract function to ensure it works correctly for both body and header extraction func TestExtractBodyAndHeaderValues(t *testing.T) { // Create test payload with nested JSON payload := []byte(`{"repository": {"name": "test-repo"}, "ref": "refs/heads/main", "pusher": {"name": "johndoe"}}`) // Create test request with headers req, _ := http.NewRequest("POST", "/webhook", nil) req.Header.Set("X-GitHub-Event", "push") req.Header.Set("X-GitHub-Delivery", "12345") // Test various extraction scenarios extractValues := []db.IntegrationExtractValue{ { Variable: "REPO_NAME", ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Key: "repository.name", }, { Variable: "GIT_REF", ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Key: "ref", }, { Variable: "PUSHER_NAME", ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataJSON, Key: "pusher.name", }, { Variable: "GITHUB_EVENT", ValueSource: db.IntegrationExtractHeaderValue, Key: "X-GitHub-Event", }, { Variable: "GITHUB_DELIVERY", ValueSource: db.IntegrationExtractHeaderValue, Key: "X-GitHub-Delivery", }, { Variable: "FULL_PAYLOAD", ValueSource: db.IntegrationExtractBodyValue, BodyDataType: db.IntegrationBodyDataString, }, } result := Extract(extractValues, req.Header, payload) // Verify body JSON extractions if result["REPO_NAME"] != "test-repo" { t.Errorf("Expected REPO_NAME to be 'test-repo', got '%s'", result["REPO_NAME"]) } if result["GIT_REF"] != "refs/heads/main" { t.Errorf("Expected GIT_REF to be 'refs/heads/main', got '%s'", result["GIT_REF"]) } if result["PUSHER_NAME"] != "johndoe" { t.Errorf("Expected PUSHER_NAME to be 'johndoe', got '%s'", result["PUSHER_NAME"]) } // Verify header extractions if result["GITHUB_EVENT"] != "push" { t.Errorf("Expected GITHUB_EVENT to be 'push', got '%s'", result["GITHUB_EVENT"]) } if result["GITHUB_DELIVERY"] != "12345" { t.Errorf("Expected GITHUB_DELIVERY to be '12345', got '%s'", result["GITHUB_DELIVERY"]) } // Verify string body extraction if result["FULL_PAYLOAD"] != string(payload) { t.Errorf("Expected FULL_PAYLOAD to match original payload") } } ================================================ FILE: api/login.go ================================================ package api import ( "bytes" "context" "crypto/rand" "crypto/tls" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "net/url" "os" "sort" "strings" "text/template" "time" "github.com/semaphoreui/semaphore/pkg/tz" "github.com/coreos/go-oidc/v3/oidc" "github.com/go-ldap/ldap/v3" "github.com/gorilla/mux" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/random" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" "golang.org/x/crypto/bcrypt" "golang.org/x/oauth2" ) func convertEntryToMap(entity *ldap.Entry) map[string]any { res := map[string]any{} for _, attr := range entity.Attributes { if len(attr.Values) == 0 { continue } res[attr.Name] = attr.Values[0] } return res } func tryFindLDAPUser(username, password string) (*db.User, error) { if !util.Config.LdapEnable { return nil, fmt.Errorf("LDAP not configured") } var l *ldap.Conn var err error if util.Config.LdapNeedTLS { l, err = ldap.DialTLS("tcp", util.Config.LdapServer, &tls.Config{ InsecureSkipVerify: true, }) } else { l, err = ldap.Dial("tcp", util.Config.LdapServer) } if err != nil { return nil, err } defer l.Close() //nolint:errcheck // First bind with a read only user if err = l.Bind(util.Config.LdapBindDN, util.Config.LdapBindPassword); err != nil { return nil, err } // Filter for the given username searchRequest := ldap.NewSearchRequest( util.Config.LdapSearchDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, fmt.Sprintf(util.Config.LdapSearchFilter, username), []string{util.Config.LdapMappings.DN}, nil, ) sr, err := l.Search(searchRequest) if err != nil { return nil, err } if len(sr.Entries) < 1 { return nil, nil } if len(sr.Entries) > 1 { return nil, fmt.Errorf("too many entries returned") } // Bind as the user userDN := sr.Entries[0].DN if err = l.Bind(userDN, password); err != nil { return nil, err } // Second time bind as read only user if err = l.Bind(util.Config.LdapBindDN, util.Config.LdapBindPassword); err != nil { return nil, err } // Get user info searchRequest = ldap.NewSearchRequest( util.Config.LdapSearchDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, fmt.Sprintf(util.Config.LdapSearchFilter, username), []string{util.Config.LdapMappings.DN, util.Config.LdapMappings.Mail, util.Config.LdapMappings.UID, util.Config.LdapMappings.CN}, nil, ) sr, err = l.Search(searchRequest) if err != nil { return nil, err } if len(sr.Entries) <= 0 { return nil, fmt.Errorf("ldap search returned no entries") } entry := convertEntryToMap(sr.Entries[0]) prepareClaims(entry) claims, err := parseClaims(entry, util.Config.LdapMappings) if err != nil { return nil, err } ldapUser := db.User{ Username: strings.ToLower(claims.username), Created: tz.Now(), Name: claims.name, Email: claims.email, External: true, Alert: false, } err = db.ValidateUser(ldapUser) if err != nil { jsonBytes, _ := json.Marshal(ldapUser) log.Error("LDAP returned incorrect user data: " + string(jsonBytes)) return nil, err } log.Info("User " + ldapUser.Name + " with email " + ldapUser.Email + " authorized via LDAP correctly") return &ldapUser, nil } // createSession creates session for passed user and stores session details // in cookies. func createSession(w http.ResponseWriter, r *http.Request, user db.User, oidc bool) { var err error var verificationMethod db.SessionVerificationMethod verified := false switch { case user.Totp != nil && util.Config.Auth.Totp.Enabled: verificationMethod = db.SessionVerificationTotp default: verificationMethod = db.SessionVerificationNone verified = true } newSession, err := helpers.Store(r).CreateSession(db.Session{ UserID: user.ID, Created: tz.Now(), LastActive: tz.Now(), IP: r.Header.Get("X-Real-IP"), UserAgent: r.Header.Get("user-agent"), Expired: false, VerificationMethod: verificationMethod, Verified: verified, }) if err != nil { log.WithError(err).WithFields(log.Fields{ "user_id": user.ID, "context": "session", }).Error("Failed to create session") helpers.WriteErrorStatus(w, "Failed to create session", http.StatusInternalServerError) return } encoded, err := util.Cookie.Encode("semaphore", map[string]any{ "user": user.ID, "session": newSession.ID, }) if err != nil { log.WithError(err).WithFields(log.Fields{ "user_id": user.ID, "context": "session", }).Error("Failed to encode session cookie") helpers.WriteErrorStatus(w, "Failed to create session", http.StatusInternalServerError) return } http.SetCookie(w, &http.Cookie{ Name: "semaphore", Value: encoded, Path: "/", HttpOnly: true, }) } func loginByPassword(store db.Store, login string, password string) (user db.User, err error) { user, err = store.GetUserByLoginOrEmail(login, login) if err != nil { return } if user.External { err = db.ErrNotFound return } err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) if err != nil { err = db.ErrNotFound return } return } func loginByLDAP(store db.Store, ldapUser db.User) (user db.User, err error) { user, err = store.GetUserByLoginOrEmail(ldapUser.Username, ldapUser.Email) if errors.Is(err, db.ErrNotFound) { user, err = store.CreateUserWithoutPassword(ldapUser) } if err != nil { return } if !user.External { err = db.ErrNotFound return } return } type loginMetadataOidcProvider struct { ID string `json:"id"` Name string `json:"name"` Color string `json:"color"` Icon string `json:"icon"` } type LoginTotpAuthMethod struct { AllowRecovery bool `json:"allow_recovery"` } type LoginEmailAuthMethod struct { } type LoginAuthMethods struct { Totp *LoginTotpAuthMethod `json:"totp,omitempty"` Email *LoginEmailAuthMethod `json:"email,omitempty"` } type loginMetadata struct { OidcProviders []loginMetadataOidcProvider `json:"oidc_providers"` LoginWithPassword bool `json:"login_with_password"` AuthMethods LoginAuthMethods `json:"auth_methods"` } // nolint: gocyclo func login(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" { config := &loginMetadata{ OidcProviders: make([]loginMetadataOidcProvider, len(util.Config.OidcProviders)), LoginWithPassword: !util.Config.PasswordLoginDisable, } i := 0 for k, v := range util.Config.OidcProviders { config.OidcProviders[i] = loginMetadataOidcProvider{ ID: k, Name: v.DisplayName, Color: v.Color, Icon: v.Icon, } i++ } sort.Slice(config.OidcProviders, func(i, j int) bool { a := util.Config.OidcProviders[config.OidcProviders[i].ID] b := util.Config.OidcProviders[config.OidcProviders[j].ID] return a.Order < b.Order }) if util.Config.Auth.Totp.Enabled { config.AuthMethods.Totp = &LoginTotpAuthMethod{ AllowRecovery: util.Config.Auth.Totp.AllowRecovery, } } helpers.WriteJSON(w, http.StatusOK, config) return } var login struct { Auth string `json:"auth" binding:"required"` Password string `json:"password" binding:"required"` } if !helpers.Bind(w, r, &login) { return } /* logic: - fetch user from ldap if enabled - fetch user from database by username/email - create user in database if doesn't exist & ldap record found - check password if non-ldap user - create session & send cookie */ login.Auth = strings.ToLower(login.Auth) var err error var ldapUser *db.User if util.Config.LdapEnable { ldapUser, err = tryFindLDAPUser(login.Auth, login.Password) if err != nil { log.WithError(err).WithFields(log.Fields{ "context": "ldap", "auth": login.Auth, }).Warn("Failed to find user in LDAP") w.WriteHeader(http.StatusInternalServerError) return } } var user db.User if ldapUser == nil { user, err = loginByPassword(helpers.Store(r), login.Auth, login.Password) } else { user, err = loginByLDAP(helpers.Store(r), *ldapUser) } if err != nil { if errors.Is(err, db.ErrNotFound) { w.WriteHeader(http.StatusUnauthorized) return } var validationError *db.ValidationError switch { case errors.As(err, &validationError): // TODO: Return more informative error code. } log.Error(err.Error()) w.WriteHeader(http.StatusInternalServerError) } createSession(w, r, user, false) w.WriteHeader(http.StatusNoContent) } // logout handles the user logout process by expiring the current session // and clearing the session cookie. // // Behavior: // - If a valid session exists, it is expired in the database. // - The session cookie is cleared by setting its value to an empty string // and its expiration date to a past time. // // Responses: // - 204 No Content: Logout successful. // - 500 Internal Server Error: An error occurred while expiring the session. func logout(w http.ResponseWriter, r *http.Request) { if session, ok := getSession(r); ok { err := helpers.Store(r).ExpireSession(session.UserID, session.ID) if err != nil { log.Error(err) w.WriteHeader(http.StatusInternalServerError) return } } http.SetCookie(w, &http.Cookie{ Name: "semaphore", Value: "", Expires: tz.Now().Add(24 * 7 * time.Hour * -1), Path: "/", HttpOnly: true, }) w.WriteHeader(http.StatusNoContent) } func getOidcProvider(id string, ctx context.Context, redirectPath string) (*oidc.Provider, *oauth2.Config, error) { provider, ok := util.Config.OidcProviders[id] if !ok { return nil, nil, fmt.Errorf("no such provider: %s", id) } config := oidc.ProviderConfig{ IssuerURL: provider.Endpoint.IssuerURL, AuthURL: provider.Endpoint.AuthURL, TokenURL: provider.Endpoint.TokenURL, UserInfoURL: provider.Endpoint.UserInfoURL, JWKSURL: provider.Endpoint.JWKSURL, Algorithms: provider.Endpoint.Algorithms, } oidcProvider := config.NewProvider(ctx) var err error if provider.AutoDiscovery != "" { oidcProvider, err = oidc.NewProvider(ctx, provider.AutoDiscovery) if err != nil { return nil, nil, err } } clientID := provider.ClientID if provider.ClientIDFile != "" { if clientID, err = getSecretFromFile(provider.ClientIDFile); err != nil { return nil, nil, err } } clientSecret := provider.ClientSecret if provider.ClientSecretFile != "" { if clientSecret, err = getSecretFromFile(provider.ClientSecretFile); err != nil { return nil, nil, err } } if redirectPath != "" { redirectPath = strings.TrimRight(redirectPath, "/") providerUrl, err2 := url.Parse(provider.RedirectURL) if err2 != nil { return nil, nil, err2 } providerPath := strings.TrimRight(providerUrl.Path, "/") if redirectPath == providerPath { redirectPath = "" } else if strings.HasPrefix(redirectPath, providerPath+"/") { redirectPath = redirectPath[len(providerPath):] } } oauthConfig := oauth2.Config{ Endpoint: oidcProvider.Endpoint(), ClientID: clientID, ClientSecret: clientSecret, RedirectURL: provider.RedirectURL + redirectPath, Scopes: provider.Scopes, } if len(oauthConfig.RedirectURL) == 0 { redirectURL, err := url.JoinPath(util.Config.WebHost, "api/auth/oidc", id, "redirect") if err != nil { return nil, nil, err } oauthConfig.RedirectURL = redirectURL if redirectURL != redirectPath { oauthConfig.RedirectURL += redirectPath } } if len(oauthConfig.Scopes) == 0 { oauthConfig.Scopes = []string{"openid", "profile", "email"} } return oidcProvider, &oauthConfig, nil } func oidcLogin(w http.ResponseWriter, r *http.Request) { pid := mux.Vars(r)["provider"] ctx := context.Background() loginURL, _ := url.JoinPath(util.Config.WebHost, "auth/login") returnPath := "" redirectPath := "" config, ok := util.Config.OidcProviders[pid] if !ok { log.Error(fmt.Errorf("no such provider: %s", pid)) http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) return } returnValue := r.URL.Query().Get("return") if returnValue != "" { if config.ReturnViaState { returnPath = returnValue } else { redirectPath = returnValue } } _, oauth, err := getOidcProvider(pid, ctx, redirectPath) if err != nil { log.Error(err.Error()) http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) return } state := generateStateOauthCookie(w, returnPath) u := oauth.AuthCodeURL(state) http.Redirect(w, r, u, http.StatusTemporaryRedirect) } type oAuthState struct { Csrf string `json:"csrf"` Return string `json:"return"` } func generateStateOauthCookie(w http.ResponseWriter, returnPath string) string { expiration := tz.Now().Add(365 * 24 * time.Hour) b := make([]byte, 16) _, err := rand.Read(b) if err != nil { panic(err) } state := oAuthState{ Csrf: base64.URLEncoding.EncodeToString(b), Return: returnPath, } // Secure flag is not set to allow Semaphore to be used without HTTPS inside private networks cookie := http.Cookie{ Name: "oauthstate", Value: state.Csrf, Expires: expiration, HttpOnly: true, SameSite: http.SameSiteLaxMode, } http.SetCookie(w, &cookie) stateBytes, err := json.Marshal(state) if err != nil { panic(err) } return base64.URLEncoding.EncodeToString(stateBytes) } type claimResult struct { username string name string email string } func parseClaim(str string, claims map[string]any) (string, bool) { for _, s := range strings.Split(str, "|") { s = strings.TrimSpace(s) if s == "" { continue } if strings.Contains(s, "{{") { tpl, err := template.New("").Parse(s) if err != nil { return "", false } buff := bytes.NewBufferString("") if err = tpl.Execute(buff, claims); err != nil { return "", false } res := buff.String() return res, res != "" } res, ok := claims[s].(string) if res != "" && ok { return res, ok } } return "", false } func prepareClaims(claims map[string]any) { for k, v := range claims { switch v := v.(type) { case float64: f := v i := int64(f) if float64(i) == f { claims[k] = i } case float32: f := v i := int64(f) if float32(i) == f { claims[k] = i } } } } func parseClaims(claims map[string]any, provider util.ClaimsProvider) (res claimResult, err error) { var ok bool res.email, ok = parseClaim(provider.GetEmailClaim(), claims) if !ok { err = fmt.Errorf("claim '%s' missing or has bad format", provider.GetEmailClaim()) return } res.username, ok = parseClaim(provider.GetUsernameClaim(), claims) if !ok { res.username = getRandomUsername() } res.name, ok = parseClaim(provider.GetNameClaim(), claims) if !ok { res.name = getRandomProfileName() } return } func claimOidcUserInfo(userInfo *oidc.UserInfo, provider util.OidcProvider) (res claimResult, err error) { claims := make(map[string]any) if err = userInfo.Claims(&claims); err != nil { return } prepareClaims(claims) return parseClaims(claims, &provider) } func claimOidcToken(idToken *oidc.IDToken, provider util.OidcProvider) (res claimResult, err error) { claims := make(map[string]any) if err = idToken.Claims(&claims); err != nil { return } prepareClaims(claims) return parseClaims(claims, &provider) } func getRandomUsername() string { return random.String(16) } func getRandomProfileName() string { return "Anonymous" } func getSecretFromFile(source string) (string, error) { content, err := os.ReadFile(source) if err != nil { return "", err } return string(content), nil } func oidcRedirect(w http.ResponseWriter, r *http.Request) { pid := mux.Vars(r)["provider"] oauthState, err := r.Cookie("oauthstate") loginURL, _ := url.JoinPath(util.Config.WebHost, "auth/login") if err != nil { log.Error(err.Error()) http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) return } s := r.FormValue("state") b, err := base64.URLEncoding.DecodeString(s) if err != nil { log.Error(err.Error()) http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) return } var stateData oAuthState err = json.Unmarshal(b, &stateData) if err != nil { log.Error(err.Error()) http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) return } if stateData.Csrf != oauthState.Value { http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) return } ctx := context.Background() _oidc, oauth, err := getOidcProvider(pid, ctx, r.URL.Path) if err != nil { log.Error(err.Error()) http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) return } provider, ok := util.Config.OidcProviders[pid] if !ok { log.Error(fmt.Errorf("no such provider: %s", pid)) http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) return } verifier := _oidc.Verifier(&oidc.Config{ClientID: oauth.ClientID}) code := r.URL.Query().Get("code") oauth2Token, err := oauth.Exchange(ctx, code) if err != nil { log.Error(err.Error()) http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) return } var claims claimResult // Extract the ID Token from OAuth2 token. rawIDToken, ok := oauth2Token.Extra("id_token").(string) if ok && rawIDToken != "" { var idToken *oidc.IDToken // Parse and verify ID Token payload. idToken, err = verifier.Verify(ctx, rawIDToken) if err == nil { claims, err = claimOidcToken(idToken, provider) } } else { var userInfo *oidc.UserInfo userInfo, err = _oidc.UserInfo(ctx, oauth2.StaticTokenSource(oauth2Token)) if err == nil { if userInfo.Email == "" { claims, err = claimOidcUserInfo(userInfo, provider) } else { claims.email = userInfo.Email claims.name = userInfo.Profile } } claims.username = getRandomUsername() if userInfo.Profile == "" { claims.name = getRandomProfileName() } } if err != nil { log.Error(err.Error()) http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) return } user, err := helpers.Store(r).GetUserByLoginOrEmail("", claims.email) // ignore username because it creates a lot of problems if err != nil { user = db.User{ Username: claims.username, Name: claims.name, Email: claims.email, External: true, } user, err = helpers.Store(r).CreateUserWithoutPassword(user) if err != nil { log.Error(err.Error()) http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) return } } if !user.External { log.Error(fmt.Errorf("OIDC user '%s' conflicts with local user", user.Username)) http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) return } createSession(w, r, user, true) config, ok := util.Config.OidcProviders[pid] if !ok { log.Error(fmt.Errorf("no such provider: %s", pid)) http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) return } redirectPath := "" if config.ReturnViaState { redirectPath = stateData.Return } else { redirectPath = mux.Vars(r)["redirect_path"] } if !strings.HasPrefix(redirectPath, "/") { redirectPath = "/" + redirectPath } redirectURL, err := url.JoinPath(util.Config.WebHost, redirectPath) if err != nil { log.Error(err) http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect) return } if redirectURL == "" { redirectURL = "/" } http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect) } ================================================ FILE: api/login_test.go ================================================ package api import ( "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/stretchr/testify/assert" ) func TestParseClaim(t *testing.T) { claims := map[string]any{ "username": "fiftin", "email": "", "id": 1234567, } res, ok := parseClaim("email | {{ .id }}@test.com", claims) assert.True(t, ok, "parseClaim should succeed") assert.Equal(t, "1234567@test.com", res, "Result should be formatted correctly") } func TestParseClaim2(t *testing.T) { claims := map[string]any{ "username": "fiftin", "email": "", "id": 1234567, } res, ok := parseClaim("username", claims) assert.True(t, ok, "parseClaim should succeed") assert.Equal(t, claims["username"], res, "Result should match username claim") } func TestParseClaim3(t *testing.T) { claims := map[string]any{ "username": "fiftin", "email": "", "id": 1234567, } _, ok := parseClaim("email", claims) assert.False(t, ok, "parseClaim should fail for empty email") } func TestParseClaim4(t *testing.T) { claims := map[string]any{ "username": "fiftin", "email": "", "id": 1234567, } _, ok := parseClaim("|", claims) assert.False(t, ok, "parseClaim should fail for invalid pattern") } func TestParseClaim5(t *testing.T) { claims := map[string]any{ "username": "fiftin", "email": "", "id": 123456757343.0, } prepareClaims(claims) res, ok := parseClaim("{{ .id }}", claims) assert.True(t, ok, "parseClaim should succeed") assert.Equal(t, "123456757343", res, "Result should match formatted ID") } func TestGenerateStateOauthCookie(t *testing.T) { w := httptest.NewRecorder() returnPath := "/dashboard" stateStr := generateStateOauthCookie(w, returnPath) // Test 1: Verify returned state is valid base64 stateBytes, err := base64.URLEncoding.DecodeString(stateStr) assert.NoError(t, err, "Returned state should be valid base64") // Test 2: Verify state contains valid JSON var state oAuthState err = json.Unmarshal(stateBytes, &state) assert.NoError(t, err, "State should contain valid JSON") // Test 3: Verify return path is preserved assert.Equal(t, returnPath, state.Return, "Return path should be preserved") // Test 4: Verify CSRF token is not empty assert.NotEmpty(t, state.Csrf, "CSRF token should not be empty") // Test 5: Verify CSRF token is valid base64 _, err = base64.URLEncoding.DecodeString(state.Csrf) assert.NoError(t, err, "CSRF token should be valid base64") // Test 6: Verify cookie is set cookies := w.Result().Cookies() assert.NotEmpty(t, cookies, "At least one cookie should be set") // Test 7: Verify cookie has correct name var oauthCookie *http.Cookie for _, cookie := range cookies { if cookie.Name == "oauthstate" { oauthCookie = cookie break } } assert.NotNil(t, oauthCookie, "Cookie 'oauthstate' should be set") // Test 8: Verify cookie value matches CSRF token in state assert.Equal(t, state.Csrf, oauthCookie.Value, "Cookie value should match CSRF token") // Test 9: Verify cookie has expiration set (should be ~365 days) assert.False(t, oauthCookie.Expires.IsZero(), "Cookie expiration should be set") expectedExpiration := time.Now().Add(365 * 24 * time.Hour) timeDiff := oauthCookie.Expires.Sub(expectedExpiration) if timeDiff < 0 { timeDiff = -timeDiff } // Allow 5 seconds tolerance for test execution time assert.LessOrEqual(t, timeDiff, 5*time.Second, "Cookie expiration should be within 5 seconds of expected") } func TestGenerateStateOauthCookieEmptyReturnPath(t *testing.T) { w := httptest.NewRecorder() returnPath := "" stateStr := generateStateOauthCookie(w, returnPath) // Decode and verify state stateBytes, err := base64.URLEncoding.DecodeString(stateStr) assert.NoError(t, err, "Returned state should be valid base64") var state oAuthState err = json.Unmarshal(stateBytes, &state) assert.NoError(t, err, "State should contain valid JSON") // Verify empty return path is preserved assert.Empty(t, state.Return, "Return path should be empty") } func TestGenerateStateOauthCookieUniqueness(t *testing.T) { // Generate two states and verify they have different CSRF tokens w1 := httptest.NewRecorder() w2 := httptest.NewRecorder() state1Str := generateStateOauthCookie(w1, "/path1") state2Str := generateStateOauthCookie(w2, "/path2") // Decode states state1Bytes, err1 := base64.URLEncoding.DecodeString(state1Str) state2Bytes, err2 := base64.URLEncoding.DecodeString(state2Str) assert.NoError(t, err1, "First state should be valid base64") assert.NoError(t, err2, "Second state should be valid base64") var state1, state2 oAuthState err1 = json.Unmarshal(state1Bytes, &state1) err2 = json.Unmarshal(state2Bytes, &state2) assert.NoError(t, err1, "First state should be valid JSON") assert.NoError(t, err2, "Second state should be valid JSON") // Verify CSRF tokens are different assert.NotEqual(t, state1.Csrf, state2.Csrf, "Multiple calls should generate different CSRF tokens") // Verify states are different assert.NotEqual(t, state1Str, state2Str, "Multiple calls should generate different state strings") } ================================================ FILE: api/options.go ================================================ package api import ( "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "net/http" ) func setOption(w http.ResponseWriter, r *http.Request) { currentUser := helpers.GetFromContext(r, "user").(*db.User) if !currentUser.Admin { helpers.WriteJSON(w, http.StatusForbidden, map[string]string{ "error": "User must be admin", }) return } var option db.Option if !helpers.Bind(w, r, &option) { return } err := helpers.Store(r).SetOption(option.Key, option.Value) if err != nil { helpers.WriteJSON(w, http.StatusInternalServerError, map[string]string{ "error": "Can not set option", }) return } helpers.WriteJSON(w, http.StatusOK, option) } func getOptions(w http.ResponseWriter, r *http.Request) { currentUser := helpers.GetFromContext(r, "user").(*db.User) if !currentUser.Admin { helpers.WriteJSON(w, http.StatusForbidden, map[string]string{ "error": "User must be admin", }) return } options, err := helpers.Store(r).GetOptions(db.RetrieveQueryParams{}) if err != nil { helpers.WriteJSON(w, http.StatusInternalServerError, map[string]string{ "error": "Can not get options", }) return } helpers.WriteJSON(w, http.StatusOK, options) } ================================================ FILE: api/projects/backup_restore.go ================================================ package projects import ( "io" "net/http" "strings" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" projectService "github.com/semaphoreui/semaphore/services/project" log "github.com/sirupsen/logrus" ) func GetBackup(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) store := helpers.Store(r) backup, err := projectService.GetBackup(project.ID, store) if err != nil { helpers.WriteError(w, err) return } str, err := backup.Marshal() if err != nil { helpers.WriteError(w, err) return } w.Header().Set("content-type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(str)) } func Restore(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "user").(*db.User) var backup projectService.BackupFormat buf := new(strings.Builder) if _, err := io.Copy(buf, r.Body); err != nil { log.Error(err) helpers.WriteError(w, err) return } str := buf.String() if err := backup.Unmarshal(str); err != nil { log.Error(err) helpers.WriteError(w, err) return } store := helpers.Store(r) if err := backup.Verify(); err != nil { log.Error(err) helpers.WriteError(w, err) return } var p *db.Project p, err := backup.Restore(*user, store) if err != nil { log.Error(err) helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, p) } ================================================ FILE: api/projects/environment.go ================================================ package projects import ( "errors" "fmt" "net/http" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/random" "github.com/semaphoreui/semaphore/services/server" ) type EnvironmentController struct { accessKeyRepo db.AccessKeyManager accessKeyService server.AccessKeyService encryptionService server.AccessKeyEncryptionService environmentService server.EnvironmentService } func NewEnvironmentController( accessKeyRepo db.AccessKeyManager, encryptionService server.AccessKeyEncryptionService, accessKeyService server.AccessKeyService, environmentService server.EnvironmentService, ) *EnvironmentController { return &EnvironmentController{ accessKeyRepo: accessKeyRepo, accessKeyService: accessKeyService, encryptionService: encryptionService, environmentService: environmentService, } } func (c *EnvironmentController) updateEnvironmentSecrets(env db.Environment) error { errors := make([]error, 0) for _, secret := range env.Secrets { err := secret.Validate() if err != nil { errors = append(errors, err) continue } var key db.AccessKey switch secret.Operation { case db.EnvironmentSecretCreate: var sourceStorageKey *string if env.SecretStorageKeyPrefix != nil { tmp := *env.SecretStorageKeyPrefix + random.String(10) sourceStorageKey = &tmp } var storageType *db.AccessKeySourceStorageType if env.SecretStorageID != nil { tmp := db.AccessKeySourceStorageVault storageType = &tmp } key, err = c.accessKeyService.Create(db.AccessKey{ Name: secret.Name, String: secret.Secret, EnvironmentID: &env.ID, ProjectID: &env.ProjectID, Type: db.AccessKeyString, Owner: secret.Type.GetAccessKeyOwner(), SourceStorageID: env.SecretStorageID, SourceStorageKey: sourceStorageKey, SourceStorageType: storageType, }) if err != nil { errors = append(errors, err) continue } case db.EnvironmentSecretDelete: key, err = c.accessKeyRepo.GetAccessKey(env.ProjectID, secret.ID) if err != nil { errors = append(errors, err) continue } if key.EnvironmentID == nil && *key.EnvironmentID == env.ID { errors = append(errors, err) continue } err = c.accessKeyService.Delete(env.ProjectID, secret.ID) case db.EnvironmentSecretUpdate: key, err = c.accessKeyRepo.GetAccessKey(env.ProjectID, secret.ID) if err != nil { errors = append(errors, err) continue } if key.EnvironmentID == nil && *key.EnvironmentID == env.ID { errors = append(errors, err) continue } updateKey := db.AccessKey{ ID: key.ID, ProjectID: key.ProjectID, Name: secret.Name, Type: db.AccessKeyString, Owner: key.Owner, SourceStorageID: env.SecretStorageID, } if secret.Secret != "" { updateKey.String = secret.Secret updateKey.OverrideSecret = true } err = c.accessKeyService.Update(updateKey) } } if len(errors) > 0 { return errors[0] } return nil } // EnvironmentMiddleware ensures an environment exists and loads it to the context func (c *EnvironmentController) EnvironmentMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) envID, err := helpers.GetIntParam("environment_id", w, r) if err != nil { w.WriteHeader(http.StatusBadRequest) return } env, err := helpers.Store(r).GetEnvironment(project.ID, envID) if err != nil { helpers.WriteError(w, err) return } if err = c.encryptionService.FillEnvironmentSecrets(&env, false); err != nil { helpers.WriteError(w, err) return } r = helpers.SetContextValue(r, "environment", env) next.ServeHTTP(w, r) }) } func GetEnvironmentRefs(w http.ResponseWriter, r *http.Request) { env := helpers.GetFromContext(r, "environment").(db.Environment) refs, err := helpers.Store(r).GetEnvironmentRefs(env.ProjectID, env.ID) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, refs) } // GetEnvironment retrieves sorted environments from the database func GetEnvironment(w http.ResponseWriter, r *http.Request) { // return single environment if request has environment ID if environment := helpers.GetFromContext(r, "environment"); environment != nil { helpers.WriteJSON(w, http.StatusOK, environment.(db.Environment)) return } project := helpers.GetFromContext(r, "project").(db.Project) env, err := helpers.Store(r).GetEnvironments(project.ID, helpers.QueryParams(r.URL)) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, env) } // UpdateEnvironment updates an existing environment in the database func (c *EnvironmentController) UpdateEnvironment(w http.ResponseWriter, r *http.Request) { oldEnv := helpers.GetFromContext(r, "environment").(db.Environment) var env db.Environment if !helpers.Bind(w, r, &env) { return } if env.ID != oldEnv.ID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Environment ID in body and URL must be the same", }) return } if env.ProjectID != oldEnv.ProjectID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Project ID in body and URL must be the same", }) return } if err := helpers.Store(r).UpdateEnvironment(env); err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogUpdate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: oldEnv.ProjectID, ObjectType: db.EventEnvironment, ObjectID: oldEnv.ID, Description: fmt.Sprintf("Environment %s updated", env.Name), }) if err := c.updateEnvironmentSecrets(env); err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } // AddEnvironment creates an environment in the database func (c *EnvironmentController) AddEnvironment(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) var env db.Environment if !helpers.Bind(w, r, &env) { return } if project.ID != env.ProjectID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Project ID in body and URL must be the same", }) } newEnv, err := helpers.Store(r).CreateEnvironment(env) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogCreate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: newEnv.ProjectID, ObjectType: db.EventEnvironment, ObjectID: newEnv.ID, Description: fmt.Sprintf("Environment %s created", newEnv.Name), }) if err = c.updateEnvironmentSecrets(newEnv); err != nil { helpers.WriteError(w, err) return } // Reload env env, err = helpers.Store(r).GetEnvironment(newEnv.ProjectID, newEnv.ID) if err != nil { helpers.WriteError(w, err) return } // Use empty array to avoid null in JSON env.Secrets = []db.EnvironmentSecret{} helpers.WriteJSON(w, http.StatusCreated, env) } // RemoveEnvironment deletes an environment from the database func (c *EnvironmentController) RemoveEnvironment(w http.ResponseWriter, r *http.Request) { env := helpers.GetFromContext(r, "environment").(db.Environment) err := c.environmentService.Delete(env.ProjectID, env.ID) //err := helpers.Store(r).DeleteEnvironment(env.ProjectID, env.ID) if errors.Is(err, db.ErrInvalidOperation) { helpers.WriteJSON(w, http.StatusBadRequest, map[string]any{ "error": "Environment is in use by one or more templates", "inUse": true, }) return } if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogDelete, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: env.ProjectID, ObjectType: db.EventEnvironment, ObjectID: env.ID, Description: fmt.Sprintf("Environment %s deleted", env.Name), }) w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/projects/integration.go ================================================ package projects import ( "fmt" "net/http" log "github.com/sirupsen/logrus" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" ) func IntegrationMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { integrationId, err := helpers.GetIntParam("integration_id", w, r) projectId, err := helpers.GetIntParam("project_id", w, r) if err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Invalid integration ID", }) return } integration, err := helpers.Store(r).GetIntegration(projectId, integrationId) if err != nil { helpers.WriteError(w, err) return } r = helpers.SetContextValue(r, "integration", integration) next.ServeHTTP(w, r) }) } func GetIntegration(w http.ResponseWriter, r *http.Request) { integration := helpers.GetFromContext(r, "integration").(db.Integration) helpers.WriteJSON(w, http.StatusOK, integration) } func GetIntegrations(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) integrations, err := helpers.Store(r).GetIntegrations(project.ID, helpers.QueryParams(r.URL), false) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, integrations) } func GetIntegrationRefs(w http.ResponseWriter, r *http.Request) { integration_id, err := helpers.GetIntParam("integration_id", w, r) if err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Invalid Integration ID", }) return } project := helpers.GetFromContext(r, "project").(db.Project) if err != nil { helpers.WriteError(w, err) return } refs, err := helpers.Store(r).GetIntegrationRefs(project.ID, integration_id) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, refs) } func AddIntegration(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) var integration db.Integration log.Info(fmt.Sprintf("Found Project: %v", project.ID)) if !helpers.Bind(w, r, &integration) { log.Info("Failed to bind for integration uploads") helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Project ID in body and URL must be the same", }) return } if integration.ProjectID != project.ID { log.Error(fmt.Sprintf("Project ID in body and URL must be the same: %v vs. %v", integration.ProjectID, project.ID)) helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Project ID in body and URL must be the same", }) return } err := integration.Validate() if err != nil { log.Error(err) helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": err.Error(), }) return } newIntegration, errIntegration := helpers.Store(r).CreateIntegration(integration) if errIntegration != nil { log.Error(errIntegration) helpers.WriteError(w, errIntegration) return } helpers.WriteJSON(w, http.StatusCreated, newIntegration) } func UpdateIntegration(w http.ResponseWriter, r *http.Request) { oldIntegration := helpers.GetFromContext(r, "integration").(db.Integration) var integration db.Integration if !helpers.Bind(w, r, &integration) { return } if integration.ID != oldIntegration.ID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Integration ID in body and URL must be the same", }) return } if integration.ProjectID != oldIntegration.ProjectID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Project ID in body and URL must be the same", }) return } err := helpers.Store(r).UpdateIntegration(integration) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } func DeleteIntegration(w http.ResponseWriter, r *http.Request) { integration_id, err := helpers.GetIntParam("integration_id", w, r) if err != nil { helpers.WriteError(w, err) return } project := helpers.GetFromContext(r, "project").(db.Project) err = helpers.Store(r).DeleteIntegration(project.ID, integration_id) if err == db.ErrInvalidOperation { helpers.WriteJSON(w, http.StatusBadRequest, map[string]any{ "error": "Integration failed to be deleted", }) return } w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/projects/integration_alias.go ================================================ package projects import ( "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/random" "github.com/semaphoreui/semaphore/util" "net/http" ) type publicAlias struct { ID int `json:"id"` URL string `json:"url"` } func getPublicAlias(alias db.IntegrationAlias) publicAlias { return publicAlias{ ID: alias.ID, URL: util.GetPublicAliasURL("integrations", alias.Alias), } } func getPublicAliases(aliases []db.IntegrationAlias) (res []publicAlias) { res = make([]publicAlias, 0) for _, alias := range aliases { res = append(res, getPublicAlias(alias)) } return } func GetIntegrationAlias(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) integration, ok := helpers.GetFromContext(r, "integration").(db.Integration) var integrationId *int if ok { integrationId = &integration.ID } aliases, err := helpers.Store(r).GetIntegrationAliases(project.ID, integrationId) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, getPublicAliases(aliases)) } func AddIntegrationAlias(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) integration, ok := helpers.GetFromContext(r, "integration").(db.Integration) var integrationId *int if ok { integrationId = &integration.ID } alias, err := helpers.Store(r).CreateIntegrationAlias(db.IntegrationAlias{ Alias: random.String(16), ProjectID: project.ID, IntegrationID: integrationId, }) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, getPublicAlias(alias)) } func RemoveIntegrationAlias(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) aliasID, err := helpers.GetIntParam("alias_id", w, r) if err != nil { helpers.WriteError(w, err) return } err = helpers.Store(r).DeleteIntegrationAlias(project.ID, aliasID) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/projects/integration_extract_value.go ================================================ package projects import ( "fmt" "net/http" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" log "github.com/sirupsen/logrus" ) func GetIntegrationExtractValue(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) valueId, err := helpers.GetIntParam("value_id", w, r) if err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Invalid IntegrationExtractValue ID", }) return } integration := helpers.GetFromContext(r, "integration").(db.Integration) var value db.IntegrationExtractValue value, err = helpers.Store(r).GetIntegrationExtractValue(project.ID, valueId, integration.ID) if err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": fmt.Sprintf("Failed to get IntegrationExtractValue, %v", err), }) return } helpers.WriteJSON(w, http.StatusOK, value) } func GetIntegrationExtractValues(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) integration := helpers.GetFromContext(r, "integration").(db.Integration) values, err := helpers.Store(r).GetIntegrationExtractValues(project.ID, helpers.QueryParams(r.URL), integration.ID) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, values) } func AddIntegrationExtractValue(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) integration := helpers.GetFromContext(r, "integration").(db.Integration) var value db.IntegrationExtractValue if !helpers.Bind(w, r, &value) { return } if value.IntegrationID != integration.ID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Extractor ID in body and URL must be the same", }) return } if err := value.Validate(); err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": err.Error(), }) return } newValue, err := helpers.Store(r).CreateIntegrationExtractValue(project.ID, value) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusCreated, newValue) } func UpdateIntegrationExtractValue(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) valueId, err := helpers.GetIntParam("value_id", w, r) if err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Invalid Value ID", }) return } integration := helpers.GetFromContext(r, "integration").(db.Integration) var value db.IntegrationExtractValue value, err = helpers.Store(r).GetIntegrationExtractValue(project.ID, valueId, integration.ID) if err != nil { helpers.WriteError(w, err) return } if !helpers.Bind(w, r, &value) { return } if value.ID != valueId { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Value ID in body and URL must be the same", }) return } err = helpers.Store(r).UpdateIntegrationExtractValue(project.ID, value) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } func GetIntegrationExtractValueRefs(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) valueId, err := helpers.GetIntParam("value_id", w, r) if err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Invalid Value ID", }) return } integration := helpers.GetFromContext(r, "integration").(db.Integration) var value db.IntegrationExtractValue value, err = helpers.Store(r).GetIntegrationExtractValue(project.ID, valueId, integration.ID) if err != nil { helpers.WriteError(w, err) return } refs, err := helpers.Store(r).GetIntegrationExtractValueRefs(project.ID, value.ID, value.IntegrationID) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, refs) } func DeleteIntegrationExtractValue(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) valueId, err := helpers.GetIntParam("value_id", w, r) if err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Invalid Value ID", }) return } integration := helpers.GetFromContext(r, "integration").(db.Integration) if err != nil { log.Error(err) helpers.WriteJSON(w, http.StatusBadRequest, map[string]any{ "error": "Integration Extract Value failed to be deleted", }) return } err = helpers.Store(r).DeleteIntegrationExtractValue(project.ID, valueId, integration.ID) if err == db.ErrInvalidOperation { helpers.WriteJSON(w, http.StatusBadRequest, map[string]any{ "error": "Integration Extract Value failed to be deleted", }) return } w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/projects/integration_matcher.go ================================================ package projects import ( // "strconv" "fmt" "net/http" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" log "github.com/sirupsen/logrus" ) func GetIntegrationMatcher(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) matcher_id, err := helpers.GetIntParam("matcher_id", w, r) if err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Invalid Matcher ID", }) return } integration := helpers.GetFromContext(r, "integration").(db.Integration) var matcher db.IntegrationMatcher matcher, err = helpers.Store(r).GetIntegrationMatcher(project.ID, matcher_id, integration.ID) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, matcher) } func GetIntegrationMatcherRefs(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) matcherId, err := helpers.GetIntParam("matcher_id", w, r) if err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Invalid Matcher ID", }) return } integration := helpers.GetFromContext(r, "integration").(db.Integration) var matcher db.IntegrationMatcher matcher, err = helpers.Store(r).GetIntegrationMatcher(project.ID, matcherId, integration.ID) if err != nil { helpers.WriteError(w, err) return } refs, err := helpers.Store(r).GetIntegrationMatcherRefs(project.ID, matcher.ID, matcher.IntegrationID) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, refs) } func GetIntegrationMatchers(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) integration := helpers.GetFromContext(r, "integration").(db.Integration) matchers, err := helpers.Store(r).GetIntegrationMatchers(project.ID, helpers.QueryParams(r.URL), integration.ID) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, matchers) } func AddIntegrationMatcher(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) integration := helpers.GetFromContext(r, "integration").(db.Integration) var matcher db.IntegrationMatcher if !helpers.Bind(w, r, &matcher) { return } if matcher.IntegrationID != integration.ID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Extractor ID in body and URL must be the same", }) return } err := matcher.Validate() if err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": err.Error(), }) return } newMatcher, err := helpers.Store(r).CreateIntegrationMatcher(project.ID, matcher) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, newMatcher) } func UpdateIntegrationMatcher(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) matcherId, err := helpers.GetIntParam("matcher_id", w, r) if err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Invalid Matcher ID", }) return } integration := helpers.GetFromContext(r, "integration").(db.Integration) var matcher db.IntegrationMatcher if !helpers.Bind(w, r, &matcher) { return } log.Info(fmt.Sprintf("Updating API Matcher %v for Extractor %v, matcher ID: %v", matcherId, integration.ID, matcher.ID)) err = helpers.Store(r).UpdateIntegrationMatcher(project.ID, matcher) log.Info(fmt.Sprintf("Err %s", err)) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } func DeleteIntegrationMatcher(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) matcherId, err := helpers.GetIntParam("matcher_id", w, r) if err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Invalid Matcher ID", }) return } integration := helpers.GetFromContext(r, "integration").(db.Integration) var matcher db.IntegrationMatcher matcher, err = helpers.Store(r).GetIntegrationMatcher(project.ID, matcherId, integration.ID) if err != nil { helpers.WriteError(w, err) return } err = helpers.Store(r).DeleteIntegrationMatcher(project.ID, matcher.ID, integration.ID) if err == db.ErrInvalidOperation { helpers.WriteJSON(w, http.StatusBadRequest, map[string]any{ "error": "Integration Matcher failed to be deleted", }) return } w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/projects/inventory.go ================================================ package projects import ( "errors" "fmt" "net/http" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "os" "path/filepath" "strings" ) // InventoryMiddleware ensures an inventory exists and loads it to the context func InventoryMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) inventoryID, err := helpers.GetIntParam("inventory_id", w, r) if err != nil { return } inventory, err := helpers.Store(r).GetInventory(project.ID, inventoryID) if err != nil { helpers.WriteError(w, err) return } r = helpers.SetContextValue(r, "inventory", inventory) next.ServeHTTP(w, r) }) } func GetInventoryRefs(w http.ResponseWriter, r *http.Request) { inventory := helpers.GetFromContext(r, "inventory").(db.Inventory) refs, err := helpers.Store(r).GetInventoryRefs(inventory.ProjectID, inventory.ID) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, refs) } // GetInventory returns an inventory from the database func GetInventory(w http.ResponseWriter, r *http.Request) { if inventory := helpers.GetFromContext(r, "inventory"); inventory != nil { helpers.WriteJSON(w, http.StatusOK, inventory.(db.Inventory)) return } project := helpers.GetFromContext(r, "project").(db.Project) params := helpers.QueryParamsWithOwner(r.URL, db.InventoryProps) app := r.URL.Query().Get("app") var types []db.InventoryType if app != "" { types = db.TemplateApp(app).InventoryTypes() } inventories, err := helpers.Store(r).GetInventories(project.ID, params, types) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, inventories) } // AddInventory creates an inventory in the database func AddInventory(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) var inventory db.Inventory if !helpers.Bind(w, r, &inventory) { return } if inventory.ProjectID != project.ID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Project ID in body and URL must be the same", }) return } switch inventory.Type { case db.InventoryStatic, db.InventoryStaticYaml, db.InventoryFile, db.InventoryTofuWorkspace, db.InventoryTerraformWorkspace: break default: helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Not supported inventory type", }) return } err := db.ValidateInventory(helpers.Store(r), &inventory) if err != nil { helpers.WriteError(w, err) return } newInventory, err := helpers.Store(r).CreateInventory(inventory) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogCreate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: project.ID, ObjectType: db.EventInventory, ObjectID: newInventory.ID, Description: fmt.Sprintf("Inventory %s created", inventory.Name), }) helpers.WriteJSON(w, http.StatusCreated, newInventory) } // IsValidInventoryPath tests a path to ensure it is below the cwd func IsValidInventoryPath(path string) bool { currentPath, err := os.Getwd() if err != nil { return false } absPath, err := filepath.Abs(path) if err != nil { return false } relPath, err := filepath.Rel(currentPath, absPath) if err != nil { return false } return !strings.HasPrefix(relPath, "..") } // UpdateInventory writes updated values to an existing inventory item in the database func UpdateInventory(w http.ResponseWriter, r *http.Request) { oldInventory := helpers.GetFromContext(r, "inventory").(db.Inventory) var inventory db.Inventory if !helpers.Bind(w, r, &inventory) { return } if inventory.ID != oldInventory.ID { helpers.WriteErrorStatus(w, "Inventory ID in body and URL must be the same", http.StatusBadRequest) return } if inventory.ProjectID != oldInventory.ProjectID { helpers.WriteErrorStatus(w, "project ID in body and URL must be the same", http.StatusBadRequest) return } switch inventory.Type { case db.InventoryTerraformWorkspace, db.InventoryTofuWorkspace: case db.InventoryStatic, db.InventoryStaticYaml: case db.InventoryFile: if !IsValidInventoryPath(inventory.Inventory) { helpers.WriteErrorStatus(w, "Invalid inventory file pathname. Must be: path/to/inventory.", http.StatusBadRequest) return } default: helpers.WriteErrorStatus(w, "unknown inventory type: "+string(inventory.Type), http.StatusBadRequest) return } if err := db.ValidateInventory(helpers.Store(r), &inventory); err != nil { helpers.WriteError(w, err) return } if err := helpers.Store(r).UpdateInventory(inventory); err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogUpdate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: oldInventory.ProjectID, ObjectType: db.EventInventory, ObjectID: oldInventory.ID, Description: fmt.Sprintf("Inventory %s updated", inventory.Name), }) w.WriteHeader(http.StatusNoContent) } // RemoveInventory deletes an inventory from the database func RemoveInventory(w http.ResponseWriter, r *http.Request) { inventory := helpers.GetFromContext(r, "inventory").(db.Inventory) var err error = helpers.Store(r).DeleteInventory(inventory.ProjectID, inventory.ID) if errors.Is(err, db.ErrInvalidOperation) { helpers.WriteJSON(w, http.StatusBadRequest, map[string]any{ "error": "Inventory is in use by one or more templates", "inUse": true, }) return } if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogDelete, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: inventory.ProjectID, ObjectType: db.EventInventory, ObjectID: inventory.ID, Description: fmt.Sprintf("Inventory %s deleted", inventory.Name), }) w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/projects/inventory_test.go ================================================ package projects import ( "runtime" "testing" ) func TestIsValidInventoryPath(t *testing.T) { if !IsValidInventoryPath("inventories/test") { t.Fatal(" a path below the cwd should be valid") } if !IsValidInventoryPath("inventories/test/../prod") { t.Fatal(" a path below the cwd should be valid") } if IsValidInventoryPath("/test/../../../inventory") { t.Fatal(" a path out of the cwd should be invalid") } if IsValidInventoryPath("/test/inventory") { t.Fatal(" a path out of the cwd should be invalid") } if runtime.GOOS == "windows" && IsValidInventoryPath("c:\\test\\inventory") { t.Fatal(" a path out of the cwd should be invalid") } } ================================================ FILE: api/projects/keys.go ================================================ package projects import ( "errors" "fmt" "net/http" "github.com/semaphoreui/semaphore/services/server" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" ) type KeyController struct { accessKeyService server.AccessKeyService } func NewKeyController( accessKeyService server.AccessKeyService, ) *KeyController { return &KeyController{ accessKeyService: accessKeyService, } } // KeyMiddleware ensures a key exists and loads it to the context func KeyMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) keyID, err := helpers.GetIntParam("key_id", w, r) if err != nil { return } key, err := helpers.Store(r).GetAccessKey(project.ID, keyID) if err != nil { helpers.WriteError(w, err) return } r = helpers.SetContextValue(r, "accessKey", key) next.ServeHTTP(w, r) }) } func GetKeyRefs(w http.ResponseWriter, r *http.Request) { key := helpers.GetFromContext(r, "accessKey").(db.AccessKey) refs, err := helpers.Store(r).GetAccessKeyRefs(*key.ProjectID, key.ID) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, refs) } // GetKeys retrieves sorted keys from the database func GetKeys(w http.ResponseWriter, r *http.Request) { if key := helpers.GetFromContext(r, "accessKey"); key != nil { k := key.(db.AccessKey) helpers.WriteJSON(w, http.StatusOK, k) return } project := helpers.GetFromContext(r, "project").(db.Project) var keys []db.AccessKey keys, err := helpers.Store(r).GetAccessKeys(project.ID, db.GetAccessKeyOptions{}, helpers.QueryParams(r.URL)) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, keys) } // AddKey adds a new key to the database func (c *KeyController) AddKey(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) var key db.AccessKey if !helpers.Bind(w, r, &key) { return } if key.ProjectID == nil || *key.ProjectID != project.ID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Project ID in body and URL must be the same", }) return } // Plain cannot be passed via a request key.Plain = nil key.IgnorePlain = true //if err := key.Validate(true); err != nil { // helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ // "error": err.Error(), // }) // return //} newKey, err := c.accessKeyService.Create(key) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogCreate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: *newKey.ProjectID, ObjectType: db.EventKey, ObjectID: newKey.ID, Description: fmt.Sprintf("Access Key %s created", key.Name), }) // Reload key to drop sensitive fields key, err = helpers.Store(r).GetAccessKey(*newKey.ProjectID, newKey.ID) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusCreated, key) } // UpdateKey updates key in database // nolint: gocyclo func (c *KeyController) UpdateKey(w http.ResponseWriter, r *http.Request) { var key db.AccessKey oldKey := helpers.GetFromContext(r, "accessKey").(db.AccessKey) if !helpers.Bind(w, r, &key) { return } // Plain cannot be passed via a request key.Plain = nil key.IgnorePlain = true repos, err := helpers.Store(r).GetRepositories(*key.ProjectID, db.RetrieveQueryParams{}) if err != nil { helpers.WriteError(w, err) return } for _, repo := range repos { if repo.SSHKeyID != key.ID { continue } err = repo.ClearCache() if err != nil { helpers.WriteError(w, err) return } } err = c.accessKeyService.Update(key) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogUpdate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: *oldKey.ProjectID, ObjectType: db.EventKey, ObjectID: oldKey.ID, Description: fmt.Sprintf("Access Key %s updated", key.Name), }) w.WriteHeader(http.StatusNoContent) } // RemoveKey deletes a key from the database func (c *KeyController) RemoveKey(w http.ResponseWriter, r *http.Request) { key := helpers.GetFromContext(r, "accessKey").(db.AccessKey) err := c.accessKeyService.Delete(*key.ProjectID, key.ID) if errors.Is(err, db.ErrInvalidOperation) { helpers.WriteJSON(w, http.StatusBadRequest, map[string]any{ "error": "Access Key is in use by one or more templates", "inUse": true, }) return } if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogDelete, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: *key.ProjectID, ObjectType: db.EventKey, ObjectID: key.ID, Description: fmt.Sprintf("Access Key %s deleted", key.Name), }) w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/projects/project.go ================================================ package projects import ( "errors" "net/http" "github.com/gorilla/mux" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/services/server" "github.com/semaphoreui/semaphore/services/tasks" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" ) // ProjectMiddleware ensures a project exists and loads it to the context func ProjectMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "user").(*db.User) projectID, err := helpers.GetIntParam("project_id", w, r) if err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Invalid project ID", }) return } // check if user in project's team projectUser, err := helpers.Store(r).GetProjectUser(projectID, user.ID) if !user.Admin && err != nil { helpers.WriteError(w, err) return } project, err := helpers.Store(r).GetProject(projectID) if err != nil { helpers.WriteError(w, err) return } roleSlug := projectUser.Role permissions := roleSlug.GetPermissions() role, err := helpers.Store(r).GetProjectOrGlobalRoleBySlug(projectID, string(projectUser.Role)) if err == nil { roleSlug = db.ProjectUserRole(role.Slug) permissions = role.Permissions } else if !errors.Is(err, db.ErrNotFound) { helpers.WriteError(w, err) return } if helpers.HasParam("template_id", r) { var templateID int templateID, err = helpers.GetIntParam("template_id", w, r) if err != nil { helpers.WriteError(w, err) return } var perm db.ProjectUserPermission perm, err = helpers.Store(r).GetTemplatePermission(project.ID, templateID, user.ID) if err != nil { helpers.WriteError(w, err) return } permissions |= perm } r = helpers.SetContextValue(r, "projectUserRole", roleSlug) r = helpers.SetContextValue(r, "permissions", permissions) r = helpers.SetContextValue(r, "project", project) next.ServeHTTP(w, r) }) } // GetMustCanMiddleware ensures that the user has administrator rights func GetMustCanMiddleware(permissions db.ProjectUserPermission) mux.MiddlewareFunc { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { me := helpers.GetFromContext(r, "user").(*db.User) userPerms := helpers.GetFromContext(r, "permissions").(db.ProjectUserPermission) can := (userPerms & permissions) == permissions if !me.Admin && r.Method != "GET" && r.Method != "HEAD" && !can { w.WriteHeader(http.StatusForbidden) return } next.ServeHTTP(w, r) }) } } type ProjectController struct { ProjectService server.ProjectService } // SendTestNotification triggers sending a test notification to enabled messengers for this project. func (c *ProjectController) SendTestNotification(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) // Respect project.Alert flag: if disabled, still return 204 without sending if !project.Alert { w.WriteHeader(http.StatusConflict) return } err := tasks.SendProjectTestAlerts(project, helpers.Store(r)) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } func (c *ProjectController) UpdateProject(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) var body db.Project if !helpers.Bind(w, r, &body) { return } if body.ID != project.ID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Project ID in body and URL must be the same", }) return } err := c.ProjectService.UpdateProject(body) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } // DeleteProject removes a project from the database func (c *ProjectController) DeleteProject(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) err := c.ProjectService.DeleteProject(project.ID) if err != nil { helpers.WriteError(w, err) return } err = util.Config.ClearProjectTmpDir(project.ID) if err != nil { log.Error(err) } w.WriteHeader(http.StatusNoContent) } // GetProject returns a project details func GetProject(w http.ResponseWriter, r *http.Request) { helpers.WriteJSON(w, http.StatusOK, helpers.GetFromContext(r, "project")) } func GetUserRole(w http.ResponseWriter, r *http.Request) { var result struct { Role db.ProjectUserRole `json:"role"` Permissions db.ProjectUserPermission `json:"permissions"` } result.Role = helpers.GetFromContext(r, "projectUserRole").(db.ProjectUserRole) result.Permissions = helpers.GetFromContext(r, "permissions").(db.ProjectUserPermission) helpers.WriteJSON(w, http.StatusOK, result) } func ClearCache(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) err := util.Config.ClearProjectTmpDir(project.ID) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/projects/projects.go ================================================ package projects import ( "net/http" "github.com/semaphoreui/semaphore/services/server" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" ) type ProjectsController struct { accessKeyService server.AccessKeyService } func NewProjectsController( accessKeyService server.AccessKeyService, ) *ProjectsController { return &ProjectsController{ accessKeyService: accessKeyService, } } // GetProjects returns all projects in this users context func GetProjects(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "user").(*db.User) var err error var projects []db.Project if user.Admin { projects, err = helpers.Store(r).GetAllProjects() } else { projects, err = helpers.Store(r).GetProjects(user.ID) } if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, projects) } func (c *ProjectsController) createDemoProject(projectID int, noneKeyID int, emptyEnvID int, store db.Store) (err error) { var demoRepo db.Repository var buildInv db.Inventory var devInv db.Inventory var prodInv db.Inventory var buildView db.View var deployView db.View var toolsView db.View buildView, err = store.CreateView(db.View{ ProjectID: projectID, Title: "Build", Position: 0, }) if err != nil { return } deployView, err = store.CreateView(db.View{ ProjectID: projectID, Title: "Deploy", Position: 1, }) if err != nil { return } toolsView, err = store.CreateView(db.View{ ProjectID: projectID, Title: "Tools", Position: 2, }) if err != nil { return } vaultKey, err := c.accessKeyService.Create(db.AccessKey{ Name: "Vault Password", Type: db.AccessKeyLoginPassword, ProjectID: &projectID, LoginPassword: db.LoginPassword{ Password: "RAX6yKN7sBn2qDagRPls", }, }) if err != nil { return } demoRepo, err = store.CreateRepository(db.Repository{ Name: "Demo", ProjectID: projectID, GitURL: "https://github.com/semaphoreui/semaphore-demo.git", GitBranch: "main", SSHKeyID: noneKeyID, }) if err != nil { return } buildInv, err = store.CreateInventory(db.Inventory{ Name: "Build", ProjectID: projectID, Inventory: "[builder]\nlocalhost ansible_connection=local", Type: "static", SSHKeyID: &noneKeyID, }) if err != nil { return } devInv, err = store.CreateInventory(db.Inventory{ Name: "Dev", ProjectID: projectID, Inventory: "invs/dev/hosts", Type: "file", SSHKeyID: &noneKeyID, }) if err != nil { return } prodInv, err = store.CreateInventory(db.Inventory{ Name: "Prod", ProjectID: projectID, Inventory: "invs/prod/hosts", Type: "file", SSHKeyID: &noneKeyID, }) var desc string if err != nil { return } desc = "Pings the website to provide a real-world example of using Semaphore." _, err = store.CreateTemplate(db.Template{ Name: "Ping semaphoreui.com", Playbook: "ping.yml", Description: &desc, ProjectID: projectID, InventoryID: &prodInv.ID, EnvironmentID: &emptyEnvID, RepositoryID: demoRepo.ID, App: db.AppAnsible, ViewID: &toolsView.ID, }) if err != nil { return } desc = "Creates a demo artifact and stores it in the cache." var startVersion = "1.0.0" buildTpl, err := store.CreateTemplate(db.Template{ Name: "Build demo app", Playbook: "build.yml", Type: db.TemplateBuild, ProjectID: projectID, InventoryID: &buildInv.ID, EnvironmentID: &emptyEnvID, RepositoryID: demoRepo.ID, StartVersion: &startVersion, App: db.AppAnsible, ViewID: &buildView.ID, }) if err != nil { return } var template db.Template template, err = store.CreateTemplate(db.Template{ Name: "Deploy demo app to Dev", Type: db.TemplateDeploy, Playbook: "deploy.yml", ProjectID: projectID, InventoryID: &devInv.ID, EnvironmentID: &emptyEnvID, RepositoryID: demoRepo.ID, BuildTemplateID: &buildTpl.ID, Autorun: true, App: db.AppAnsible, ViewID: &deployView.ID, }) if err != nil { return } _, err = store.CreateTemplateVault(db.TemplateVault{ ProjectID: projectID, TemplateID: template.ID, VaultKeyID: &vaultKey.ID, Name: nil, Type: "password", }) if err != nil { return } template, err = store.CreateTemplate(db.Template{ Name: "Deploy demo app to Production", Type: db.TemplateDeploy, Playbook: "deploy.yml", ProjectID: projectID, InventoryID: &prodInv.ID, EnvironmentID: &emptyEnvID, RepositoryID: demoRepo.ID, BuildTemplateID: &buildTpl.ID, App: db.AppAnsible, ViewID: &deployView.ID, }) if err != nil { return } _, err = store.CreateTemplateVault(db.TemplateVault{ ProjectID: projectID, TemplateID: template.ID, VaultKeyID: &vaultKey.ID, Name: nil, Type: "password", }) if err != nil { return } template, err = store.CreateTemplate(db.Template{ Name: "Apply infrastructure (OpenTofu)", Type: db.TemplateTask, Playbook: "", ProjectID: projectID, EnvironmentID: &emptyEnvID, RepositoryID: demoRepo.ID, BuildTemplateID: &buildTpl.ID, App: db.AppTofu, ViewID: &buildView.ID, }) if err != nil { return } template, err = store.CreateTemplate(db.Template{ Name: "Apply infrastructure (Terragrunt)", Type: db.TemplateTask, Playbook: "", ProjectID: projectID, EnvironmentID: &emptyEnvID, RepositoryID: demoRepo.ID, BuildTemplateID: &buildTpl.ID, App: db.AppTerragrunt, ViewID: &buildView.ID, }) if err != nil { return } template, err = store.CreateTemplate(db.Template{ Name: "Print system info (Bash)", Type: db.TemplateTask, Playbook: "print_system_info.sh", ProjectID: projectID, InventoryID: &prodInv.ID, EnvironmentID: &emptyEnvID, RepositoryID: demoRepo.ID, BuildTemplateID: &buildTpl.ID, App: db.AppBash, ViewID: &toolsView.ID, }) if err != nil { return } template, err = store.CreateTemplate(db.Template{ Name: "Print system info (PowerShell)", Type: db.TemplateTask, Playbook: "print_system_info.ps1", ProjectID: projectID, InventoryID: &prodInv.ID, EnvironmentID: &emptyEnvID, RepositoryID: demoRepo.ID, BuildTemplateID: &buildTpl.ID, App: db.AppPowerShell, ViewID: &toolsView.ID, }) return } // AddProject adds a new project to the database func (c *ProjectsController) AddProject(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "user").(*db.User) if !user.Admin && !util.Config.NonAdminCanCreateProject { log.Warn(user.Username + " is not permitted to edit users") w.WriteHeader(http.StatusUnauthorized) return } var bodyWithDemo struct { db.Project Demo bool `json:"demo"` } if !helpers.Bind(w, r, &bodyWithDemo) { return } body := bodyWithDemo.Project store := helpers.Store(r) body, err := store.CreateProject(body) if err != nil { helpers.WriteError(w, err) return } _, err = store.CreateProjectUser(db.ProjectUser{ProjectID: body.ID, UserID: user.ID, Role: db.ProjectOwner}) if err != nil { helpers.WriteError(w, err) return } noneKey, err := c.accessKeyService.Create(db.AccessKey{ Name: "None", Type: db.AccessKeyNone, ProjectID: &body.ID, }) if err != nil { helpers.WriteError(w, err) return } _, err = store.CreateView(db.View{ ProjectID: body.ID, Title: "All", Position: 0, Type: db.ViewTypeAll, }) if err != nil { helpers.WriteError(w, err) return } //_, err = store.CreateInventory(db.Inventory{ // Name: "None", // ProjectID: body.ID, // Type: "none", // SSHKeyID: &noneKey.ID, //}) //if err != nil { // helpers.WriteError(w, err) // return //} envStr := "{}" emptyEnv, err := store.CreateEnvironment(db.Environment{ Name: "Empty", ProjectID: body.ID, JSON: "{}", ENV: &envStr, }) if err != nil { return } if bodyWithDemo.Demo { err = c.createDemoProject(body.ID, noneKey.ID, emptyEnv.ID, store) if err != nil { helpers.WriteError(w, err) return } } helpers.EventLog(r, helpers.EventLogCreate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: body.ID, ObjectType: db.EventProject, ObjectID: body.ID, Description: "Project created", }) helpers.WriteJSON(w, http.StatusCreated, body) } ================================================ FILE: api/projects/repository.go ================================================ package projects import ( "errors" "fmt" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/db_lib" "github.com/semaphoreui/semaphore/util" "net/http" ) // RepositoryMiddleware ensures a repository exists and loads it to the context func RepositoryMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) repositoryID, err := helpers.GetIntParam("repository_id", w, r) if err != nil { return } repository, err := helpers.Store(r).GetRepository(project.ID, repositoryID) if err != nil { helpers.WriteError(w, err) return } r = helpers.SetContextValue(r, "repository", repository) next.ServeHTTP(w, r) }) } func GetRepositoryRefs(w http.ResponseWriter, r *http.Request) { repo := helpers.GetFromContext(r, "repository").(db.Repository) refs, err := helpers.Store(r).GetRepositoryRefs(repo.ProjectID, repo.ID) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, refs) } type RepositoryController struct { keyInstaller db_lib.AccessKeyInstaller } func NewRepositoryController(keyInstaller db_lib.AccessKeyInstaller) *RepositoryController { return &RepositoryController{ keyInstaller: keyInstaller, } } func (c *RepositoryController) GetRepositoryBranches(w http.ResponseWriter, r *http.Request) { repo := helpers.GetFromContext(r, "repository").(db.Repository) if repo.GetType() == db.RepositoryLocal || repo.GetType() == db.RepositoryFile { helpers.WriteJSON(w, http.StatusBadRequest, "Wrong repository type: "+repo.GetType()) return } git := db_lib.GitRepository{ Repository: repo, Client: db_lib.CreateDefaultGitClient(c.keyInstaller), } branches, err := git.GetRemoteBranches() if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, branches) } // GetRepositories returns all repositories in a project sorted by type func GetRepositories(w http.ResponseWriter, r *http.Request) { if repo := helpers.GetFromContext(r, "repository"); repo != nil { helpers.WriteJSON(w, http.StatusOK, repo.(db.Repository)) return } project := helpers.GetFromContext(r, "project").(db.Project) params := helpers.QueryParamsForProps(r.URL, db.RepositoryProps) repos, err := helpers.Store(r).GetRepositories(project.ID, params) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, repos) } // AddRepository creates a new repository in the database func AddRepository(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) var repository db.Repository if !helpers.Bind(w, r, &repository) { return } if repository.ProjectID != project.ID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Project ID in body and URL must be the same", }) } if err := db.ValidateRepository(helpers.Store(r), &repository); err != nil { helpers.WriteError(w, err) return } newRepo, err := helpers.Store(r).CreateRepository(repository) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogCreate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: newRepo.ProjectID, ObjectType: db.EventRepository, ObjectID: newRepo.ID, Description: fmt.Sprintf("Repository %s created", repository.GitURL), }) helpers.WriteJSON(w, http.StatusCreated, newRepo) } // UpdateRepository updates the values of a repository in the database func UpdateRepository(w http.ResponseWriter, r *http.Request) { oldRepo := helpers.GetFromContext(r, "repository").(db.Repository) var repository db.Repository if !helpers.Bind(w, r, &repository) { return } if repository.ID != oldRepo.ID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Repository ID in body and URL must be the same", }) return } if repository.ProjectID != oldRepo.ProjectID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Project ID in body and URL must be the same", }) return } if err := db.ValidateRepository(helpers.Store(r), &repository); err != nil { helpers.WriteError(w, err) return } if err := helpers.Store(r).UpdateRepository(repository); err != nil { helpers.WriteError(w, err) return } if oldRepo.GitURL != repository.GitURL { util.LogWarning(oldRepo.ClearCache()) } helpers.EventLog(r, helpers.EventLogUpdate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: oldRepo.ProjectID, ObjectType: db.EventRepository, ObjectID: oldRepo.ID, Description: fmt.Sprintf("Repository %s updated", repository.GitURL), }) w.WriteHeader(http.StatusNoContent) } // RemoveRepository deletes a repository from a project in the database func RemoveRepository(w http.ResponseWriter, r *http.Request) { repository := helpers.GetFromContext(r, "repository").(db.Repository) var err error = helpers.Store(r).DeleteRepository(repository.ProjectID, repository.ID) if errors.Is(err, db.ErrInvalidOperation) { helpers.WriteJSON(w, http.StatusBadRequest, map[string]any{ "error": "Repository is in use by one or more templates", "inUse": true, }) return } if err != nil { helpers.WriteError(w, err) return } util.LogWarning(repository.ClearCache()) helpers.EventLog(r, helpers.EventLogDelete, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: repository.ProjectID, ObjectType: db.EventRepository, ObjectID: repository.ID, Description: fmt.Sprintf("Repository %s deleted", repository.GitURL), }) w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/projects/schedules.go ================================================ package projects import ( "fmt" "net/http" "time" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/services/schedules" ) // SchedulesMiddleware ensures a template exists and loads it to the context func SchedulesMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) scheduleID, err := helpers.GetIntParam("schedule_id", w, r) if err != nil { // not specified schedule_id return } schedule, err := helpers.Store(r).GetSchedule(project.ID, scheduleID) if err != nil { helpers.WriteError(w, err) return } r = helpers.SetContextValue(r, "schedule", schedule) next.ServeHTTP(w, r) }) } func refreshSchedulePool(r *http.Request) { pool := helpers.GetFromContext(r, "schedule_pool").(schedules.SchedulePool) pool.Refresh() } // GetSchedule returns single template by ID func GetSchedule(w http.ResponseWriter, r *http.Request) { schedule := helpers.GetFromContext(r, "schedule").(db.Schedule) helpers.WriteJSON(w, http.StatusOK, schedule) } func GetProjectSchedules(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) tplSchedules, err := helpers.Store(r).GetProjectSchedules(project.ID, false, false) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, tplSchedules) } func GetTemplateSchedules(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) templateID, err := helpers.GetIntParam("template_id", w, r) if err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "template_id must be provided", }) return } tplSchedules, err := helpers.Store(r).GetTemplateSchedules(project.ID, templateID, true) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, tplSchedules) } func validateCronFormat(cronFormat string, w http.ResponseWriter) bool { err := schedules.ValidateCronFormat(cronFormat) if err == nil { return true } helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Cron: " + err.Error(), }) return false } func validateSchedulePayload(schedule *db.Schedule, w http.ResponseWriter) bool { if schedule.Type == "" { schedule.Type = db.ScheduleTypeCron } switch schedule.Type { case db.ScheduleTypeRunAt: if schedule.RunAt == nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "run_at must be provided for run_at schedules", }) return false } if schedule.RunAt.Before(time.Now()) { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "run_at must be in the future", }) return false } schedule.CronFormat = "" return true case db.ScheduleTypeCron: schedule.RunAt = nil return validateCronFormat(schedule.CronFormat, w) default: helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "invalid schedule type", }) return false } } func ValidateScheduleCronFormat(w http.ResponseWriter, r *http.Request) { var schedule db.Schedule if !helpers.Bind(w, r, &schedule) { return } _ = validateCronFormat(schedule.CronFormat, w) } // AddSchedule adds a template to the database func AddSchedule(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) var schedule db.Schedule if !helpers.Bind(w, r, &schedule) { return } if !validateSchedulePayload(&schedule, w) { return } schedule.ProjectID = project.ID schedule, err := helpers.Store(r).CreateSchedule(schedule) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogCreate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: project.ID, ObjectType: db.EventSchedule, ObjectID: schedule.ID, Description: fmt.Sprintf("Schedule ID %d created", schedule.ID), }) refreshSchedulePool(r) helpers.WriteJSON(w, http.StatusCreated, schedule) } // UpdateSchedule writes a schedule to an existing key in the database func UpdateSchedule(w http.ResponseWriter, r *http.Request) { oldSchedule := helpers.GetFromContext(r, "schedule").(db.Schedule) var schedule db.Schedule if !helpers.Bind(w, r, &schedule) { return } // project ID and schedule ID in the body and the path must be the same if schedule.ID != oldSchedule.ID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "schedule id in URL and in body must be the same", }) return } if schedule.ProjectID != oldSchedule.ProjectID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "You can not move schedule to other project", }) return } if !validateSchedulePayload(&schedule, w) { return } err := helpers.Store(r).UpdateSchedule(schedule) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogUpdate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: oldSchedule.ProjectID, ObjectType: db.EventSchedule, ObjectID: oldSchedule.ID, Description: fmt.Sprintf("Schedule ID %d updated", schedule.ID), }) refreshSchedulePool(r) w.WriteHeader(http.StatusNoContent) } func SetScheduleActive(w http.ResponseWriter, r *http.Request) { oldSchedule := helpers.GetFromContext(r, "schedule").(db.Schedule) var schedule struct { Active bool `json:"active"` } if !helpers.Bind(w, r, &schedule) { return } err := helpers.Store(r).SetScheduleActive(oldSchedule.ProjectID, oldSchedule.ID, schedule.Active) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogUpdate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: oldSchedule.ProjectID, ObjectType: db.EventSchedule, ObjectID: oldSchedule.ID, Description: fmt.Sprintf("Schedule ID %d updated", oldSchedule.ID), }) refreshSchedulePool(r) w.WriteHeader(http.StatusNoContent) } // RemoveSchedule deletes a schedule from the database func RemoveSchedule(w http.ResponseWriter, r *http.Request) { schedule := helpers.GetFromContext(r, "schedule").(db.Schedule) err := helpers.Store(r).DeleteSchedule(schedule.ProjectID, schedule.ID) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogDelete, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: schedule.ProjectID, ObjectType: db.EventSchedule, ObjectID: schedule.ID, Description: fmt.Sprintf("Schedule ID %d deleted", schedule.ID), }) refreshSchedulePool(r) w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/projects/secret_storages.go ================================================ package projects import ( "fmt" "net/http" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/services/server" ) type SecretStorageController struct { secretRepo db.SecretStorageRepository secretStorageService server.SecretStorageService } func SecretStorageMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) storageID, err := helpers.GetIntParam("storage_id", w, r) if err != nil { return } storage, err := helpers.Store(r).GetSecretStorage(project.ID, storageID) if err != nil { helpers.WriteError(w, err) return } keys, err := helpers.Store(r).GetAccessKeys(project.ID, db.GetAccessKeyOptions{ Owner: db.AccessKeySecretStorage, StorageID: &storage.ID, }, db.RetrieveQueryParams{}) if err != nil { helpers.WriteError(w, err) return } if len(keys) == 0 { helpers.WriteErrorStatus(w, "Access key not found", http.StatusNotFound) return } if keys[0].SourceStorageKey != nil { storage.Secret = *keys[0].SourceStorageKey } storage.SourceStorageType = keys[0].SourceStorageType r = helpers.SetContextValue(r, "secretStorage", storage) next.ServeHTTP(w, r) }) } func NewSecretStorageController( secretRepo db.SecretStorageRepository, secretStorageService server.SecretStorageService, ) *SecretStorageController { return &SecretStorageController{ secretRepo: secretRepo, secretStorageService: secretStorageService, } } func (c *SecretStorageController) GetRefs(w http.ResponseWriter, r *http.Request) { key := helpers.GetFromContext(r, "secretStorage").(db.SecretStorage) refs, err := helpers.Store(r).GetSecretStorageRefs(key.ProjectID, key.ID) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, refs) } func (c *SecretStorageController) GetSecretStorages(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) storages, err := c.secretStorageService.GetSecretStorages(project.ID) if err != nil { helpers.WriteError(w, err) } helpers.WriteJSON(w, http.StatusOK, storages) } func (c *SecretStorageController) GetSecretStorage(w http.ResponseWriter, r *http.Request) { storage := helpers.GetFromContext(r, "secretStorage").(db.SecretStorage) helpers.WriteJSON(w, http.StatusOK, storage) } func (c *SecretStorageController) Update(w http.ResponseWriter, r *http.Request) { oldStorage := helpers.GetFromContext(r, "secretStorage").(db.SecretStorage) var storage db.SecretStorage if !helpers.Bind(w, r, &storage) { return } if storage.ID != oldStorage.ID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Secret storage id in URL and in body must be the same", }) return } if storage.ProjectID != oldStorage.ProjectID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "You can not move secret storage to other project", }) return } err := c.secretStorageService.Update(storage) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogUpdate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: oldStorage.ProjectID, ObjectType: db.EventSchedule, ObjectID: oldStorage.ID, Description: fmt.Sprintf("Secret storage with ID %d has been updated", storage.ID), }) helpers.WriteJSON(w, http.StatusOK, storage) } func (c *SecretStorageController) Add(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) var storage db.SecretStorage if !helpers.Bind(w, r, &storage) { return } if storage.ProjectID != project.ID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Project ID in body and URL must be the same", }) return } newStorage, err := c.secretStorageService.Create(storage) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogCreate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: newStorage.ProjectID, ObjectType: db.EventKey, ObjectID: newStorage.ID, Description: fmt.Sprintf("Secret storage %s has been created", storage.Name), }) helpers.WriteJSON(w, http.StatusCreated, newStorage) } func (c *SecretStorageController) Remove(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) storageID, err := helpers.GetIntParam("storage_id", w, r) if err != nil { helpers.WriteError(w, err) return } err = c.secretStorageService.Delete(project.ID, storageID) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } func (c *SecretStorageController) SyncSecrets(w http.ResponseWriter, r *http.Request) { oldStorage := helpers.GetFromContext(r, "secretStorage").(db.SecretStorage) var storage db.SecretStorage if !helpers.Bind(w, r, &storage) { return } if storage.ID != oldStorage.ID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Secret storage id in URL and in body must be the same", }) return } if storage.ProjectID != oldStorage.ProjectID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "You can not move secret storage to other project", }) return } err := c.secretStorageService.SyncSecrets(storage) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogUpdate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: oldStorage.ProjectID, ObjectType: db.EventSchedule, ObjectID: oldStorage.ID, Description: fmt.Sprintf("Secret storage with ID %d has been synced", storage.ID), }) helpers.WriteJSON(w, http.StatusOK, storage) } ================================================ FILE: api/projects/tasks.go ================================================ package projects import ( "bytes" "encoding/json" "errors" "net/http" "strconv" "time" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/common_errors" "github.com/semaphoreui/semaphore/services/tasks" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" ) type TaskController struct { ansibleTaskRepo db.AnsibleTaskRepository } func NewTaskController(ansibleTaskRepo db.AnsibleTaskRepository) *TaskController { return &TaskController{ ansibleTaskRepo: ansibleTaskRepo, } } func taskPool(r *http.Request) *tasks.TaskPool { return helpers.GetFromContext(r, "task_pool").(*tasks.TaskPool) } // AddTask inserts a task into the database and returns a header or returns error func AddTask(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) user := helpers.GetFromContext(r, "user").(*db.User) taskObj := helpers.GetFromContext(r, "task").(db.Task) tpl, err := helpers.Store(r).GetTemplate(project.ID, taskObj.TemplateID) if err != nil { helpers.WriteError(w, err) return } newTask, err := taskPool(r).AddTask( taskObj, &user.ID, user.Username, project.ID, tpl.App.NeedTaskAlias(), ) if errors.Is(err, common_errors.ErrInvalidSubscription) { helpers.WriteErrorStatus(w, "No active subscription available.", http.StatusForbidden) return } if err != nil { log.WithFields(log.Fields{ "context": "AddTask", "project_id": project.ID, "template_id": taskObj.TemplateID, "user_id": user.ID, }).WithError(err).Error("Cannot add task") w.WriteHeader(http.StatusInternalServerError) return } helpers.WriteJSON(w, http.StatusCreated, newTask) } // GetTasksList returns a list of tasks for the current project in desc order to limit or error func GetTasksList(w http.ResponseWriter, r *http.Request, limit int) { project := helpers.GetFromContext(r, "project").(db.Project) tpl := helpers.GetFromContext(r, "template") var err error var tasks []db.TaskWithTpl if tpl != nil { tasks, err = helpers.Store(r).GetTemplateTasks(tpl.(db.Template).ProjectID, tpl.(db.Template).ID, db.RetrieveQueryParams{ Count: limit, }) } else { tasks, err = helpers.Store(r).GetProjectTasks(project.ID, db.RetrieveQueryParams{ Count: limit, }) } if err != nil { util.LogErrorF(err, log.Fields{"error": "Bad request. Cannot get tasks list from database"}) w.WriteHeader(http.StatusBadRequest) return } helpers.WriteJSON(w, http.StatusOK, tasks) } // GetAllTasks returns all tasks for the current project func GetAllTasks(w http.ResponseWriter, r *http.Request) { GetTasksList(w, r, 1000) } // GetLastTasks returns the hundred most recent tasks func GetLastTasks(w http.ResponseWriter, r *http.Request) { str := r.URL.Query().Get("limit") limit, err := strconv.Atoi(str) if err != nil || limit <= 0 || limit > 200 { limit = 200 } GetTasksList(w, r, limit) } // GetTask returns a task based on its id func GetTask(w http.ResponseWriter, r *http.Request) { task := helpers.GetFromContext(r, "task").(db.Task) helpers.WriteJSON(w, http.StatusOK, task) } func GetTaskPermissionsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) user := helpers.GetFromContext(r, "user").(*db.User) task := helpers.GetFromContext(r, "task").(db.Task) permissions := helpers.GetFromContext(r, "permissions").(db.ProjectUserPermission) perm, err := helpers.Store(r).GetTemplatePermission(project.ID, task.TemplateID, user.ID) if err != nil { w.WriteHeader(http.StatusBadRequest) } permissions |= perm r = helpers.SetContextValue(r, "permissions", permissions) next.ServeHTTP(w, r) }) } // GetTaskMiddleware is middleware that gets a task by id and sets the context to it or panics func GetTaskMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) taskID, err := helpers.GetIntParam("task_id", w, r) if err != nil { util.LogErrorF(err, log.Fields{"error": "Bad request. Cannot get task_id from request"}) w.WriteHeader(http.StatusBadRequest) return } task, err := helpers.Store(r).GetTask(project.ID, taskID) if err != nil { util.LogErrorF(err, log.Fields{"error": "Bad request. Cannot get task from database"}) w.WriteHeader(http.StatusBadRequest) return } r = helpers.SetContextValue(r, "task", task) next.ServeHTTP(w, r) }) } // GetTaskMiddleware is middleware that gets a task by id and sets the context to it or panics func NewTaskMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var taskObj db.Task if !helpers.Bind(w, r, &taskObj) { return } r = helpers.SetContextValue(r, "task", taskObj) next.ServeHTTP(w, r) }) } func (c *TaskController) GetAnsibleTaskHosts(w http.ResponseWriter, r *http.Request) { task := helpers.GetFromContext(r, "task").(db.Task) project := helpers.GetFromContext(r, "project").(db.Project) hosts, err := c.ansibleTaskRepo.GetAnsibleTaskHosts(project.ID, task.ID) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, hosts) } func (c *TaskController) GetAnsibleTaskErrors(w http.ResponseWriter, r *http.Request) { task := helpers.GetFromContext(r, "task").(db.Task) project := helpers.GetFromContext(r, "project").(db.Project) hosts, err := c.ansibleTaskRepo.GetAnsibleTaskErrors(project.ID, task.ID) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, hosts) } // GetTaskStages returns the logged task stages by id and writes it as json or returns error func GetTaskStages(w http.ResponseWriter, r *http.Request) { task := helpers.GetFromContext(r, "task").(db.Task) project := helpers.GetFromContext(r, "project").(db.Project) stages, err := helpers.Store(r).GetTaskStages(project.ID, task.ID) if err != nil { helpers.WriteError(w, err) return } for i := range stages { if stages[i].JSON == "" { continue } var res any err = json.Unmarshal([]byte(stages[i].JSON), &res) if err != nil { helpers.WriteError(w, err) return } stages[i].Result = res } helpers.WriteJSON(w, http.StatusOK, stages) } // GetTaskOutput returns the logged task output by id and writes it as json or returns error func GetTaskOutput(w http.ResponseWriter, r *http.Request) { task := helpers.GetFromContext(r, "task").(db.Task) project := helpers.GetFromContext(r, "project").(db.Project) var output []db.TaskOutput output, err := helpers.Store(r).GetTaskOutputs(project.ID, task.ID, db.RetrieveQueryParams{}) if err != nil { util.LogErrorF(err, log.Fields{"error": "Bad request. Cannot get task output from database"}) w.WriteHeader(http.StatusBadRequest) return } helpers.WriteJSON(w, http.StatusOK, output) } func outputToBytes(lines []db.TaskOutput) []byte { var buffer bytes.Buffer for _, line := range lines { output := util.ClearFromAnsiCodes(line.Output) buffer.WriteString(output) buffer.WriteByte('\n') } return buffer.Bytes() } func GetTaskRawOutput(w http.ResponseWriter, r *http.Request) { task := helpers.GetFromContext(r, "task").(db.Task) project := helpers.GetFromContext(r, "project").(db.Project) const chunkSize = 10000 offset := 0 eof := false for !eof { var output []db.TaskOutput output, err := helpers.Store(r).GetTaskOutputs(project.ID, task.ID, db.RetrieveQueryParams{Offset: offset, Count: chunkSize}) if err != nil { if offset == 0 { util.LogErrorF(err, log.Fields{"error": "Bad request. Cannot get task output from database"}) w.WriteHeader(http.StatusBadRequest) return } util.LogErrorF(err, log.Fields{"error": "Cannot get task output from database"}) return } if offset == 0 { w.Header().Set("content-type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) } readSize := len(output) if readSize > 0 { offset += readSize data := outputToBytes(output) if _, err := w.Write(data); err != nil { return } } eof = readSize < chunkSize } } func ConfirmTask(w http.ResponseWriter, r *http.Request) { targetTask := helpers.GetFromContext(r, "task").(db.Task) project := helpers.GetFromContext(r, "project").(db.Project) if targetTask.ProjectID != project.ID { w.WriteHeader(http.StatusBadRequest) return } err := taskPool(r).ConfirmTask(targetTask) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } func RejectTask(w http.ResponseWriter, r *http.Request) { targetTask := helpers.GetFromContext(r, "task").(db.Task) project := helpers.GetFromContext(r, "project").(db.Project) if targetTask.ProjectID != project.ID { w.WriteHeader(http.StatusBadRequest) return } err := taskPool(r).RejectTask(targetTask) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } func StopTask(w http.ResponseWriter, r *http.Request) { targetTask := helpers.GetFromContext(r, "task").(db.Task) project := helpers.GetFromContext(r, "project").(db.Project) if targetTask.ProjectID != project.ID { w.WriteHeader(http.StatusBadRequest) return } var stopObj struct { Force bool `json:"force"` } if !helpers.Bind(w, r, &stopObj) { return } err := taskPool(r).StopTask(targetTask, stopObj.Force) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } // RemoveTask removes a task from the database func RemoveTask(w http.ResponseWriter, r *http.Request) { targetTask := helpers.GetFromContext(r, "task").(db.Task) editor := helpers.GetFromContext(r, "user").(*db.User) project := helpers.GetFromContext(r, "project").(db.Project) activeTask := taskPool(r).GetTask(targetTask.ID) if activeTask != nil { // can't delete task in queue or running // task must be stopped firstly w.WriteHeader(http.StatusBadRequest) return } if !editor.Admin { log.Warn(editor.Username + " is not permitted to delete task logs") w.WriteHeader(http.StatusUnauthorized) return } err := helpers.Store(r).DeleteTaskWithOutputs(project.ID, targetTask.ID) if err != nil { util.LogErrorF(err, log.Fields{"error": "Bad request. Cannot delete task from database"}) w.WriteHeader(http.StatusBadRequest) return } w.WriteHeader(http.StatusNoContent) } func GetTaskStats(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) var tplID *int if tpl := helpers.GetFromContext(r, "template"); tpl != nil { id := tpl.(db.Template).ID tplID = &id } filter := db.TaskFilter{} if start := r.URL.Query().Get("start"); start != "" { d, err := time.Parse("2006-01-02", start) if err != nil { helpers.WriteErrorStatus(w, "Invalid start date", http.StatusBadRequest) return } filter.Start = &d } if end := r.URL.Query().Get("end"); end != "" { d, err := time.Parse("2006-01-02", end) if err != nil { helpers.WriteErrorStatus(w, "Invalid end date", http.StatusBadRequest) return } filter.End = &d } if userId := r.URL.Query().Get("user_id"); userId != "" { u, err := strconv.Atoi(userId) if err != nil { helpers.WriteErrorStatus(w, "Invalid user_id", http.StatusBadRequest) return } filter.UserID = &u } stats, err := helpers.Store(r).GetTaskStats(project.ID, tplID, db.TaskStatUnitDay, filter) if err != nil { util.LogErrorF(err, log.Fields{"error": "Bad request. Cannot get task stats from database"}) w.WriteHeader(http.StatusBadRequest) return } helpers.WriteJSON(w, http.StatusOK, stats) } func (c *TaskController) StopAllTasks(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) tpl := helpers.GetFromContext(r, "template").(db.Template) var stopObj struct { Force bool `json:"force"` } // optional body; ignore bind error and default Force=false if ok := helpers.Bind(w, r, &stopObj); !ok { helpers.WriteErrorStatus(w, "Not allowed", http.StatusBadRequest) return } taskPool(r).StopTasksByTemplate(project.ID, tpl.ID, stopObj.Force) w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/projects/templates.go ================================================ package projects import ( "fmt" "net/http" "github.com/semaphoreui/semaphore/util" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" ) // TemplatesMiddleware ensures a template exists and loads it to the context func TemplatesMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) templateID, err := helpers.GetIntParam("template_id", w, r) if err != nil { return } template, err := helpers.Store(r).GetTemplate(project.ID, templateID) if err != nil { helpers.WriteError(w, err) return } r = helpers.SetContextValue(r, "template", template) next.ServeHTTP(w, r) }) } type TemplateController struct { templateRepo db.TemplateManager roleRepo db.RoleRepository } func NewTemplateController( templateRepo db.TemplateManager, roleRepo db.RoleRepository, ) *TemplateController { return &TemplateController{ templateRepo: templateRepo, roleRepo: roleRepo, } } // GetTemplate returns single template by ID func GetTemplate(w http.ResponseWriter, r *http.Request) { template := helpers.GetFromContext(r, "template").(db.Template) permissions := helpers.GetFromContext(r, "permissions").(db.ProjectUserPermission) res := db.TemplateWithPerms{ Template: template, Permissions: &permissions, } helpers.WriteJSON(w, http.StatusOK, res) } func GetTemplateRefs(w http.ResponseWriter, r *http.Request) { tpl := helpers.GetFromContext(r, "template").(db.Template) refs, err := helpers.Store(r).GetTemplateRefs(tpl.ProjectID, tpl.ID) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, refs) } // GetTemplates returns all templates for a project in a sort order func GetTemplates(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) user := helpers.UserFromContext(r) filter := db.TemplateFilter{} if r.URL.Query().Get("app") != "" { app := db.TemplateApp(r.URL.Query().Get("app")) filter.App = &app } templates, err := helpers.Store(r).GetTemplatesWithPermissions(project.ID, user.ID, filter, helpers.QueryParams(r.URL)) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, templates) } // AddTemplate adds a template to the database func AddTemplate(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) var template db.Template if !helpers.Bind(w, r, &template) { return } var err error template.ProjectID = project.ID newTemplate, err := helpers.Store(r).CreateTemplate(template) if err != nil { helpers.WriteError(w, err) return } if _, ok := util.Config.Apps[string(newTemplate.App)]; !ok { helpers.WriteErrorStatus(w, "Invalid app id: "+string(newTemplate.App), http.StatusBadRequest) return } // Check workspace and create it if required. if newTemplate.App.IsTerraform() { var inv db.Inventory if newTemplate.InventoryID == nil { var inventoryType db.InventoryType if invTypes := newTemplate.App.InventoryTypes(); len(invTypes) > 0 { inventoryType = invTypes[0] } else { helpers.WriteErrorStatus(w, "Inventory type is not supported for this template", http.StatusBadRequest) return } inv, err = helpers.Store(r).CreateInventory(db.Inventory{ Name: "default", ProjectID: project.ID, TemplateID: &newTemplate.ID, Type: inventoryType, 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.TemplateID = &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, ObjectType: db.EventSchedule, ObjectID: newTemplate.ID, Description: fmt.Sprintf("Template ID %d created", newTemplate.ID), }) helpers.WriteJSON(w, http.StatusCreated, newTemplate) } func UpdateTemplateDescription(w http.ResponseWriter, r *http.Request) { template := helpers.GetFromContext(r, "template").(db.Template) var tpl struct { Description string `json:"description"` } if !helpers.Bind(w, r, &tpl) { return } err := helpers.Store(r).SetTemplateDescription(template.ProjectID, template.ID, tpl.Description) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogUpdate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: template.ProjectID, ObjectType: db.EventTemplate, ObjectID: template.ID, Description: fmt.Sprintf("Template ID %d description updated", template.ID), }) w.WriteHeader(http.StatusNoContent) } // UpdateTemplate writes a template to an existing key in the database func UpdateTemplate(w http.ResponseWriter, r *http.Request) { oldTemplate := helpers.GetFromContext(r, "template").(db.Template) var template db.Template if !helpers.Bind(w, r, &template) { return } if _, ok := util.Config.Apps[string(template.App)]; !ok { helpers.WriteErrorStatus(w, "Invalid app id: "+string(template.App), http.StatusBadRequest) return } // project ID and template ID in the body and the path must be the same if template.ID != oldTemplate.ID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "template id in URL and in body must be the same", }) return } if template.ProjectID != oldTemplate.ProjectID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "You can not move template to other project", }) return } if template.Arguments != nil && *template.Arguments == "" { template.Arguments = nil } if template.Type != db.TemplateDeploy { template.BuildTemplateID = nil } if template.Type != db.TemplateBuild { template.StartVersion = nil } err := helpers.Store(r).UpdateTemplate(template) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogUpdate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: oldTemplate.ProjectID, ObjectType: db.EventTemplate, ObjectID: oldTemplate.ID, Description: fmt.Sprintf("Template ID %d updated", template.ID), }) w.WriteHeader(http.StatusNoContent) } // RemoveTemplate deletes a template from the database func RemoveTemplate(w http.ResponseWriter, r *http.Request) { tpl := helpers.GetFromContext(r, "template").(db.Template) err := helpers.Store(r).DeleteTemplate(tpl.ProjectID, tpl.ID) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogDelete, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: tpl.ProjectID, ObjectType: db.EventTemplate, ObjectID: tpl.ID, Description: fmt.Sprintf("Template ID %d deleted", tpl.ID), }) w.WriteHeader(http.StatusNoContent) } func SetTemplateInventory(w http.ResponseWriter, r *http.Request) { tpl := helpers.GetFromContext(r, "template").(db.Template) inv := helpers.GetFromContext(r, "inventory").(db.Inventory) if !tpl.App.HasInventoryType(inv.Type) { helpers.WriteErrorStatus(w, "Inventory type is not supported for this template", http.StatusBadRequest) return } if tpl.App.IsTerraform() && (inv.TemplateID == nil || *inv.TemplateID != tpl.ID) { helpers.WriteErrorStatus(w, "Inventory is not attached to this template", http.StatusBadRequest) return } tpl.InventoryID = &inv.ID err := helpers.Store(r).UpdateTemplate(tpl) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } func AttachInventory(w http.ResponseWriter, r *http.Request) { tpl := helpers.GetFromContext(r, "template").(db.Template) inv := helpers.GetFromContext(r, "inventory").(db.Inventory) if inv.TemplateID != nil { helpers.WriteErrorStatus(w, "Inventory is already attached to another template", http.StatusBadRequest) return } if !tpl.App.HasInventoryType(inv.Type) { helpers.WriteErrorStatus(w, "Inventory type is not supported for this template", http.StatusBadRequest) return } inv.TemplateID = &tpl.ID err := helpers.Store(r).UpdateInventory(inv) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } func DetachInventory(w http.ResponseWriter, r *http.Request) { tpl := helpers.GetFromContext(r, "template").(db.Template) inv := helpers.GetFromContext(r, "inventory").(db.Inventory) if inv.TemplateID == nil || *inv.TemplateID != tpl.ID { helpers.WriteErrorStatus(w, "Inventory is not attached to this template", http.StatusBadRequest) return } inv.TemplateID = nil err := helpers.Store(r).UpdateInventory(inv) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } func (c *TemplateController) GetTemplatePerms(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) tpl := helpers.GetFromContext(r, "template").(db.Template) perms, err := helpers.Store(r).GetTemplateRoles(project.ID, tpl.ID) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, perms) } func (c *TemplateController) AddTemplatePerm(w http.ResponseWriter, r *http.Request) { template := helpers.GetFromContext(r, "template").(db.Template) var perm db.TemplateRolePerm if !helpers.Bind(w, r, &perm) { return } perm.ProjectID = template.ProjectID perm.TemplateID = template.ID newPerm, err := c.templateRepo.CreateTemplateRole(perm) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusCreated, newPerm) } func (c *TemplateController) UpdateTemplatePerm(w http.ResponseWriter, r *http.Request) { template := helpers.GetFromContext(r, "template").(db.Template) permID, err := helpers.GetIntParam("perm_id", w, r) if err != nil { return } var perm db.TemplateRolePerm if !helpers.Bind(w, r, &perm) { return } perm.ID = permID perm.ProjectID = template.ProjectID perm.TemplateID = template.ID err = c.templateRepo.UpdateTemplateRole(perm) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } func (c *TemplateController) DeleteTemplatePerm(w http.ResponseWriter, r *http.Request) { template := helpers.GetFromContext(r, "template").(db.Template) permID, err := helpers.GetIntParam("perm_id", w, r) if err != nil { return } err = c.templateRepo.DeleteTemplateRole(template.ProjectID, template.ID, permID) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } func (c *TemplateController) GetTemplatePerm(w http.ResponseWriter, r *http.Request) { template := helpers.GetFromContext(r, "template").(db.Template) permID, err := helpers.GetIntParam("perm_id", w, r) if err != nil { helpers.WriteError(w, err) return } perm, err := c.templateRepo.GetTemplateRole(template.ProjectID, template.ID, permID) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, perm) } ================================================ FILE: api/projects/users.go ================================================ package projects import ( "fmt" "net/http" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" ) // UserMiddleware ensures a user exists and loads it to the context func UserMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) userID, err := helpers.GetIntParam("user_id", w, r) if err != nil { return } _, err = helpers.Store(r).GetProjectUser(project.ID, userID) if err != nil { helpers.WriteError(w, err) return } user, err := helpers.Store(r).GetUser(userID) if err != nil { helpers.WriteError(w, err) return } r = helpers.SetContextValue(r, "projectUser", user) next.ServeHTTP(w, r) }) } type projUser struct { ID int `json:"id"` Username string `json:"username"` Name string `json:"name"` Role db.ProjectUserRole `json:"role"` } // GetUsers returns all users in a project func GetUsers(w http.ResponseWriter, r *http.Request) { // get single user if user ID specified in the request if user := helpers.GetFromContext(r, "projectUser"); user != nil { helpers.WriteJSON(w, http.StatusOK, user.(db.User)) return } project := helpers.GetFromContext(r, "project").(db.Project) users, err := helpers.Store(r).GetProjectUsers(project.ID, helpers.QueryParams(r.URL)) if err != nil { helpers.WriteError(w, err) return } var result = make([]projUser, 0) for _, user := range users { result = append(result, projUser{ ID: user.ID, Name: user.Name, Username: user.Username, Role: user.Role, }) } helpers.WriteJSON(w, http.StatusOK, result) } // AddUser adds a user to a projects team in the database func AddUser(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) var projectUser struct { UserID int `json:"user_id" binding:"required"` Role db.ProjectUserRole `json:"role"` } if !helpers.Bind(w, r, &projectUser) { return } if !projectUser.Role.IsValid() { _, err := helpers.Store(r).GetProjectOrGlobalRoleBySlug(project.ID, string(projectUser.Role)) if err != nil { w.WriteHeader(http.StatusBadRequest) return } } _, err := helpers.Store(r).CreateProjectUser(db.ProjectUser{ ProjectID: project.ID, UserID: projectUser.UserID, Role: projectUser.Role, }) if err != nil { w.WriteHeader(http.StatusConflict) return } helpers.EventLog(r, helpers.EventLogCreate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: project.ID, ObjectType: db.EventUser, ObjectID: projectUser.UserID, Description: fmt.Sprintf("User ID %d added to team", projectUser.UserID), }) w.WriteHeader(http.StatusNoContent) } // removeUser removes a user from a project team func removeUser(targetUser db.User, w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) me := helpers.GetFromContext(r, "user").(*db.User) // logged in user myRole := helpers.GetFromContext(r, "projectUserRole").(db.ProjectUserRole) if !me.Admin && targetUser.ID == me.ID && myRole == db.ProjectOwner { helpers.WriteError(w, fmt.Errorf("owner can not left the project")) return } err := helpers.Store(r).DeleteProjectUser(project.ID, targetUser.ID) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogDelete, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: project.ID, ObjectType: db.EventUser, ObjectID: targetUser.ID, Description: fmt.Sprintf("User ID %d removed from team", targetUser.ID), }) w.WriteHeader(http.StatusNoContent) } // LeftProject removes a user from a project team func LeftProject(w http.ResponseWriter, r *http.Request) { me := helpers.GetFromContext(r, "user").(*db.User) // logged in user removeUser(*me, w, r) } // RemoveUser removes a user from a project team func RemoveUser(w http.ResponseWriter, r *http.Request) { targetUser := helpers.GetFromContext(r, "projectUser").(db.User) // target user removeUser(targetUser, w, r) } func UpdateUser(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) me := helpers.GetFromContext(r, "user").(*db.User) // logged in user targetUser := helpers.GetFromContext(r, "projectUser").(db.User) targetUserRole := helpers.GetFromContext(r, "projectUserRole").(db.ProjectUserRole) if !me.Admin && targetUser.ID == me.ID && targetUserRole == db.ProjectOwner { helpers.WriteError(w, fmt.Errorf("owner can not change his role in the project")) return } var projectUser struct { Role db.ProjectUserRole `json:"role"` } if !helpers.Bind(w, r, &projectUser) { return } if !projectUser.Role.IsValid() { _, err := helpers.Store(r).GetProjectOrGlobalRoleBySlug(project.ID, string(projectUser.Role)) if err != nil { w.WriteHeader(http.StatusBadRequest) return } } err := helpers.Store(r).UpdateProjectUser(db.ProjectUser{ UserID: targetUser.ID, ProjectID: project.ID, Role: projectUser.Role, }) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogUpdate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: project.ID, ObjectType: db.EventUser, ObjectID: targetUser.ID, Description: fmt.Sprintf("Changed role for User ID %d", targetUser.ID), }) w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/projects/views.go ================================================ package projects import ( "fmt" "net/http" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" ) // ViewMiddleware ensures a key exists and loads it to the context func ViewMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) viewID, err := helpers.GetIntParam("view_id", w, r) if err != nil { return } view, err := helpers.Store(r).GetView(project.ID, viewID) if err != nil { helpers.WriteError(w, err) return } r = helpers.SetContextValue(r, "view", view) next.ServeHTTP(w, r) }) } func GetViewTemplates(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) view := helpers.GetFromContext(r, "view").(db.View) user := helpers.UserFromContext(r) templates, err := helpers.Store(r).GetTemplatesWithPermissions(project.ID, user.ID, db.TemplateFilter{ViewID: &view.ID}, helpers.QueryParams(r.URL)) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, templates) } // GetViews retrieves sorted keys from the database func GetViews(w http.ResponseWriter, r *http.Request) { if view := helpers.GetFromContext(r, "view"); view != nil { k := view.(db.View) helpers.WriteJSON(w, http.StatusOK, k) return } project := helpers.GetFromContext(r, "project").(db.Project) var views []db.View views, err := helpers.Store(r).GetViews(project.ID) if err != nil { helpers.WriteError(w, err) return } helpers.WriteJSON(w, http.StatusOK, views) } // AddView adds a new key to the database func AddView(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) var view db.View if !helpers.Bind(w, r, &view) { return } if view.ProjectID != project.ID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Project ID in body and URL must be the same", }) return } if err := view.Validate(); err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": err.Error(), }) return } newView, err := helpers.Store(r).CreateView(view) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogCreate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: newView.ProjectID, ObjectType: db.EventView, ObjectID: newView.ID, Description: fmt.Sprintf("View %s created", view.Title), }) helpers.WriteJSON(w, http.StatusCreated, newView) } func SetViewPositions(w http.ResponseWriter, r *http.Request) { var positions map[int]int project := helpers.GetFromContext(r, "project").(db.Project) if !helpers.Bind(w, r, &positions) { return } err := helpers.Store(r).SetViewPositions(project.ID, positions) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } // UpdateView updates key in database // nolint: gocyclo func UpdateView(w http.ResponseWriter, r *http.Request) { var view db.View oldView := helpers.GetFromContext(r, "view").(db.View) if !helpers.Bind(w, r, &view) { return } if view.ID != oldView.ID { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "View ID in URL and in body must be the same", }) return } if err := view.Validate(); err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": err.Error(), }) return } if err := helpers.Store(r).UpdateView(view); err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogUpdate, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: oldView.ProjectID, ObjectType: db.EventView, ObjectID: oldView.ID, Description: fmt.Sprintf("View %s updated", view.Title), }) w.WriteHeader(http.StatusNoContent) } // RemoveView deletes a view from the database func RemoveView(w http.ResponseWriter, r *http.Request) { view := helpers.GetFromContext(r, "view").(db.View) err := helpers.Store(r).DeleteView(view.ProjectID, view.ID) if err != nil { helpers.WriteError(w, err) return } helpers.EventLog(r, helpers.EventLogDelete, helpers.EventLogItem{ UserID: helpers.UserFromContext(r).ID, ProjectID: view.ProjectID, ObjectType: db.EventView, ObjectID: view.ID, Description: fmt.Sprintf("View %s deleted", view.Title), }) w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/router.go ================================================ package api import ( "bytes" "embed" "fmt" "net/http" "os" "path" "strings" "time" "github.com/semaphoreui/semaphore/pro_interfaces" proApi "github.com/semaphoreui/semaphore/pro/api" proProjects "github.com/semaphoreui/semaphore/pro/api/projects" "github.com/semaphoreui/semaphore/services/server" taskServices "github.com/semaphoreui/semaphore/services/tasks" "github.com/semaphoreui/semaphore/api/debug" "github.com/semaphoreui/semaphore/api/tasks" "github.com/semaphoreui/semaphore/pkg/tz" log "github.com/sirupsen/logrus" "github.com/semaphoreui/semaphore/api/runners" "github.com/gorilla/mux" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/api/projects" "github.com/semaphoreui/semaphore/api/sockets" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/util" ) var startTime = tz.Now() //go:embed public/* var publicAssets embed.FS // StoreMiddleware WTF? func StoreMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { store := helpers.Store(r) //var url = r.URL.String() db.StoreSession(store, util.RandString(12), func() { next.ServeHTTP(w, r) }) }) } // JSONMiddleware ensures that all the routes respond with Json, this is added by default to all routes func JSONMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "application/json") next.ServeHTTP(w, r) }) } // plainTextMiddleware resets headers to Plain Text if needed func plainTextMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("content-type", "text/plain; charset=utf-8") next.ServeHTTP(w, r) }) } func pongHandler(w http.ResponseWriter, r *http.Request) { //nolint: errcheck w.Write([]byte("pong")) } // DelayMiddleware adds artificial delay to simulate slow network conditions func DelayMiddleware(delay time.Duration) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(delay) next.ServeHTTP(w, r) }) } } // Route declares all routes func Route( store db.Store, terraformStore db.TerraformStore, ansibleTaskRepo db.AnsibleTaskRepository, taskPool *taskServices.TaskPool, projectService server.ProjectService, integrationService server.IntegrationService, encryptionService server.AccessKeyEncryptionService, accessKeyInstallationService server.AccessKeyInstallationService, secretStorageService server.SecretStorageService, accessKeyService server.AccessKeyService, environmentService server.EnvironmentService, subscriptionService pro_interfaces.SubscriptionService, ) *mux.Router { projectController := &projects.ProjectController{ProjectService: projectService} runnerController := runners.NewRunnerController(store, taskPool, encryptionService) integrationController := NewIntegrationController(integrationService) environmentController := projects.NewEnvironmentController(store, encryptionService, accessKeyService, environmentService) secretStorageController := projects.NewSecretStorageController(store, secretStorageService) repositoryController := projects.NewRepositoryController(accessKeyInstallationService) keyController := projects.NewKeyController(accessKeyService) projectsController := projects.NewProjectsController(accessKeyService) terraformController := proApi.NewTerraformController(encryptionService, terraformStore, store) terraformInventoryController := proProjects.NewTerraformInventoryController(terraformStore) userController := NewUserController(subscriptionService) usersController := NewUsersController(subscriptionService) subscriptionController := proApi.NewSubscriptionController(store, store, store, terraformStore) projectRunnerController := proProjects.NewProjectRunnerController(subscriptionService) taskController := projects.NewTaskController(ansibleTaskRepo) rolesController := proApi.NewRolesController(store) templateController := projects.NewTemplateController(store, store) systemInfoController := NewSystemInfoController(subscriptionService) r := mux.NewRouter() r.NotFoundHandler = http.HandlerFunc(servePublic) if util.Config.Debugging.ApiDelay != "" { delay, err := time.ParseDuration(util.Config.Debugging.ApiDelay) if err != nil { log.WithError(err).WithFields(log.Fields{ "context": "debugging", }).Panic("Invalid API delay format") } r.Use(DelayMiddleware(delay)) } webPath := "/" if util.WebHostURL != nil { webPath = util.WebHostURL.Path if !strings.HasSuffix(webPath, "/") { webPath += "/" } } r.Use(mux.CORSMethodMiddleware(r)) pingRouter := r.Path(webPath + "api/ping").Subrouter() pingRouter.Use(plainTextMiddleware) pingRouter.Methods("GET", "HEAD").HandlerFunc(pongHandler) publicAPIRouter := r.PathPrefix(webPath + "api").Subrouter() publicAPIRouter.Use(StoreMiddleware, JSONMiddleware) publicAPIRouter.HandleFunc("/auth/login", login).Methods("GET", "POST") publicAPIRouter.HandleFunc("/auth/verify", verifySession).Methods("POST") publicAPIRouter.HandleFunc("/auth/recovery", recoverySession).Methods("POST") publicAPIRouter.HandleFunc("/auth/logout", logout).Methods("POST") publicAPIRouter.HandleFunc("/auth/oidc/{provider}/login", oidcLogin).Methods("GET") publicAPIRouter.HandleFunc("/auth/oidc/{provider}/redirect", oidcRedirect).Methods("GET") publicAPIRouter.HandleFunc("/auth/oidc/{provider}/redirect/{redirect_path:.*}", oidcRedirect).Methods("GET") internalAPI := publicAPIRouter.PathPrefix("/internal").Subrouter() internalAPI.HandleFunc("/runners", runners.RegisterRunner).Methods("POST") runnersAPI := internalAPI.PathPrefix("/runners").Subrouter() runnersAPI.Use(runners.RunnerMiddleware) runnersAPI.Path("").HandlerFunc(runnerController.GetRunner).Methods("GET", "HEAD") runnersAPI.Path("").HandlerFunc(runnerController.UpdateRunner).Methods("PUT") runnersAPI.Path("").HandlerFunc(runners.UnregisterRunner).Methods("DELETE") publicWebHookRouter := r.PathPrefix(webPath + "api").Subrouter() publicWebHookRouter.Use(StoreMiddleware, JSONMiddleware) publicWebHookRouter.Path("/integrations/{integration_alias}").HandlerFunc( integrationController.ReceiveIntegration).Methods("POST", "GET", "OPTIONS") terraformWebhookRouter := publicWebHookRouter.PathPrefix("/terraform").Subrouter() terraformWebhookRouter.Use(terraformController.TerraformInventoryAliasMiddleware) terraformWebhookRouter.Path("/{alias}").HandlerFunc(terraformController.GetTerraformState).Methods("GET") terraformWebhookRouter.Path("/{alias}").HandlerFunc(terraformController.AddTerraformState).Methods("POST") terraformWebhookRouter.Path("/{alias}").HandlerFunc(terraformController.LockTerraformState).Methods("LOCK") terraformWebhookRouter.Path("/{alias}").HandlerFunc(terraformController.UnlockTerraformState).Methods("UNLOCK") authenticatedWS := r.PathPrefix(webPath + "api").Subrouter() authenticatedWS.Use(JSONMiddleware, authenticationWithStore) authenticatedWS.Path("/ws").HandlerFunc(sockets.Handler).Methods("GET", "HEAD") authenticatedAPI := r.PathPrefix(webPath + "api").Subrouter() authenticatedAPI.Use(StoreMiddleware, JSONMiddleware, authentication) authenticatedAPI.Path("/info").HandlerFunc(systemInfoController.GetSystemInfo).Methods("GET", "HEAD") authenticatedAPI.Path("/subscription").HandlerFunc(subscriptionController.Activate).Methods("POST") authenticatedAPI.Path("/subscription/refresh").HandlerFunc(subscriptionController.Refresh).Methods("POST") authenticatedAPI.Path("/subscription").HandlerFunc(subscriptionController.GetSubscription).Methods("GET") authenticatedAPI.Path("/subscription").HandlerFunc(subscriptionController.Delete).Methods("DELETE") authenticatedAPI.Path("/projects").HandlerFunc(projects.GetProjects).Methods("GET", "HEAD") authenticatedAPI.Path("/projects").HandlerFunc(projectsController.AddProject).Methods("POST") authenticatedAPI.Path("/projects/restore").HandlerFunc(projects.Restore).Methods("POST") authenticatedAPI.Path("/events").HandlerFunc(getAllEvents).Methods("GET", "HEAD") authenticatedAPI.HandleFunc("/events/last", getLastEvents).Methods("GET", "HEAD") authenticatedAPI.Path("/users").HandlerFunc(usersController.GetUsers).Methods("GET", "HEAD") authenticatedAPI.Path("/users").HandlerFunc(usersController.AddUser).Methods("POST") authenticatedAPI.Path("/user").HandlerFunc(userController.GetUser).Methods("GET", "HEAD") authenticatedAPI.Path("/apps").HandlerFunc(getApps).Methods("GET", "HEAD") tokenAPI := authenticatedAPI.PathPrefix("/user").Subrouter() tokenAPI.Path("/tokens").HandlerFunc(getAPITokens).Methods("GET", "HEAD") tokenAPI.Path("/tokens").HandlerFunc(createAPIToken).Methods("POST") tokenAPI.HandleFunc("/tokens/{token_id}", deleteAPIToken).Methods("DELETE") adminAPI := authenticatedAPI.NewRoute().Subrouter() adminAPI.Use(adminMiddleware) adminAPI.Path("/options").HandlerFunc(getOptions).Methods("GET", "HEAD") adminAPI.Path("/options").HandlerFunc(setOption).Methods("POST") adminAPI.Path("/runners").HandlerFunc(getAllRunners).Methods("GET", "HEAD") adminAPI.Path("/runners").HandlerFunc(addGlobalRunner).Methods("POST", "HEAD") adminAPI.Path("/roles").HandlerFunc(rolesController.GetRoles).Methods("GET", "HEAD") adminAPI.Path("/roles").HandlerFunc(rolesController.AddRole).Methods("POST", "HEAD") adminAPI.Path("/cache").HandlerFunc(clearCache).Methods("DELETE", "HEAD") debugAPI := adminAPI.PathPrefix("/debug").Subrouter() debugAPI.Path("/gc").HandlerFunc(debug.GC).Methods("POST") debugAPI.Path("/pprof/dump").HandlerFunc(debug.Dump).Methods("POST") globalRunnersAPI := adminAPI.PathPrefix("/runners").Subrouter() globalRunnersAPI.Use(globalRunnerMiddleware) globalRunnersAPI.Path("/{runner_id}").HandlerFunc(getGlobalRunner).Methods("GET", "HEAD") globalRunnersAPI.Path("/{runner_id}").HandlerFunc(updateGlobalRunner).Methods("PUT", "POST") globalRunnersAPI.Path("/{runner_id}/active").HandlerFunc(setGlobalRunnerActive).Methods("POST") globalRunnersAPI.Path("/{runner_id}").HandlerFunc(deleteGlobalRunner).Methods("DELETE") globalRunnersAPI.Path("/{runner_id}/cache").HandlerFunc(clearGlobalRunnerCache).Methods("DELETE") rolesAPI := adminAPI.PathPrefix("/roles").Subrouter() rolesAPI.Path("/{role_slug}").HandlerFunc(rolesController.GetGlobalRole).Methods("GET", "HEAD") rolesAPI.Path("/{role_slug}").HandlerFunc(rolesController.UpdateRole).Methods("PUT", "POST") rolesAPI.Path("/{role_slug}").HandlerFunc(rolesController.DeleteRole).Methods("DELETE") appsAPI := adminAPI.PathPrefix("/apps").Subrouter() appsAPI.Use(appMiddleware) appsAPI.Path("/{app_id}").HandlerFunc(getApp).Methods("GET", "HEAD") appsAPI.Path("/{app_id}").HandlerFunc(setApp).Methods("PUT", "POST") appsAPI.Path("/{app_id}/active").HandlerFunc(setAppActive).Methods("POST") appsAPI.Path("/{app_id}").HandlerFunc(deleteApp).Methods("DELETE") adminAPI.Path("/tasks").HandlerFunc(tasks.GetTasks).Methods("GET", "HEAD") tasksAPI := adminAPI.PathPrefix("/tasks").Subrouter() tasksAPI.Use(tasks.TaskMiddleware) tasksAPI.Path("/{task_id}").HandlerFunc(tasks.GetTasks).Methods("GET", "HEAD") tasksAPI.Path("/{task_id}").HandlerFunc(tasks.DeleteTask).Methods("DELETE") userUserAPI := authenticatedAPI.Path("/users/{user_id}").Subrouter() userUserAPI.Use(readonlyUserMiddleware) userUserAPI.Methods("GET", "HEAD").HandlerFunc(userController.GetUser) userAPI := authenticatedAPI.Path("/users/{user_id}").Subrouter() userAPI.Use(getUserMiddleware) userAPI.Methods("PUT").HandlerFunc(usersController.UpdateUser) userAPI.Methods("DELETE").HandlerFunc(deleteUser) userPasswordAPI := authenticatedAPI.PathPrefix("/users/{user_id}").Subrouter() userPasswordAPI.Use(getUserMiddleware) userPasswordAPI.Path("/password").HandlerFunc(updateUserPassword).Methods("POST") userPasswordAPI.Path("/2fas/totp").HandlerFunc(enableTotp).Methods("POST") userPasswordAPI.Path("/2fas/totp/{totp_id}/qr").HandlerFunc(totpQr).Methods("GET") userPasswordAPI.Path("/2fas/totp/{totp_id}").HandlerFunc(disableTotp).Methods("DELETE") projectGet := authenticatedAPI.Path("/project/{project_id}").Subrouter() projectGet.Use(projects.ProjectMiddleware) projectGet.Methods("GET", "HEAD").HandlerFunc(projects.GetProject) // // Start and Stop tasks projectTaskStart := authenticatedAPI.PathPrefix("/project/{project_id}").Subrouter() projectTaskStart.Use(projects.ProjectMiddleware, projects.NewTaskMiddleware, projects.GetTaskPermissionsMiddleware, projects.GetMustCanMiddleware(db.CanRunProjectTasks)) projectTaskStart.Path("/tasks").HandlerFunc(projects.AddTask).Methods("POST") projectTaskStop := authenticatedAPI.PathPrefix("/project/{project_id}").Subrouter() projectTaskStop.Use(projects.ProjectMiddleware, projects.GetTaskMiddleware, projects.GetTaskPermissionsMiddleware, projects.GetMustCanMiddleware(db.CanRunProjectTasks)) projectTaskStop.HandleFunc("/tasks/{task_id}/stop", projects.StopTask).Methods("POST") projectTaskStop.HandleFunc("/tasks/{task_id}/confirm", projects.ConfirmTask).Methods("POST") projectTaskStop.HandleFunc("/tasks/{task_id}/reject", projects.RejectTask).Methods("POST") // // Project resources CRUD projectUserAPI := authenticatedAPI.PathPrefix("/project/{project_id}").Subrouter() projectUserAPI.Use(projects.ProjectMiddleware, projects.GetMustCanMiddleware(db.CanManageProjectResources)) projectUserAPI.Path("/role").HandlerFunc(projects.GetUserRole).Methods("GET", "HEAD") projectUserAPI.Path("/events").HandlerFunc(getAllEvents).Methods("GET", "HEAD") projectUserAPI.HandleFunc("/events/last", getLastEvents).Methods("GET", "HEAD") projectUserAPI.Path("/users").HandlerFunc(projects.GetUsers).Methods("GET", "HEAD") projectUserAPI.Path("/keys").HandlerFunc(projects.GetKeys).Methods("GET", "HEAD") projectUserAPI.Path("/keys").HandlerFunc(keyController.AddKey).Methods("POST") projectUserAPI.Path("/secret_storages").HandlerFunc(secretStorageController.GetSecretStorages).Methods("GET", "HEAD") projectUserAPI.Path("/secret_storages").HandlerFunc(secretStorageController.Add).Methods("POST") projectUserAPI.Path("/repositories").HandlerFunc(projects.GetRepositories).Methods("GET", "HEAD") projectUserAPI.Path("/repositories").HandlerFunc(projects.AddRepository).Methods("POST") projectUserAPI.Path("/inventory").HandlerFunc(projects.GetInventory).Methods("GET", "HEAD") projectUserAPI.Path("/inventory").HandlerFunc(projects.AddInventory).Methods("POST") projectUserAPI.Path("/environment").HandlerFunc(projects.GetEnvironment).Methods("GET", "HEAD") projectUserAPI.Path("/environment").HandlerFunc(environmentController.AddEnvironment).Methods("POST") projectUserAPI.Path("/tasks").HandlerFunc(projects.GetAllTasks).Methods("GET", "HEAD") projectUserAPI.HandleFunc("/tasks/last", projects.GetLastTasks).Methods("GET", "HEAD") projectUserAPI.Path("/stats").HandlerFunc(projects.GetTaskStats).Methods("GET", "HEAD") projectUserAPI.Path("/templates").HandlerFunc(projects.GetTemplates).Methods("GET", "HEAD") projectUserAPI.Path("/templates").HandlerFunc(projects.AddTemplate).Methods("POST") projectUserAPI.Path("/schedules").HandlerFunc(projects.GetProjectSchedules).Methods("GET", "HEAD") projectUserAPI.Path("/schedules").HandlerFunc(projects.AddSchedule).Methods("POST") projectUserAPI.Path("/schedules/validate").HandlerFunc(projects.ValidateScheduleCronFormat).Methods("POST") projectUserAPI.Path("/views").HandlerFunc(projects.GetViews).Methods("GET", "HEAD") projectUserAPI.Path("/views").HandlerFunc(projects.AddView).Methods("POST") projectUserAPI.Path("/views/positions").HandlerFunc(projects.SetViewPositions).Methods("POST") projectUserAPI.Path("/integrations").HandlerFunc(projects.GetIntegrations).Methods("GET", "HEAD") projectUserAPI.Path("/integrations").HandlerFunc(projects.AddIntegration).Methods("POST") projectUserAPI.Path("/backup").HandlerFunc(projects.GetBackup).Methods("GET", "HEAD") projectUserAPI.Path("/notifications/test").HandlerFunc(projectController.SendTestNotification).Methods("POST") projectUserAPI.Path("/runners").HandlerFunc(projectRunnerController.GetRunners).Methods("GET", "HEAD") projectUserAPI.Path("/runners").HandlerFunc(projectRunnerController.AddRunner).Methods("POST") projectUserAPI.Path("/runner_tags").HandlerFunc(projectRunnerController.GetRunnerTags).Methods("GET", "HEAD") projectRunnersAPI := projectUserAPI.PathPrefix("/runners").Subrouter() projectRunnersAPI.Use(projectRunnerController.RunnerMiddleware) projectRunnersAPI.Path("/{runner_id}").HandlerFunc(projectRunnerController.GetRunner).Methods("GET", "HEAD") projectRunnersAPI.Path("/{runner_id}").HandlerFunc(projectRunnerController.UpdateRunner).Methods("PUT", "POST") projectRunnersAPI.Path("/{runner_id}/active").HandlerFunc(projectRunnerController.SetRunnerActive).Methods("POST") projectRunnersAPI.Path("/{runner_id}").HandlerFunc(projectRunnerController.DeleteRunner).Methods("DELETE") projectRunnersAPI.Path("/{runner_id}/cache").HandlerFunc(projectRunnerController.ClearRunnerCache).Methods("DELETE") projectUserAPI.Path("/roles").HandlerFunc(rolesController.GetProjectRoles).Methods("GET", "HEAD") projectUserAPI.Path("/roles/all").HandlerFunc(rolesController.GetProjectAndGlobalRoles).Methods("GET", "HEAD") projectUserAPI.Path("/roles").HandlerFunc(rolesController.AddProjectRole).Methods("POST") projectRolesAPI := projectUserAPI.PathPrefix("/roles").Subrouter() projectRolesAPI.Path("/{role_slug}").HandlerFunc(rolesController.GetProjectRole).Methods("GET", "HEAD") projectRolesAPI.Path("/{role_slug}").HandlerFunc(rolesController.UpdateProjectRole).Methods("PUT", "POST") projectRolesAPI.Path("/{role_slug}").HandlerFunc(rolesController.DeleteProjectRole).Methods("DELETE") // // Updating and deleting project projectAdminAPI := authenticatedAPI.Path("/project/{project_id}").Subrouter() projectAdminAPI.Use(projects.ProjectMiddleware, projects.GetMustCanMiddleware(db.CanUpdateProject)) projectAdminAPI.Methods("PUT").HandlerFunc(projectController.UpdateProject) projectAdminAPI.Methods("DELETE").HandlerFunc(projectController.DeleteProject) meAPI := authenticatedAPI.Path("/project/{project_id}/me").Subrouter() meAPI.Use(projects.ProjectMiddleware) meAPI.HandleFunc("", projects.LeftProject).Methods("DELETE") cacheAPI := authenticatedAPI.Path("/project/{project_id}/cache").Subrouter() cacheAPI.Use(projects.ProjectMiddleware) cacheAPI.HandleFunc("", projects.ClearCache).Methods("DELETE") // // Manage project users projectAdminUsersAPI := authenticatedAPI.PathPrefix("/project/{project_id}").Subrouter() projectAdminUsersAPI.Use(projects.ProjectMiddleware, projects.GetMustCanMiddleware(db.CanManageProjectUsers)) projectAdminUsersAPI.Path("/users").HandlerFunc(projects.AddUser).Methods("POST") projectUserManagement := projectAdminUsersAPI.PathPrefix("/users").Subrouter() projectUserManagement.Use(projects.UserMiddleware) projectUserManagement.HandleFunc("/{user_id}", projects.GetUsers).Methods("GET", "HEAD") projectUserManagement.HandleFunc("/{user_id}", projects.UpdateUser).Methods("PUT") projectUserManagement.HandleFunc("/{user_id}", projects.RemoveUser).Methods("DELETE") // // Project resources CRUD (continue) projectKeyManagement := projectUserAPI.PathPrefix("/keys").Subrouter() projectKeyManagement.Use(projects.KeyMiddleware) projectKeyManagement.HandleFunc("/{key_id}", projects.GetKeys).Methods("GET", "HEAD") projectKeyManagement.HandleFunc("/{key_id}/refs", projects.GetKeyRefs).Methods("GET", "HEAD") projectKeyManagement.HandleFunc("/{key_id}", keyController.UpdateKey).Methods("PUT") projectKeyManagement.HandleFunc("/{key_id}", keyController.RemoveKey).Methods("DELETE") projectSecretStorageManagement := projectUserAPI.PathPrefix("/secret_storages").Subrouter() projectSecretStorageManagement.Use(projects.SecretStorageMiddleware) projectSecretStorageManagement.HandleFunc("/{storage_id}", secretStorageController.GetSecretStorage).Methods("GET", "HEAD") projectSecretStorageManagement.HandleFunc("/{storage_id}/refs", secretStorageController.GetRefs).Methods("GET", "HEAD") projectSecretStorageManagement.HandleFunc("/{storage_id}", secretStorageController.Update).Methods("PUT") projectSecretStorageManagement.HandleFunc("/{storage_id}", secretStorageController.Remove).Methods("DELETE") projectSecretStorageManagement.HandleFunc("/{storage_id}/sync", secretStorageController.SyncSecrets).Methods("POST") projectRepoManagement := projectUserAPI.PathPrefix("/repositories").Subrouter() projectRepoManagement.Use(projects.RepositoryMiddleware) projectRepoManagement.HandleFunc("/{repository_id}", projects.GetRepositories).Methods("GET", "HEAD") projectRepoManagement.HandleFunc("/{repository_id}/refs", projects.GetRepositoryRefs).Methods("GET", "HEAD") projectRepoManagement.HandleFunc("/{repository_id}", projects.UpdateRepository).Methods("PUT") projectRepoManagement.HandleFunc("/{repository_id}", projects.RemoveRepository).Methods("DELETE") projectRepoManagement.HandleFunc("/{repository_id}/branches", repositoryController.GetRepositoryBranches).Methods("GET", "HEAD") projectInventoryManagement := projectUserAPI.PathPrefix("/inventory").Subrouter() projectInventoryManagement.Use(projects.InventoryMiddleware) projectInventoryManagement.HandleFunc("/{inventory_id}", projects.GetInventory).Methods("GET", "HEAD") projectInventoryManagement.HandleFunc("/{inventory_id}/refs", projects.GetInventoryRefs).Methods("GET", "HEAD") projectInventoryManagement.HandleFunc("/{inventory_id}", projects.UpdateInventory).Methods("PUT") projectInventoryManagement.HandleFunc("/{inventory_id}", projects.RemoveInventory).Methods("DELETE") projectInventoryManagement.HandleFunc("/{inventory_id}/terraform/aliases", terraformInventoryController.GetTerraformInventoryAliases).Methods("GET", "HEAD") projectInventoryManagement.HandleFunc("/{inventory_id}/terraform/aliases", terraformInventoryController.AddTerraformInventoryAlias).Methods("POST") projectInventoryManagement.HandleFunc("/{inventory_id}/terraform/aliases/{alias_id}", terraformInventoryController.GetTerraformInventoryAlias).Methods("GET") projectInventoryManagement.HandleFunc("/{inventory_id}/terraform/aliases/{alias_id}", terraformInventoryController.DeleteTerraformInventoryAlias).Methods("DELETE") projectInventoryManagement.HandleFunc("/{inventory_id}/terraform/aliases/{alias_id}", terraformInventoryController.SetTerraformInventoryAliasAccessKey).Methods("PUT") projectInventoryManagement.HandleFunc("/{inventory_id}/terraform/states", terraformInventoryController.GetTerraformInventoryStates).Methods("GET", "HEAD") projectInventoryManagement.HandleFunc("/{inventory_id}/terraform/states/latest", terraformInventoryController.GetTerraformInventoryLatestState).Methods("GET", "HEAD") projectInventoryManagement.HandleFunc("/{inventory_id}/terraform/states/{state_id}", terraformInventoryController.GetTerraformInventoryState).Methods("GET") projectInventoryManagement.HandleFunc("/{inventory_id}/terraform/states/{state_id}", terraformInventoryController.DeleteTerraformInventoryState).Methods("DELETE") projectEnvManagement := projectUserAPI.PathPrefix("/environment").Subrouter() projectEnvManagement.Use(environmentController.EnvironmentMiddleware) projectEnvManagement.HandleFunc("/{environment_id}", projects.GetEnvironment).Methods("GET", "HEAD") projectEnvManagement.HandleFunc("/{environment_id}/refs", projects.GetEnvironmentRefs).Methods("GET", "HEAD") projectEnvManagement.HandleFunc("/{environment_id}", environmentController.UpdateEnvironment).Methods("PUT") projectEnvManagement.HandleFunc("/{environment_id}", environmentController.RemoveEnvironment).Methods("DELETE") projectTmplManagement := projectUserAPI.PathPrefix("/templates").Subrouter() projectTmplManagement.Use(projects.TemplatesMiddleware) projectTmplManagement.HandleFunc("/{template_id}", projects.UpdateTemplate).Methods("PUT") projectTmplManagement.HandleFunc("/{template_id}/description", projects.UpdateTemplateDescription).Methods("PUT") projectTmplManagement.HandleFunc("/{template_id}", projects.RemoveTemplate).Methods("DELETE") projectTmplManagement.HandleFunc("/{template_id}", projects.GetTemplate).Methods("GET") projectTmplManagement.HandleFunc("/{template_id}/refs", projects.GetTemplateRefs).Methods("GET", "HEAD") projectTmplManagement.HandleFunc("/{template_id}/tasks", projects.GetAllTasks).Methods("GET") projectTmplManagement.HandleFunc("/{template_id}/tasks/last", projects.GetLastTasks).Methods("GET") projectTmplManagement.HandleFunc("/{template_id}/schedules", projects.GetTemplateSchedules).Methods("GET") projectTmplManagement.HandleFunc("/{template_id}/stats", projects.GetTaskStats).Methods("GET") projectTmplManagement.HandleFunc("/{template_id}/stop_all_tasks", taskController.StopAllTasks).Methods("POST") projectTmplManagement.HandleFunc("/{template_id}/perms", templateController.GetTemplatePerms).Methods("GET") projectTmplManagement.HandleFunc("/{template_id}/perms", templateController.AddTemplatePerm).Methods("POST") projectTmplManagement.HandleFunc("/{template_id}/perms/{perm_id}", templateController.GetTemplatePerm).Methods("GET") projectTmplManagement.HandleFunc("/{template_id}/perms/{perm_id}", templateController.UpdateTemplatePerm).Methods("PUT") projectTmplManagement.HandleFunc("/{template_id}/perms/{perm_id}", templateController.DeleteTemplatePerm).Methods("DELETE") projectTmplInvManagement := projectTmplManagement.PathPrefix("/{template_id}/inventory").Subrouter() projectTmplInvManagement.Use(projects.InventoryMiddleware) projectTmplInvManagement.HandleFunc("/{inventory_id}/set_default", projects.SetTemplateInventory).Methods("POST") projectTmplInvManagement.HandleFunc("/{inventory_id}/attach", projects.AttachInventory).Methods("POST") projectTmplInvManagement.HandleFunc("/{inventory_id}/detach", projects.DetachInventory).Methods("POST") projectTaskManagement := projectUserAPI.PathPrefix("/tasks").Subrouter() projectTaskManagement.Use(projects.GetTaskMiddleware) projectTaskManagement.HandleFunc("/{task_id}/output", projects.GetTaskOutput).Methods("GET", "HEAD") projectTaskManagement.HandleFunc("/{task_id}/raw_output", projects.GetTaskRawOutput).Methods("GET", "HEAD") projectTaskManagement.HandleFunc("/{task_id}", projects.GetTask).Methods("GET", "HEAD") projectTaskManagement.HandleFunc("/{task_id}", projects.RemoveTask).Methods("DELETE") projectTaskManagement.HandleFunc("/{task_id}/stages", projects.GetTaskStages).Methods("GET", "HEAD") projectTaskManagement.HandleFunc("/{task_id}/ansible/hosts", taskController.GetAnsibleTaskHosts).Methods("GET", "HEAD") projectTaskManagement.HandleFunc("/{task_id}/ansible/errors", taskController.GetAnsibleTaskErrors).Methods("GET", "HEAD") projectScheduleManagement := projectUserAPI.PathPrefix("/schedules").Subrouter() projectScheduleManagement.Use(projects.SchedulesMiddleware) projectScheduleManagement.HandleFunc("/{schedule_id}", projects.GetSchedule).Methods("GET", "HEAD") projectScheduleManagement.HandleFunc("/{schedule_id}", projects.UpdateSchedule).Methods("PUT") projectScheduleManagement.HandleFunc("/{schedule_id}/active", projects.SetScheduleActive).Methods("PUT") projectScheduleManagement.HandleFunc("/{schedule_id}", projects.RemoveSchedule).Methods("DELETE") projectViewManagement := projectUserAPI.PathPrefix("/views").Subrouter() projectViewManagement.Use(projects.ViewMiddleware) projectViewManagement.HandleFunc("/{view_id}", projects.GetViews).Methods("GET", "HEAD") projectViewManagement.HandleFunc("/{view_id}", projects.UpdateView).Methods("PUT") projectViewManagement.HandleFunc("/{view_id}", projects.RemoveView).Methods("DELETE") projectViewManagement.HandleFunc("/{view_id}/templates", projects.GetViewTemplates).Methods("GET", "HEAD") projectIntegrationsAliasAPI := projectUserAPI.PathPrefix("/integrations").Subrouter() projectIntegrationsAliasAPI.Use(projects.ProjectMiddleware) projectIntegrationsAliasAPI.HandleFunc("/aliases", projects.GetIntegrationAlias).Methods("GET", "HEAD") projectIntegrationsAliasAPI.HandleFunc("/aliases", projects.AddIntegrationAlias).Methods("POST") projectIntegrationsAliasAPI.HandleFunc("/aliases/{alias_id}", projects.RemoveIntegrationAlias).Methods("DELETE") projectIntegrationsAPI := projectUserAPI.PathPrefix("/integrations").Subrouter() projectIntegrationsAPI.Use(projects.ProjectMiddleware, projects.IntegrationMiddleware) projectIntegrationsAPI.HandleFunc("/{integration_id}", projects.UpdateIntegration).Methods("PUT") projectIntegrationsAPI.HandleFunc("/{integration_id}", projects.DeleteIntegration).Methods("DELETE") projectIntegrationsAPI.HandleFunc("/{integration_id}", projects.GetIntegration).Methods("GET") projectIntegrationsAPI.HandleFunc("/{integration_id}/refs", projects.GetIntegrationRefs).Methods("GET", "HEAD") projectIntegrationsAPI.HandleFunc("/{integration_id}/matchers", projects.GetIntegrationMatchers).Methods("GET", "HEAD") projectIntegrationsAPI.HandleFunc("/{integration_id}/matchers", projects.AddIntegrationMatcher).Methods("POST") projectIntegrationsAPI.HandleFunc("/{integration_id}/values", projects.GetIntegrationExtractValues).Methods("GET", "HEAD") projectIntegrationsAPI.HandleFunc("/{integration_id}/values", projects.AddIntegrationExtractValue).Methods("POST") projectIntegrationsAPI.HandleFunc("/{integration_id}/aliases", projects.GetIntegrationAlias).Methods("GET", "HEAD") projectIntegrationsAPI.HandleFunc("/{integration_id}/aliases", projects.AddIntegrationAlias).Methods("POST") projectIntegrationsAPI.HandleFunc("/{integration_id}/aliases/{alias_id}", projects.RemoveIntegrationAlias).Methods("DELETE") projectIntegrationsAPI.HandleFunc("/{integration_id}/matchers/{matcher_id}", projects.GetIntegrationMatcher).Methods("GET", "HEAD") projectIntegrationsAPI.HandleFunc("/{integration_id}/matchers/{matcher_id}", projects.UpdateIntegrationMatcher).Methods("PUT") projectIntegrationsAPI.HandleFunc("/{integration_id}/matchers/{matcher_id}", projects.DeleteIntegrationMatcher).Methods("DELETE") projectIntegrationsAPI.HandleFunc("/{integration_id}/matchers/{matcher_id}/refs", projects.GetIntegrationMatcherRefs).Methods("GET", "HEAD") projectIntegrationsAPI.HandleFunc("/{integration_id}/values/{value_id}", projects.GetIntegrationExtractValue).Methods("GET", "HEAD") projectIntegrationsAPI.HandleFunc("/{integration_id}/values/{value_id}", projects.UpdateIntegrationExtractValue).Methods("PUT") projectIntegrationsAPI.HandleFunc("/{integration_id}/values/{value_id}", projects.DeleteIntegrationExtractValue).Methods("DELETE") projectIntegrationsAPI.HandleFunc("/{integration_id}/values/{value_id}/refs", projects.GetIntegrationExtractValueRefs).Methods("GET") if os.Getenv("DEBUG") == "1" { defer debugPrintRoutes(r) } return r } func debugPrintRoutes(r *mux.Router) { err := r.Walk(func(route *mux.Route, router *mux.Router, ancestors []*mux.Route) error { pathTemplate, err := route.GetPathTemplate() if err == nil { fmt.Println("ROUTE:", pathTemplate) } pathRegexp, err := route.GetPathRegexp() if err == nil { fmt.Println("Path regexp:", pathRegexp) } queriesTemplates, err := route.GetQueriesTemplates() if err == nil { fmt.Println("Queries templates:", strings.Join(queriesTemplates, ",")) } queriesRegexps, err := route.GetQueriesRegexp() if err == nil { fmt.Println("Queries regexps:", strings.Join(queriesRegexps, ",")) } methods, err := route.GetMethods() if err == nil { fmt.Println("Methods:", strings.Join(methods, ",")) } fmt.Println() return nil }) if err != nil { fmt.Println(err) } } func servePublic(w http.ResponseWriter, r *http.Request) { webPath := "/" if util.WebHostURL != nil { webPath = util.WebHostURL.Path if !strings.HasSuffix(webPath, "/") { webPath += "/" } } reqPath := r.URL.Path apiPath := path.Join(webPath, "api") if reqPath == apiPath || strings.HasPrefix(reqPath, apiPath) { http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) return } // Check if this is a request for the swagger UI swaggerPath := path.Join(webPath, "swagger") if reqPath == swaggerPath || reqPath == swaggerPath+"/" { serveFile(w, r, "swagger/index.html") return } if !strings.Contains(reqPath, ".") { serveFile(w, r, "index.html") return } newPath := strings.Replace( reqPath, webPath, "", 1, ) serveFile(w, r, newPath) } func serveFile(w http.ResponseWriter, r *http.Request, name string) { res, err := publicAssets.ReadFile( fmt.Sprintf("public/%s", name), ) if err != nil { http.Error( w, http.StatusText(http.StatusNotFound), http.StatusNotFound, ) return } if util.WebHostURL != nil && name == "index.html" { baseURL := util.WebHostURL.String() if !strings.HasSuffix(baseURL, "/") { baseURL += "/" } res = []byte( strings.Replace( string(res), ``, fmt.Sprintf(``, baseURL), 1, ), ) } if !strings.HasSuffix(name, ".html") { w.Header().Add( "Cache-Control", fmt.Sprintf("max-age=%d, public, must-revalidate, proxy-revalidate", 24*time.Hour), ) } http.ServeContent( w, r, name, startTime, bytes.NewReader( res, ), ) } ================================================ FILE: api/runners/runners.go ================================================ package runners import ( "bytes" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/json" "encoding/pem" "fmt" "net/http" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/services/runners" "github.com/semaphoreui/semaphore/services/server" "github.com/semaphoreui/semaphore/services/tasks" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" ) func RunnerMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("X-Runner-Token") if token == "" { helpers.WriteJSON(w, http.StatusUnauthorized, map[string]string{ "error": "Invalid token", }) return } store := helpers.Store(r) runner, err := store.GetRunnerByToken(token) if err != nil { helpers.WriteJSON(w, http.StatusNotFound, map[string]string{ "error": "Runner not found", }) return } if runner.Token != token { helpers.WriteJSON(w, http.StatusUnauthorized, map[string]string{ "error": "Invalid token", }) return } r = helpers.SetContextValue(r, "runner", runner) next.ServeHTTP(w, r) }) } func loadPublicKey(keyData []byte) (*rsa.PublicKey, error) { block, _ := pem.Decode(keyData) if block == nil || block.Type != "PUBLIC KEY" { return nil, fmt.Errorf("invalid public key") } pub, err := x509.ParsePKCS1PublicKey(block.Bytes) if err != nil { return nil, err } return pub, nil } func chunkRSAEncrypt(pub *rsa.PublicKey, plaintext []byte) ([]byte, error) { // For a 2048-bit key, pub.Size() == 256 bytes // PKCS#1 v1.5 overhead = 11 bytes, so max plaintext per chunk = 256 - 11 = 245 rsaBlockSize := pub.Size() // 256 for 2048-bit maxChunkSize := rsaBlockSize - 11 // 245 var encryptedBuffer bytes.Buffer for start := 0; start < len(plaintext); start += maxChunkSize { end := start + maxChunkSize if end > len(plaintext) { end = len(plaintext) } chunk := plaintext[start:end] encryptedChunk, err := rsa.EncryptPKCS1v15(rand.Reader, pub, chunk) if err != nil { return nil, fmt.Errorf("encrypt chunk failed: %w", err) } // Append the encrypted chunk (always 256 bytes for 2048-bit key) encryptedBuffer.Write(encryptedChunk) } return encryptedBuffer.Bytes(), nil } type RunnerController struct { runnerRepo db.RunnerManager taskPool *tasks.TaskPool encryptionService server.AccessKeyEncryptionService } func NewRunnerController(runnerRepo db.RunnerManager, taskPool *tasks.TaskPool, encryptionService server.AccessKeyEncryptionService) *RunnerController { return &RunnerController{ runnerRepo: runnerRepo, taskPool: taskPool, encryptionService: encryptionService, } } func (c *RunnerController) GetRunner(w http.ResponseWriter, r *http.Request) { runner := helpers.GetFromContext(r, "runner").(db.Runner) clearCache := false err := c.runnerRepo.TouchRunner(runner) if err != nil { log.WithFields(log.Fields{ "runner_id": runner.ID, "context": "runner", }).WithError(err).Error("runner touch failed") helpers.WriteError(w, err) return } if runner.CleaningRequested != nil && (runner.Touched == nil || runner.CleaningRequested.After(*runner.Touched)) { clearCache = true } data := runners.RunnerState{ AccessKeys: make(map[int]db.AccessKey), ClearCache: clearCache, } if clearCache { data.CacheCleanProjectID = runner.ProjectID } tasks := c.taskPool.GetRunningTasks() for _, tsk := range tasks { if tsk.RunnerID != runner.ID { continue } if tsk.Task.Status == task_logger.TaskStartingStatus { data.NewJobs = append(data.NewJobs, runners.JobData{ Username: tsk.Username, IncomingVersion: tsk.IncomingVersion, Alias: tsk.Alias, Task: tsk.Task, Template: tsk.Template, Inventory: tsk.Inventory, InventoryRepository: tsk.Inventory.Repository, Repository: tsk.Repository, Environment: tsk.Environment, }) if tsk.Inventory.SSHKeyID != nil { err := c.encryptionService.DeserializeSecret(&tsk.Inventory.SSHKey) if err != nil { // TODO: return error } data.AccessKeys[*tsk.Inventory.SSHKeyID] = tsk.Inventory.SSHKey } if tsk.Inventory.BecomeKeyID != nil { err := c.encryptionService.DeserializeSecret(&tsk.Inventory.BecomeKey) if err != nil { // TODO: return error } data.AccessKeys[*tsk.Inventory.BecomeKeyID] = tsk.Inventory.BecomeKey } if tsk.Template.Vaults != nil { for _, vault := range tsk.Template.Vaults { if vault.VaultKeyID != nil { err := c.encryptionService.DeserializeSecret(vault.Vault) if err != nil { // TODO: return error } data.AccessKeys[*vault.VaultKeyID] = *vault.Vault } } } if tsk.Inventory.RepositoryID != nil { err := c.encryptionService.DeserializeSecret(&tsk.Inventory.Repository.SSHKey) if err != nil { // TODO: return error } data.AccessKeys[tsk.Inventory.Repository.SSHKeyID] = tsk.Inventory.Repository.SSHKey } data.AccessKeys[tsk.Repository.SSHKeyID] = tsk.Repository.SSHKey } else { data.CurrentJobs = append(data.CurrentJobs, runners.JobState{ ID: tsk.Task.ID, Status: tsk.Task.Status, }) } } if runner.PublicKey != nil { publicKey, err := loadPublicKey([]byte(*runner.PublicKey)) if err != nil { helpers.WriteError(w, err) return } message, err := json.Marshal(data) if err != nil { helpers.WriteError(w, err) return } encryptedBytes, err := chunkRSAEncrypt(publicKey, message) if err != nil { helpers.WriteError(w, err) return } w.Header().Set("Content-Type", "application/octet-stream") _, err = w.Write(encryptedBytes) if err != nil { helpers.WriteError(w, err) return } } else { helpers.WriteJSON(w, http.StatusOK, data) } } func (c *RunnerController) UpdateRunner(w http.ResponseWriter, r *http.Request) { runner := helpers.GetFromContext(r, "runner").(db.Runner) var body runners.RunnerProgress if !helpers.Bind(w, r, &body) { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Invalid format", }) return } taskPool := c.taskPool if body.Jobs == nil { w.WriteHeader(http.StatusNoContent) return } for _, job := range body.Jobs { tsk := taskPool.GetTask(job.ID) if tsk == nil { continue } if tsk.RunnerID != runner.ID { helpers.WriteErrorStatus(w, "Task not assigned to this runner", http.StatusBadRequest) return } for _, logRecord := range job.LogRecords { tsk.LogWithTime(logRecord.Time, logRecord.Message) } if !job.Status.IsValid() { helpers.WriteErrorStatus(w, "Invalid task status", http.StatusBadRequest) return } tsk.SetStatus(job.Status) if job.Commit != nil { tsk.SetCommit(job.Commit.Hash, job.Commit.Message) } } w.WriteHeader(http.StatusNoContent) } func RegisterRunner(w http.ResponseWriter, r *http.Request) { var register runners.RunnerRegistration if !helpers.Bind(w, r, ®ister) { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Invalid format", }) return } if util.Config.RunnerRegistrationToken == "" || register.RegistrationToken != util.Config.RunnerRegistrationToken { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "Invalid registration token", }) return } runner, err := helpers.Store(r).CreateRunner(db.Runner{ Webhook: register.Webhook, MaxParallelTasks: register.MaxParallelTasks, PublicKey: register.PublicKey, }) if err != nil { helpers.WriteJSON(w, http.StatusInternalServerError, map[string]string{ "error": "Unexpected error", }) return } log.WithFields(log.Fields{ "runner_id": runner.ID, "context": "runner", }).Info("New runner registered") var res struct { Token string `json:"token"` } res.Token = runner.Token helpers.WriteJSON(w, http.StatusOK, res) } func UnregisterRunner(w http.ResponseWriter, r *http.Request) { runner := helpers.GetFromContext(r, "runner").(db.Runner) err := helpers.Store(r).DeleteGlobalRunner(runner.ID) if err != nil { helpers.WriteJSON(w, http.StatusInternalServerError, map[string]string{ "error": "Unknown error", }) return } w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/runners.go ================================================ package api import ( "bufio" "bytes" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" "net/http" ) func getAllRunners(w http.ResponseWriter, r *http.Request) { runners, err := helpers.Store(r).GetAllRunners(false, false) if err != nil { panic(err) } var result = make([]db.Runner, 0) result = append(result, runners...) helpers.WriteJSON(w, http.StatusOK, result) } type runnerWithToken struct { db.Runner Token string `json:"token"` PrivateKey string `json:"private_key"` } func addGlobalRunner(w http.ResponseWriter, r *http.Request) { var runner db.Runner if !helpers.Bind(w, r, &runner) { return } runner.ProjectID = nil var privateKey []byte if runner.PublicKey == nil { var b bytes.Buffer privateKeyFile := bufio.NewWriter(&b) publicKey, err := util.GeneratePrivateKey(privateKeyFile) if err != nil { helpers.WriteError(w, err) return } err = privateKeyFile.Flush() if err != nil { helpers.WriteError(w, err) return } privateKey = b.Bytes() runner.PublicKey = &publicKey } newRunner, err := helpers.Store(r).CreateRunner(runner) if err != nil { log.Warn("Runner is not created: " + err.Error()) w.WriteHeader(http.StatusBadRequest) return } helpers.WriteJSON(w, http.StatusCreated, runnerWithToken{ Runner: newRunner, Token: newRunner.Token, PrivateKey: string(privateKey), }) } func globalRunnerMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { runnerID, err := helpers.GetIntParam("runner_id", w, r) if err != nil { helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{ "error": "runner_id required", }) return } store := helpers.Store(r) runner, err := store.GetGlobalRunner(runnerID) if err != nil { helpers.WriteJSON(w, http.StatusNotFound, map[string]string{ "error": "Runner not found", }) return } r = helpers.SetContextValue(r, "runner", &runner) next.ServeHTTP(w, r) }) } func getGlobalRunner(w http.ResponseWriter, r *http.Request) { runner := helpers.GetFromContext(r, "runner").(*db.Runner) helpers.WriteJSON(w, http.StatusOK, runner) } func updateGlobalRunner(w http.ResponseWriter, r *http.Request) { oldRunner := helpers.GetFromContext(r, "runner").(*db.Runner) var runner db.Runner if !helpers.Bind(w, r, &runner) { return } store := helpers.Store(r) runner.ID = oldRunner.ID runner.ProjectID = nil err := store.UpdateRunner(runner) if err != nil { helpers.WriteErrorStatus(w, err.Error(), http.StatusBadRequest) return } w.WriteHeader(http.StatusNoContent) } func clearGlobalRunnerCache(w http.ResponseWriter, r *http.Request) { runner := helpers.GetFromContext(r, "runner").(*db.Runner) store := helpers.Store(r) err := store.ClearRunnerCache(*runner) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } func deleteGlobalRunner(w http.ResponseWriter, r *http.Request) { runner := helpers.GetFromContext(r, "runner").(*db.Runner) store := helpers.Store(r) err := store.DeleteGlobalRunner(runner.ID) if err != nil { helpers.WriteErrorStatus(w, err.Error(), http.StatusBadRequest) return } w.WriteHeader(http.StatusNoContent) } func setGlobalRunnerActive(w http.ResponseWriter, r *http.Request) { runner := helpers.GetFromContext(r, "runner").(*db.Runner) store := helpers.Store(r) var body struct { Active bool `json:"active"` } if !helpers.Bind(w, r, &body) { helpers.WriteErrorStatus(w, "Invalid request body", http.StatusBadRequest) return } runner.Active = body.Active err := store.UpdateRunner(*runner) if err != nil { helpers.WriteErrorStatus(w, err.Error(), http.StatusBadRequest) return } w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/sockets/handler.go ================================================ package sockets import ( "fmt" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/tz" "net/http" "time" "github.com/gorilla/websocket" log "github.com/sirupsen/logrus" ) var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 1024, CheckOrigin: func(r *http.Request) bool { return true }, } const ( // Time allowed to write a message to the peer. writeWait = 2 * 10 * time.Second // Time allowed to read the next pong message from the peer. pongWait = 2 * 60 * time.Second // Send pings to peer with this period. Must be less than pongWait. pingPeriod = (pongWait * 9) / 10 // Maximum message size allowed from peer. maxMessageSize = 512 // Maximum size of the connection.send channel. // When the channel is full, the hub closes it (see method hub.run). connectionChannelSize = 256 ) type connection struct { ws *websocket.Conn send chan []byte userID int } func (c *connection) log(level log.Level, err error, msg string) { log.WithError(err).WithFields(log.Fields{ "context": "websocket", "user_id": c.userID, }).Log(level, msg) } func (c *connection) logError(err error, msg string) { c.log(log.ErrorLevel, err, msg) } func (c *connection) logWarn(err error, msg string) { c.log(log.DebugLevel, err, msg) } func (c *connection) logDebug(err error, msg string) { c.log(log.DebugLevel, err, msg) } // readPump pumps messages from the websocket connection to the hub. func (c *connection) readPump() { defer func() { h.unregister <- c _ = c.ws.Close() }() c.ws.SetReadLimit(maxMessageSize) if err := c.ws.SetReadDeadline(tz.Now().Add(pongWait)); err != nil { c.logWarn(err, "Failed to set read deadline") } c.ws.SetPongHandler(func(string) error { deadline := tz.Now().Add(pongWait) if err := c.ws.SetReadDeadline(deadline); err != nil { c.logWarn(err, "Failed to set read deadline") } return nil }) for { _, message, err := c.ws.ReadMessage() fmt.Println(string(message)) if err != nil { if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) { c.logDebug(err, "Failed to read message from client") } break } } } // write writes a message with the given message type and payload. func (c *connection) write(mt int, payload []byte) error { deadline := tz.Now().Add(writeWait) if err := c.ws.SetWriteDeadline(deadline); err != nil { c.logWarn(err, "Cannot set write deadline") } return c.ws.WriteMessage(mt, payload) } // writePump pumps messages from the hub to the websocket connection. func (c *connection) writePump() { ticker := time.NewTicker(pingPeriod) defer func() { ticker.Stop() _ = c.ws.Close() }() for { select { case message, ok := <-c.send: if !ok { if err := c.write(websocket.CloseMessage, []byte{}); err != nil { c.logDebug(err, "Failed to write close message to client") } return } if err := c.write(websocket.TextMessage, message); err != nil { c.logDebug(err, "Failed to write message to client") return } case <-ticker.C: if err := c.write(websocket.PingMessage, []byte{}); err != nil { c.logDebug(err, "Failed to write ping message to client") return } } } } // Handler is used by the router to handle the /ws endpoint func Handler(w http.ResponseWriter, r *http.Request) { usr := helpers.GetFromContext(r, "user") if usr == nil { return } user := usr.(*db.User) ws, err := upgrader.Upgrade(w, r, nil) if err != nil { log.WithError(err).WithFields(log.Fields{ "context": "websocket", "user_id": user.ID, }).Error("Failed to upgrade connection to websocket") w.WriteHeader(http.StatusInternalServerError) return } c := &connection{ send: make(chan []byte, connectionChannelSize), ws: ws, userID: user.ID, } h.register <- c go c.writePump() c.readPump() } // Message allows a message to be sent to the websockets, called in API task logging. // In HA mode, messages are relayed to all cluster nodes via the configured Broadcaster. func Message(userID int, message []byte) { if broadcaster != nil { broadcaster.Publish(userID, message) return } h.broadcast <- &sendRequest{ userID: userID, msg: message, } } ================================================ FILE: api/sockets/pool.go ================================================ package sockets import log "github.com/sirupsen/logrus" // Broadcaster provides cross-node WebSocket message delivery for HA setups. // When configured, Message() delegates to the broadcaster which publishes // messages to all nodes in the cluster via Redis Pub/Sub. type Broadcaster interface { // Start begins listening for messages from other nodes. Start() // Publish delivers a message to all nodes in the cluster. // The implementation must also deliver the message to local clients // by calling LocalBroadcast. Publish(userID int, msg []byte) // Stop shuts down the broadcaster. Stop() } var broadcaster Broadcaster // SetBroadcaster configures a cross-node broadcaster for HA mode. // When set, Message() delegates to the broadcaster instead of the local hub. func SetBroadcaster(b Broadcaster) { broadcaster = b } // hub maintains the set of active connections and broadcasts messages to the // connections. type hub struct { // Registered websocket connections. connections map[*connection]bool // Inbound messages from the connections. broadcast chan *sendRequest // Register requests from the connections. register chan *connection // Unregister requests from connections. unregister chan *connection } type sendRequest struct { userID int msg []byte } var h = hub{ broadcast: make(chan *sendRequest), register: make(chan *connection), unregister: make(chan *connection), connections: make(map[*connection]bool), } func (h *hub) run() { for { select { case c := <-h.register: h.connections[c] = true case c := <-h.unregister: if _, ok := h.connections[c]; ok { delete(h.connections, c) close(c.send) } case m := <-h.broadcast: for conn := range h.connections { if m.userID > 0 && m.userID != conn.userID { continue } select { case conn.send <- m.msg: default: log.WithFields(log.Fields{ "context": "websocket", "user_id": conn.userID, }).Error("Connection send channel is full, connection closing") close(conn.send) delete(h.connections, conn) _ = conn.ws.Close() // Close the WebSocket connection first } } } } } // StartWS starts the web sockets in a goroutine func StartWS() { h.run() } // LocalBroadcast delivers a message to locally-connected WebSocket clients // only. Used by Broadcaster implementations to relay messages received from // other nodes without re-publishing them. func LocalBroadcast(userID int, message []byte) { h.broadcast <- &sendRequest{ userID: userID, msg: message, } } ================================================ FILE: api/system_info.go ================================================ package api import ( "errors" "net/http" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" proFeatures "github.com/semaphoreui/semaphore/pro/pkg/features" "github.com/semaphoreui/semaphore/pro_interfaces" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" ) type SystemInfoController struct { subscriptionService pro_interfaces.SubscriptionService } func NewSystemInfoController(subscriptionService pro_interfaces.SubscriptionService) *SystemInfoController { return &SystemInfoController{ subscriptionService, } } func (c *SystemInfoController) GetSystemInfo(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "user").(*db.User) var authMethods LoginAuthMethods if util.Config.Auth.Totp.Enabled { authMethods.Totp = &LoginTotpAuthMethod{ AllowRecovery: util.Config.Auth.Totp.AllowRecovery, } } if util.Config.Auth.Email.Enabled { authMethods.Email = &LoginEmailAuthMethod{} } timezone := util.Config.Schedule.Timezone if timezone == "" { timezone = "UTC" } roles, err := helpers.Store(r).GetGlobalRoles() if err != nil { log.WithError(err).Error("Failed to get roles") http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } var plan string token, err := c.subscriptionService.GetToken() if errors.Is(err, db.ErrNotFound) { err = nil } if err != nil { log.WithError(err).Error("Failed to get subscription plan") err = nil //http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) //return } switch { case errors.Is(err, db.ErrNotFound): err = nil plan = "" case err != nil: log.WithError(err).Error("Failed to get subscription plan") err = nil plan = "" //http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return default: plan = token.Plan } body := map[string]any{ "version": util.Version(), "ansible": util.AnsibleVersion(), "web_host": util.Config.WebHost, "use_remote_runner": util.Config.UseRemoteRunner, "auth_methods": authMethods, "premium_features": proFeatures.GetFeatures(user, plan), "git_client": util.Config.GitClientId, "schedule_timezone": timezone, "teams": util.Config.Teams, "roles": roles, } helpers.WriteJSON(w, http.StatusOK, body) } ================================================ FILE: api/tasks/tasks.go ================================================ package tasks import ( "net/http" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" task2 "github.com/semaphoreui/semaphore/services/tasks" ) func TaskMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { taskID, err := helpers.GetIntParam("task_id", w, r) if err != nil { helpers.WriteErrorStatus(w, err.Error(), http.StatusBadRequest) } r = helpers.SetContextValue(r, "task_id", taskID) next.ServeHTTP(w, r) }) } type taskLocation string const ( taskQueue taskLocation = "queue" taskRunning taskLocation = "running" ) type taskRes struct { TaskID int `json:"task_id"` ProjectID int `json:"project_id"` Username string `json:"username,omitempty"` RunnerID int `json:"runner_id,omitempty"` Status task_logger.TaskStatus `json:"status"` Location taskLocation `json:"location"` RunnerName string `json:"runner_name,omitempty"` ProjectName string `json:"project_name,omitempty"` } func GetTasks(w http.ResponseWriter, r *http.Request) { pool := helpers.GetFromContext(r, "task_pool").(*task2.TaskPool) res := []taskRes{} for _, task := range pool.GetQueuedTasks() { res = append(res, taskRes{ TaskID: task.Task.ID, ProjectID: task.Task.ProjectID, RunnerID: task.RunnerID, Username: task.Username, Status: task.Task.Status, Location: taskQueue, }) } for _, task := range pool.GetRunningTasks() { res = append(res, taskRes{ TaskID: task.Task.ID, ProjectID: task.Task.ProjectID, RunnerID: task.RunnerID, Username: task.Username, Status: task.Task.Status, Location: taskRunning, }) } helpers.WriteJSON(w, http.StatusOK, res) } func DeleteTask(w http.ResponseWriter, r *http.Request) { taskID := helpers.GetFromContext(r, "task_id").(int) pool := helpers.GetFromContext(r, "task_pool").(*task2.TaskPool) var task *db.Task for _, t := range pool.GetQueuedTasks() { if t.Task.ID == taskID { task = &t.Task break } } if task == nil { for _, t := range pool.GetRunningTasks() { if t.Task.ID == taskID { task = &t.Task break } } } if task != nil { err := pool.StopTask(*task, false) if err != nil { helpers.WriteErrorStatus(w, err.Error(), http.StatusInternalServerError) return } } helpers.WriteJSON(w, http.StatusNoContent, nil) } ================================================ FILE: api/user.go ================================================ package api import ( "crypto/rand" "encoding/base64" "io" "net/http" "strings" "github.com/gorilla/mux" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pro_interfaces" "github.com/semaphoreui/semaphore/util" ) type UserController struct { subscriptionService pro_interfaces.SubscriptionService } func NewUserController(subscriptionService pro_interfaces.SubscriptionService) *UserController { return &UserController{ subscriptionService: subscriptionService, } } func (c *UserController) GetUser(w http.ResponseWriter, r *http.Request) { if u, exists := helpers.GetOkFromContext(r, "_user"); exists { helpers.WriteJSON(w, http.StatusOK, u) return } var user struct { db.User CanCreateProject bool `json:"can_create_project"` HasActiveSubscription bool `json:"has_active_subscription"` } user.User = *helpers.GetFromContext(r, "user").(*db.User) user.CanCreateProject = user.Admin || util.Config.NonAdminCanCreateProject user.HasActiveSubscription = c.subscriptionService.HasActiveSubscription() if !user.HasActiveSubscription { user.Pro = false } helpers.WriteJSON(w, http.StatusOK, user) } func getAPITokens(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "user").(*db.User) tokens, err := helpers.Store(r).GetAPITokens(user.ID) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } for i := range tokens { if len(tokens[i].ID) >= 8 { tokens[i].ID = tokens[i].ID[:8] } // If ID is shorter than 8 chars, leave it as-is } helpers.WriteJSON(w, http.StatusOK, tokens) } func createAPIToken(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "user").(*db.User) tokenID := make([]byte, 32) if _, err := io.ReadFull(rand.Reader, tokenID); err != nil { panic(err) } token, err := helpers.Store(r).CreateAPIToken(db.APIToken{ ID: strings.ToLower(base64.URLEncoding.EncodeToString(tokenID)), UserID: user.ID, Expired: false, }) if err != nil { panic(err) } helpers.WriteJSON(w, http.StatusCreated, token) } func deleteAPIToken(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "user").(*db.User) tokenID := mux.Vars(r)["token_id"] err := helpers.Store(r).DeleteAPIToken(user.ID, tokenID) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api/users.go ================================================ package api import ( "bytes" "fmt" "github.com/pquerna/otp" "github.com/pquerna/otp/totp" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pro_interfaces" log "github.com/sirupsen/logrus" "image/png" "net/http" "github.com/semaphoreui/semaphore/util" ) type UsersController struct { subscriptionService pro_interfaces.SubscriptionService } func NewUsersController(subscriptionService pro_interfaces.SubscriptionService) *UsersController { return &UsersController{ subscriptionService: subscriptionService, } } type minimalUser struct { ID int `json:"id"` Username string `json:"username"` Name string `json:"name"` } func (c *UsersController) GetUsers(w http.ResponseWriter, r *http.Request) { currentUser := helpers.GetFromContext(r, "user").(*db.User) users, err := helpers.Store(r).GetUsers(db.RetrieveQueryParams{ Filter: r.URL.Query().Get("s"), }) if err != nil { panic(err) } if currentUser.Admin { helpers.WriteJSON(w, http.StatusOK, users) } else { var result = make([]minimalUser, 0) for _, user := range users { result = append(result, minimalUser{ ID: user.ID, Name: user.Name, Username: user.Username, }) } helpers.WriteJSON(w, http.StatusOK, result) } } func (c *UsersController) AddUser(w http.ResponseWriter, r *http.Request) { var user db.UserWithPwd if !helpers.Bind(w, r, &user) { return } editor := helpers.GetFromContext(r, "user").(*db.User) if !editor.Admin { log.Warn(editor.Username + " is not permitted to create users") w.WriteHeader(http.StatusUnauthorized) return } if user.Pro { ok, err := c.subscriptionService.CanAddProUser() if err != nil { w.WriteHeader(http.StatusInternalServerError) return } if !ok { helpers.WriteErrorStatus(w, fmt.Sprintf("You have reached the limit of Pro users for your subscription."), http.StatusForbidden) return } } var err error var newUser db.User if user.External { newUser, err = helpers.Store(r).CreateUserWithoutPassword(user.User) } else { newUser, err = helpers.Store(r).CreateUser(user) } if err != nil { log.Warn(editor.Username + " is not created: " + err.Error()) w.WriteHeader(http.StatusBadRequest) return } helpers.WriteJSON(w, http.StatusCreated, newUser) } func readonlyUserMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userID, err := helpers.GetIntParam("user_id", w, r) if err != nil { return } user, err := helpers.Store(r).GetUser(userID) if err != nil { helpers.WriteError(w, err) return } editor := helpers.GetFromContext(r, "user").(*db.User) if !editor.Admin && editor.ID != user.ID { user = db.User{ ID: user.ID, Username: user.Username, Name: user.Name, } } r = helpers.SetContextValue(r, "_user", user) next.ServeHTTP(w, r) }) } func getUserMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { userID, err := helpers.GetIntParam("user_id", w, r) if err != nil { return } user, err := helpers.Store(r).GetUser(userID) if err != nil { helpers.WriteError(w, err) return } editor := helpers.GetFromContext(r, "user").(*db.User) if !editor.Admin && editor.ID != user.ID { log.Warn(editor.Username + " is not permitted to edit users") w.WriteHeader(http.StatusUnauthorized) return } r = helpers.SetContextValue(r, "_user", user) next.ServeHTTP(w, r) }) } func (c *UsersController) UpdateUser(w http.ResponseWriter, r *http.Request) { targetUser := helpers.GetFromContext(r, "_user").(db.User) editor := helpers.GetFromContext(r, "user").(*db.User) var user db.UserWithPwd if !helpers.Bind(w, r, &user) { return } if !editor.Admin && (user.Pro && !targetUser.Pro) { log.Warn(editor.Username + " is not permitted to mark users as Pro") w.WriteHeader(http.StatusUnauthorized) return } if user.Pro { ok, err := c.subscriptionService.CanAddProUser() if err != nil { w.WriteHeader(http.StatusInternalServerError) return } if !ok { helpers.WriteErrorStatus(w, fmt.Sprintf("You have reached the limit of Pro users for your subscription."), http.StatusForbidden) return } } if !editor.Admin && editor.ID != targetUser.ID { log.Warn(editor.Username + " is not permitted to edit users") w.WriteHeader(http.StatusUnauthorized) return } if editor.ID == targetUser.ID && targetUser.Admin != user.Admin { log.Warn("User can't edit his own role") w.WriteHeader(http.StatusUnauthorized) return } if targetUser.External && targetUser.Username != user.Username { log.Warn("Username is not editable for external users") w.WriteHeader(http.StatusBadRequest) return } user.ID = targetUser.ID if err := helpers.Store(r).UpdateUser(user); err != nil { log.Error(err.Error()) w.WriteHeader(http.StatusBadRequest) return } w.WriteHeader(http.StatusNoContent) } func updateUserPassword(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "_user").(db.User) editor := helpers.GetFromContext(r, "user").(*db.User) var pwd struct { Pwd string `json:"password"` } if !editor.Admin && editor.ID != user.ID { log.Warn(editor.Username + " is not permitted to edit users") w.WriteHeader(http.StatusUnauthorized) return } if user.External { log.Warn("Password is not editable for external users") w.WriteHeader(http.StatusBadRequest) return } if !helpers.Bind(w, r, &pwd) { return } if err := helpers.Store(r).SetUserPassword(user.ID, pwd.Pwd); err != nil { util.LogWarning(err) w.WriteHeader(http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) } func deleteUser(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "_user").(db.User) editor := helpers.GetFromContext(r, "user").(*db.User) if !editor.Admin && editor.ID != user.ID { log.Warn(editor.Username + " is not permitted to delete users") w.WriteHeader(http.StatusUnauthorized) return } if err := helpers.Store(r).DeleteUser(user.ID); err != nil { w.WriteHeader(http.StatusInternalServerError) } w.WriteHeader(http.StatusNoContent) } func totpQr(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "_user").(db.User) if user.Totp == nil { helpers.WriteErrorStatus(w, "TOTP not enabled", http.StatusNotFound) return } key, err := otp.NewKeyFromURL(user.Totp.URL) if err != nil { helpers.WriteError(w, err) return } image, err := key.Image(256, 256) if err != nil { helpers.WriteError(w, err) return } var buf bytes.Buffer err = png.Encode(&buf, image) if err != nil { helpers.WriteError(w, err) return } pngBytes := buf.Bytes() w.Header().Add("Content-Type", "image/png") _, err = w.Write(pngBytes) } func enableTotp(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "_user").(db.User) if !util.Config.Auth.Totp.Enabled { helpers.WriteErrorStatus(w, "TOTP not enabled", http.StatusBadRequest) return } if user.Totp != nil { helpers.WriteErrorStatus(w, "TOTP already enabled", http.StatusBadRequest) return } key, err := totp.Generate(totp.GenerateOpts{ Issuer: "Semaphore", AccountName: user.Email, }) if err != nil { http.Error(w, "Error generating key", http.StatusInternalServerError) return } var code, hash string if util.Config.Auth.Totp.AllowRecovery { code, hash, err = util.GenerateRecoveryCode() if err != nil { helpers.WriteError(w, err) return } } newTotp, err := helpers.Store(r).AddTotpVerification(user.ID, key.URL(), hash) if err != nil { helpers.WriteError(w, err) return } newTotp.RecoveryCode = code helpers.WriteJSON(w, http.StatusOK, newTotp) } func disableTotp(w http.ResponseWriter, r *http.Request) { user := helpers.GetFromContext(r, "_user").(db.User) if user.Totp == nil { helpers.WriteErrorStatus(w, "TOTP not enabled", http.StatusBadRequest) return } totpID, err := helpers.GetIntParam("totp_id", w, r) if err != nil { helpers.WriteError(w, err) return } err = helpers.Store(r).DeleteTotpVerification(user.ID, totpID) if err != nil { helpers.WriteError(w, err) return } w.WriteHeader(http.StatusNoContent) } ================================================ FILE: api-docs.yml ================================================ --- swagger: '2.0' info: title: Semaphore API description: | Semaphore API provides endpoints for managing and interacting with the Semaphore UI. This documentation outlines the available operations and data models. version: "2.16.14" consumes: - application/json produces: - application/json - text/plain; charset=utf-8 tags: - name: authentication description: Authentication, Logout & API Tokens - name: project description: Everything related to a project - name: user description: User-related API - name: integration description: Integration API schemes: - http - https basePath: /api definitions: App: type: object Pong: type: string x-example: pong Login: type: object properties: auth: type: string description: Username/Email address x-example: user@semaphoreui.com password: type: string format: password description: Password LoginMetadata: type: object properties: oidc_providers: type: array description: List of OIDC providers items: type: object properties: id: type: string description: ID of the provider, used in the login URL x-example: mysso name: type: string description: Text to show on the login button x-example: Sign in with MySSO UserRequest: type: object properties: name: type: string x-example: Integration Test User example: Integration Test User username: type: string x-example: test-user example: test-user email: type: string x-example: test@ansiblesemaphore.test example: test@ansiblesemaphore.test password: type: string format: password alert: type: boolean admin: type: boolean external: type: boolean UserPutRequest: type: object properties: name: type: string x-example: Integration Test User2 example: Integration Test User2 username: type: string x-example: test-user2 example: test-user2 email: type: string x-example: test2@ansiblesemaphore.test example: test2@ansiblesemaphore.test alert: type: boolean admin: type: boolean User: type: object properties: id: type: integer minimum: 1 name: type: string username: type: string email: type: string created: type: string alert: type: boolean admin: type: boolean external: type: boolean ProjectUser: type: object properties: id: type: integer minimum: 1 name: type: string username: type: string role: type: string enum: [owner, manager, task_runner, guest] ProjectInvite: type: object properties: id: type: integer minimum: 1 project_id: type: integer minimum: 1 user_id: type: integer minimum: 1 description: User ID for user-based invites (optional) email: type: string format: email description: Email address for email-based invites (optional) role: type: string enum: [owner, manager, task_runner, guest] example: manager status: type: string enum: [pending, accepted, declined, expired] example: pending inviter_user_id: type: integer minimum: 1 description: ID of the user who created the invite created: type: string format: date-time description: When the invite was created expires_at: type: string format: date-time description: When the invite expires (optional) accepted_at: type: string format: date-time description: When the invite was accepted (optional) inviter_user: $ref: "#/definitions/User" description: Details of the user who created the invite user: $ref: "#/definitions/User" description: Details of the invited user (for user-based invites) ProjectInviteRequest: type: object properties: # user_id: # type: integer # minimum: 1 # description: User ID to invite (use either user_id or email, not both) email: type: string format: email description: Email address to invite (use either user_id or email, not both) x-example: user@example.com role: type: string enum: [owner, manager, task_runner, guest] example: manager expires_at: type: string format: date-time description: When the invite should expire (optional, defaults to 7 days) required: - role AcceptInviteRequest: type: object properties: token: type: string description: The invitation token x-example: "a1b2c3d4e5f6..." required: - token ProjectBackup: type: object example: {"meta":{"name":"homelab","alert":true,"alert_chat":"Test","max_parallel_tasks":0,"type":null},"templates":[{"inventory":"Build","repository":"Demo","environment":"Empty","name":"Build","playbook":"build.yml","arguments":"[]","allow_override_args_in_task":false,"description":"Build Job","vault_key":null,"type":"build","start_version":"1.0.0","build_template":null,"view":"Build","autorun":false,"survey_vars":[],"suppress_success_alerts":false,"cron":"* * * * *"}],"repositories":[{"name":"Demo","git_url":"https://github.com/semaphoreui/semaphore-demo.git","git_branch":"main","ssh_key":"None"}],"keys":[{"name":"None","type":"none"},{"name":"Vault Password","type":"login_password"}],"views":[{"title":"Build","position":0}],"inventories":[{"name":"Build","inventory":"","ssh_key":"None","become_key":"None","type":"static"},{"name":"Dev","inventory":"","ssh_key":"None","become_key":"None","type":"file"},{"name":"Prod","inventory":"","ssh_key":"None","become_key":"None","type":"file"}],"environments":[{"name":"Empty","password":null,"json":"{}","env":null}]} properties: meta: type: object properties: name: type: string alert: type: boolean alert_chat: type: string max_parallel_tasks: type: integer minimum: 0 type: type: string templates: type: array items: type: object properties: inventory: type: string repository: type: string environment: type: string view: type: string name: type: string playbook: type: string arguments: type: string description: type: string allow_override_args_in_task: type: boolean suppress_success_alerts: type: boolean cron: type: string build_template: type: string autorun: type: boolean survey_vars: type: string start_version: type: string type: type: string vault_key: type: string allow_override_branch_in_task: type: boolean repositories: type: array items: type: object properties: name: type: string git_url: type: string git_branch: type: string ssh_key: type: string keys: type: array items: type: object properties: name: type: string type: type: string enum: [ssh, login_password, none] views: type: array items: type: object properties: name: type: string position: type: integer minimum: 0 inventories: type: array items: type: object properties: name: type: string inventory: type: string ssh_key: type: string become_key: type: string type: type: string enum: [static, static-yaml, file] environments: type: array items: type: object properties: name: type: string password: type: string json: type: string env: type: string APIToken: type: object properties: id: type: string created: type: string # pattern: ^\d{4}-(?:0[0-9]{1}|1[0-2]{1})-[0-9]{2}T\d{2}:\d{2}:\d{2}Z$ expired: type: boolean user_id: type: integer minimum: 1 ProjectRequest: type: object properties: name: type: string example: Test alert: type: boolean alert_chat: type: string example: Test max_parallel_tasks: type: integer minimum: 0 type: type: string demo: description: Create Demo project resources? type: boolean Project: type: object properties: id: type: integer minimum: 1 name: type: string example: Test created: type: string # pattern: ^\d{4}-(?:0[0-9]{1}|1[0-2]{1})-[0-9]{2}T\d{2}:\d{2}:\d{2}Z$ alert: type: boolean alert_chat: type: string example: Test max_parallel_tasks: type: integer minimum: 0 type: type: string AccessKeyRequest: type: object properties: id: type: integer name: type: string x-example: None example: None type: type: string enum: [none, ssh, login_password] x-example: none project_id: type: integer minimum: 1 x-example: 2 override_secret: type: boolean login_password: type: object properties: password: type: string x-example: password example: password login: type: string x-example: username example: username ssh: type: object properties: login: type: string x-example: user example: user passphrase: type: string x-example: passphrase example: passphrase private_key: type: string x-example: private key example: private key AccessKey: type: object properties: id: type: integer name: type: string example: Test type: type: string enum: [none, ssh, login_password] project_id: type: integer EnvironmentSecret: type: object properties: id: type: integer name: type: string type: type: string enum: [env, var] EnvironmentSecretRequest: type: object properties: id: type: integer name: type: string example: Test secret: type: string type: type: string enum: [env, var] operation: type: string enum: [create, update, delete] EnvironmentRequest: type: object properties: id: type: integer example: 1 name: type: string example: Test project_id: type: integer minimum: 1 password: type: string json: type: string example: '{}' env: type: string example: '{}' secrets: type: array items: $ref: '#/definitions/EnvironmentSecretRequest' Environment: type: object properties: id: type: integer minimum: 1 name: type: string example: Test project_id: type: integer minimum: 1 password: type: string json: type: string example: '{}' env: type: string example: '{}' secrets: type: array items: $ref: '#/definitions/EnvironmentSecret' InventoryRequest: type: object properties: id: type: integer name: type: string example: Test project_id: type: integer minimum: 1 inventory: type: string ssh_key_id: type: integer minimum: 1 become_key_id: type: integer minimum: 1 repository_id: type: integer minimum: 1 type: type: string enum: [static, static-yaml, file, terraform-workspace] Inventory: type: object properties: id: type: integer name: type: string example: Test project_id: type: integer inventory: type: string ssh_key_id: type: integer become_key_id: type: integer repository_id: type: integer type: type: string enum: [static, static-yaml, file, terraform-workspace] Integration: type: object properties: id: type: integer name: type: string example: deploy project_id: type: integer minimum: 1 template_id: type: integer minimum: 1 task_params: $ref: '#/definitions/TaskPrams' IntegrationRequest: type: object properties: name: type: string example: deploy project_id: type: integer template_id: type: integer params: $ref: '#/definitions/TaskPrams' IntegrationExtractValueRequest: type: object properties: name: type: string example: deploy value_source: type: string enum: [body, header] body_data_type: type: string enum: [json, xml, string] key: type: string example: key variable: type: string example: variable variable_type: type: string enum: [environment, task] IntegrationExtractValue: type: object properties: id: type: integer name: type: string example: extract this value value_source: type: string enum: [body, header] body_data_type: type: string enum: [json, xml, string] key: type: string example: key variable: type: string example: variable variable_type: type: string enum: [environment, task] integration_id: type: integer IntegrationMatcherRequest: type: object properties: name: type: string example: deploy match_type: type: string enum: [body, header] method: type: string enum: [equals, unequals, contains] body_data_type: type: string enum: [json, xml, string] key: type: string example: key value: type: string example: value IntegrationMatcher: type: object properties: id: type: integer integration_id: type: integer name: type: string example: deploy match_type: type: string enum: [body, header] method: type: string enum: [equals, unequals, contains] body_data_type: type: string enum: [json, xml, string] key: type: string example: key value: type: string example: value IntegrationAlias: type: object properties: id: type: integer url: type: string RepositoryRequest: type: object properties: id: type: integer name: type: string example: Test project_id: type: integer git_url: type: string example: git@example.com git_branch: type: string example: master ssh_key_id: type: integer Repository: type: object properties: id: type: integer name: type: string example: Test project_id: type: integer git_url: type: string example: git@example.com git_branch: type: string example: master ssh_key_id: type: integer Task: type: object properties: id: type: integer example: 23 template_id: type: integer status: type: string playbook: type: string environment: type: string secret: type: string arguments: type: string git_branch: type: string message: type: string inventory_id: type: integer params: allOf: - $ref: '#/definitions/AnsibleTaskParams' - $ref: '#/definitions/TerraformTaskParams' limit: type: string AnsibleTaskParams: type: object properties: debug: type: boolean dry_run: type: boolean diff: type: boolean limit: type: array items: type: string tags: type: array items: type: string skip_tags: type: array items: type: string TerraformTaskParams: type: object properties: plan: type: boolean destroy: type: boolean auto_approve: type: boolean upgrade: type: boolean TaskPrams: type: object properties: environment: type: string git_branch: type: string message: type: string # inventory_id: # type: integer # x-nullable: true arguments: type: string params: allOf: - $ref: '#/definitions/AnsibleTaskParams' - $ref: '#/definitions/TerraformTaskParams' TaskOutput: type: object properties: task_id: type: integer example: 23 time: type: string format: date-time output: type: string TemplateRequest: type: object properties: id: type: integer example: 1 project_id: type: integer minimum: 1 inventory_id: type: integer minimum: 1 repository_id: type: integer minimum: 1 environment_id: type: integer minimum: 1 view_id: type: integer minimum: 1 vaults: type: array items: $ref: '#/definitions/TemplateVault' name: type: string example: Test playbook: type: string example: test.yml arguments: type: string example: '[]' description: type: string example: Hello, World! allow_override_args_in_task: type: boolean example: false limit: type: string example: '' suppress_success_alerts: type: boolean app: type: string example: ansible git_branch: type: string example: main survey_vars: type: array items: $ref: "#/definitions/TemplateSurveyVar" type: type: string enum: ["", build, deploy] start_version: type: string build_template_id: type: integer autorun: type: boolean Template: type: object properties: id: type: integer minimum: 1 project_id: type: integer minimum: 1 inventory_id: type: integer minimum: 1 repository_id: type: integer environment_id: type: integer minimum: 1 view_id: type: integer minimum: 1 name: type: string example: Test playbook: type: string example: test.yml arguments: type: string example: '[]' description: type: string example: Hello, World! allow_override_args_in_task: type: boolean example: false suppress_success_alerts: type: boolean app: type: string git_branch: type: string example: main type: type: string enum: ["", build, deploy] start_version: type: string build_template_id: type: integer autorun: type: boolean survey_vars: type: array items: $ref: "#/definitions/TemplateSurveyVar" vaults: type: array items: $ref: "#/definitions/TemplateVault" TemplateSurveyVar: type: object properties: name: type: string title: type: string description: type: string type: type: string enum: ["", int, enum, secret] # String => "", Integer => "int" example: int required: type: boolean values: type: array items: $ref: "#/definitions/TemplateSurveyVarValue" TemplateSurveyVarValue: type: object properties: name: type: string value: type: string TemplateVault: type: object properties: id: type: integer name: type: string example: default type: type: string enum: [password, script] example: script vault_key_id: type: integer script: type: string example: path/to/script-client.py ScheduleRequest: type: object properties: id: type: integer cron_format: type: string x-example: "* * * 1 *" example: "* * * 1 *" project_id: type: integer template_id: type: integer name: type: string active: type: boolean run_at: type: string format: date-time type: type: string enum: ['', 'run_at'] task_params: $ref: '#/definitions/TaskPrams' Schedule: type: object properties: id: type: integer cron_format: type: string project_id: type: integer template_id: type: integer name: type: string active: type: boolean run_at: type: string format: date-time type: type: string enum: ['', 'run_at'] task_params: $ref: '#/definitions/TaskPrams' ViewRequest: type: object properties: title: type: string example: Test project_id: type: integer minimum: 1 position: type: integer minimum: 1 View: type: object properties: id: type: integer title: type: string project_id: type: integer position: type: integer hidden: type: boolean type: type: string enum: ["", all] sort_column: type: string enum: [name] sort_reverse: type: boolean Runner: type: object properties: token: type: string Event: type: object properties: project_id: type: integer user_id: type: integer object_id: type: integer object_type: type: string description: type: string InfoType: type: object properties: version: type: string ansible: type: string web_host: type: string use_remote_runner: type: boolean auth_methods: type: object git_client: type: string schedule_timezone: type: string premium_features: type: object securityDefinitions: cookie: type: apiKey name: Cookie in: header bearer: type: apiKey name: Authorization in: header security: - bearer: [] - cookie: [] parameters: project_id: name: project_id description: Project ID in: path type: integer required: true x-example: 1 user_id: name: user_id description: User ID in: path type: integer required: true x-example: 2 key_id: name: key_id description: key ID in: path type: integer required: true x-example: 3 repository_id: name: repository_id description: repository ID in: path type: integer required: true x-example: 4 inventory_id: name: inventory_id description: inventory ID in: path type: integer required: true x-example: 5 environment_id: name: environment_id description: environment ID in: path type: integer required: true x-example: 6 template_id: name: template_id description: template ID in: path type: integer required: true x-example: 7 task_id: name: task_id description: task ID in: path type: integer required: true x-example: 8 schedule_id: name: schedule_id description: schedule ID in: path type: integer required: true x-example: 9 view_id: name: view_id description: view ID in: path type: integer required: true x-example: 10 integration_id: name: integration_id description: integration ID in: path type: integer required: true x-example: 11 extractvalue_id: name: extractvalue_id description: extractValue ID in: path type: integer required: true x-example: 12 matcher_id: name: matcher_id description: matcher ID in: path type: integer required: true x-example: 13 alias_id: name: alias_id description: Integration Alias ID in: path type: integer required: true x-example: 15 invite_id: name: invite_id description: Invite ID in: path type: integer required: true x-example: 14 paths: /debug/gc: post: summary: Garbage collector description: Run the garbage collector responses: 204: description: Successful "OK" reply /ping: get: summary: PING test produces: - text/plain security: [] # No security responses: 200: description: Successful "PONG" reply schema: $ref: "#/definitions/Pong" headers: content-type: type: string x-example: text/plain; charset=utf-8 /ws: get: summary: Websocket handler schemes: - ws - wss responses: 200: description: OK 401: description: not authenticated /info: get: summary: Fetches information about semaphore description: you must be authenticated to use this responses: 200: description: ok schema: $ref: "#/definitions/InfoType" # Authentication /auth/login: get: tags: - authentication summary: Fetches login metadata description: Fetches metadata for login, such as available OIDC providers security: [] responses: 200: description: Login metadata schema: $ref: "#/definitions/LoginMetadata" post: tags: - authentication summary: Performs Login description: Upon success you will be logged in security: [] # No security parameters: - name: Login Body in: body required: true schema: $ref: '#/definitions/Login' responses: 204: description: You are logged in 400: description: something in body is missing / is invalid /auth/logout: post: tags: - authentication summary: Destroys current session responses: 204: description: Your session was successfully nuked /auth/oidc/{provider_id}/login: parameters: - name: provider_id in: path type: string required: true x-example: "mysso" get: tags: - authentication summary: Begin OIDC authentication flow and redirect to OIDC provider description: The user agent is redirected to this endpoint when chosing to sign in via OIDC responses: 302: description: Redirection to the OIDC provider on success, or to the login page on error /auth/oidc/{provider_id}/redirect: parameters: - name: provider_id in: path type: string required: true x-example: "mysso" get: tags: - authentication summary: Finish OIDC authentication flow, upon succes you will be logged in description: The user agent is redirected here by the OIDC provider to complete authentication responses: 302: description: Redirection to the Semaphore root URL on success, or to the login page on error # User Tokens /user/: get: tags: - user summary: Fetch logged in user responses: 200: description: User schema: $ref: "#/definitions/User" /user/tokens: get: tags: - authentication - user summary: Fetch API tokens for user responses: 200: description: API Tokens schema: type: array items: $ref: "#/definitions/APIToken" post: tags: - authentication - user summary: Create an API token responses: 201: description: API Token schema: $ref: "#/definitions/APIToken" /user/tokens/{api_token_id}: parameters: - name: api_token_id in: path type: string required: true x-example: "kwofd61g93-yuqvex8efmhjkgnbxlo8mp1tin6spyhu=" delete: tags: - authentication - user summary: Expires API token responses: 204: description: Expired API Token # User Profiles /users: get: tags: - user summary: Fetches all users responses: 200: description: Users schema: type: array items: $ref: "#/definitions/User" post: tags: - user summary: Creates a user consumes: - application/json parameters: - name: User in: body required: true schema: $ref: "#/definitions/UserRequest" responses: 400: description: User creation failed 201: description: User created schema: $ref: "#/definitions/User" /users/{user_id}/: parameters: - $ref: "#/parameters/user_id" get: tags: - user summary: Fetches a user profile responses: 200: description: User profile schema: $ref: "#/definitions/User" put: tags: - user summary: Updates user details consumes: - application/json parameters: - name: User in: body required: true schema: $ref: "#/definitions/UserPutRequest" responses: 204: description: User Updated delete: tags: - user summary: Deletes user responses: 204: description: User deleted /users/{user_id}/password: parameters: - $ref: "#/parameters/user_id" post: tags: - user summary: Updates user password consumes: - application/json parameters: - name: Password in: body required: true schema: type: object properties: password: type: string format: password responses: 204: description: Password updated # Projects /projects: get: tags: - project summary: Get projects responses: 200: description: List of projects schema: type: array items: $ref: "#/definitions/Project" post: tags: - project summary: Create a new project consumes: - application/json parameters: - name: Project in: body required: true schema: $ref: '#/definitions/ProjectRequest' responses: 201: description: Created project schema: $ref: "#/definitions/Project" /projects/restore: post: tags: - project summary: Restore Project consumes: - application/json parameters: - name: Backup in: body required: true schema: $ref: '#/definitions/ProjectBackup' responses: 200: description: Created project schema: $ref: "#/definitions/Project" /events: get: summary: Get Events related to Semaphore and projects you are part of responses: 200: description: Array of events in chronological order schema: type: array items: $ref: '#/definitions/Event' /events/last: get: summary: Get last 200 Events related to Semaphore and projects you are part of responses: 200: description: Array of events in chronological order schema: type: array items: $ref: '#/definitions/Event' /project/{project_id}/: parameters: - $ref: "#/parameters/project_id" get: tags: - project summary: Fetch project responses: 200: description: Project schema: $ref: "#/definitions/Project" put: tags: - project summary: Update project parameters: - name: Project in: body required: true schema: allOf: - $ref: '#/definitions/ProjectRequest' - properties: id: type: integer minimum: 1 responses: 204: description: Project saved delete: tags: - project summary: Delete project responses: 204: description: Project deleted /project/{project_id}/backup: parameters: - $ref: "#/parameters/project_id" get: tags: - project summary: Backup A Project responses: 200: description: Backup schema: $ref: '#/definitions/ProjectBackup' /project/{project_id}/role: parameters: - $ref: "#/parameters/project_id" get: tags: - project summary: Fetch permissions of the current user for project responses: 200: description: Permissions schema: type: object properties: role: type: string example: owner permissions: type: number example: 0 /project/{project_id}/events: parameters: - $ref: '#/parameters/project_id' get: tags: - project summary: Get Events related to this project responses: 200: description: Array of events in chronological order schema: type: array items: $ref: '#/definitions/Event' # User management /project/{project_id}/users: parameters: - $ref: "#/parameters/project_id" get: tags: - project summary: Get users linked to project parameters: - name: sort in: query required: true type: string enum: [name, username, email, role] description: sorting name x-example: email - name: order in: query required: true type: string enum: [asc, desc] description: ordering manner x-example: desc responses: 200: description: Users schema: type: array items: $ref: "#/definitions/ProjectUser" post: tags: - project summary: Link user to project parameters: - name: User in: body required: true schema: type: object properties: user_id: type: integer minimum: 2 role: type: string enum: [owner, manager, task_runner, guest] example: owner responses: 204: description: User added /project/{project_id}/users/{user_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/user_id" delete: tags: - project summary: Removes user from project responses: 204: description: User removed put: parameters: - name: Project User in: body required: true schema: type: object properties: role: type: string enum: [owner, manager, task_runner, guest] example: owner summary: Update user role tags: - project responses: 204: description: User updated # Invite management # /project/{project_id}/invites: # parameters: # - $ref: "#/parameters/project_id" # get: # tags: # - project # summary: Get invitations for project # parameters: # - name: sort # in: query # required: false # type: string # enum: [created, status, role] # description: sorting field # x-example: created # - name: order # in: query # required: false # type: string # enum: [asc, desc] # description: ordering manner # x-example: desc # responses: # 200: # description: Project invitations # schema: # type: array # items: # $ref: "#/definitions/ProjectInvite" # post: # tags: # - project # summary: Create project invitation # parameters: # - name: Invite # in: body # required: true # schema: # $ref: "#/definitions/ProjectInviteRequest" # responses: # 201: # description: Invitation created # schema: # $ref: "#/definitions/ProjectInvite" # 400: # description: Bad request (invalid role, missing user_id/email, or both provided) # 409: # description: User already a member or invitation already exists # # /project/{project_id}/invites/{invite_id}: # parameters: # - $ref: "#/parameters/project_id" # - $ref: "#/parameters/invite_id" # get: # tags: # - project # summary: Get specific project invitation # responses: # 200: # description: Project invitation # schema: # $ref: "#/definitions/ProjectInvite" # 404: # description: Invitation not found # put: # tags: # - project # summary: Update project invitation status # parameters: # - name: Invite Update # in: body # required: true # schema: # type: object # properties: # status: # type: string # enum: [pending, declined, expired] # example: declined # responses: # 204: # description: Invitation updated # 400: # description: Invalid status or status transition # delete: # tags: # - project # summary: Delete project invitation # responses: # 204: # description: Invitation deleted # # /invites/accept: # post: # tags: # - project # summary: Accept project invitation # parameters: # - name: Accept Invite # in: body # required: true # schema: # $ref: "#/definitions/AcceptInviteRequest" # responses: # 204: # description: Invitation accepted successfully # 400: # description: Invalid token, invitation expired, or user already a member # 403: # description: Invitation not for this user # 404: # description: Invitation not found /project/{project_id}/integrations: parameters: - $ref: "#/parameters/project_id" get: tags: - integration summary: get all integrations responses: 200: description: integration schema: type: array items: $ref: "#/definitions/Integration" post: summary: create a new integration tags: - integration parameters: - name: Integration in: body required: true schema: $ref: "#/definitions/IntegrationRequest" responses: 201: description: Integration Created schema: $ref: "#/definitions/Integration" /project/{project_id}/integrations/{integration_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/integration_id" get: tags: - integration summary: Get Integration responses: 200: description: Integration Value schema: $ref: "#/definitions/Integration" put: tags: - integration summary: Update Integration parameters: - name: Integration in: body required: true schema: $ref: "#/definitions/IntegrationRequest" responses: 204: description: Integration updated delete: tags: - integration summary: Remove integration responses: 204: description: integration removed /project/{project_id}/integrations/{integration_id}/values: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/integration_id" get: tags: - integration summary: Get Integration Extracted Values linked to integration extractor responses: 200: description: Integration Extracted Value schema: type: array items: $ref: "#/definitions/IntegrationExtractValue" post: tags: - integration summary: Add Integration Extracted Value parameters: - name: Integration Extracted Value in: body required: true schema: $ref: "#/definitions/IntegrationExtractValue" responses: 201: description: Integration Extract Value Created 400: description: Bad Integration Extract Value params /project/{project_id}/integrations/{integration_id}/values/{extractvalue_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/integration_id" - $ref: "#/parameters/extractvalue_id" put: tags: - integration summary: Updates Integration ExtractValue parameters: - name: Integration ExtractValue in: body required: true schema: $ref: "#/definitions/IntegrationExtractValueRequest" responses: 204: description: Integration Extract Value updated 400: description: Bad integration extract value parameter delete: tags: - integration summary: Removes integration extract value responses: 204: description: integration extract value removed /project/{project_id}/integrations/{integration_id}/matchers: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/integration_id" get: tags: - integration summary: Get Integration Matcher linked to integration extractor responses: 200: description: Integration Matcher schema: type: array items: $ref: "#/definitions/IntegrationMatcher" post: tags: - integration summary: Add Integration Matcher parameters: - name: Integration Matcher in: body required: true schema: $ref: "#/definitions/IntegrationMatcher" responses: 200: description: Integration Matcher Created 400: description: Bad Integration Matcher params /project/{project_id}/integrations/{integration_id}/matchers/{matcher_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/integration_id" - $ref: "#/parameters/matcher_id" put: tags: - integration summary: Updates Integration Matcher parameters: - name: Integration Matcher in: body required: true schema: $ref: "#/definitions/IntegrationMatcherRequest" responses: 204: description: Integration Matcher updated 400: description: Bad integration matcher parameter delete: tags: - integration summary: Removes integration matcher responses: 204: description: integration matcher removed /project/{project_id}/integrations/aliases: parameters: - $ref: "#/parameters/project_id" get: tags: - integration summary: Get all integration aliases for the project responses: 200: description: Integration Aliases schema: type: array items: $ref: "#/definitions/IntegrationAlias" post: tags: - integration summary: Create a new integration alias for the project responses: 200: description: Integration Alias Created schema: $ref: "#/definitions/IntegrationAlias" /project/{project_id}/integrations/aliases/{alias_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/alias_id" delete: tags: - integration summary: Remove integration alias responses: 204: description: integration alias removed /project/{project_id}/integrations/{integration_id}/aliases: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/integration_id" get: tags: - integration summary: Get all aliases for an integration responses: 200: description: Integration Aliases schema: type: array items: $ref: "#/definitions/IntegrationAlias" post: tags: - integration summary: Create a new alias for an integration responses: 200: description: Integration Alias Created schema: $ref: "#/definitions/IntegrationAlias" /project/{project_id}/integrations/{integration_id}/aliases/{alias_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/integration_id" - $ref: "#/parameters/alias_id" delete: tags: - integration summary: Remove integration alias responses: 204: description: integration alias removed # project access keys /project/{project_id}/keys: parameters: - $ref: "#/parameters/project_id" get: tags: - key-store summary: Get access keys linked to project parameters: # TODO - the space in this parameter name results in a dredd warning - name: Key type in: query required: false type: string enum: [none, ssh, login_password] description: Filter by key type x-example: none - name: sort in: query required: true type: string enum: [name, type] description: sorting name x-example: type - name: order in: query required: true type: string enum: [asc, desc] description: ordering manner x-example: asc responses: 200: description: Access Keys schema: type: array items: $ref: "#/definitions/AccessKey" post: tags: - key-store summary: Add access key parameters: - name: Access Key in: body required: true schema: $ref: "#/definitions/AccessKeyRequest" responses: 201: description: Access Key created schema: $ref: "#/definitions/AccessKey" 400: description: Bad type /project/{project_id}/keys/{key_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/key_id" put: tags: - key-store summary: Updates access key parameters: - name: Access Key in: body required: true schema: $ref: "#/definitions/AccessKeyRequest" responses: 204: description: Key updated 400: description: Bad type delete: tags: - key-store summary: Removes access key responses: 204: description: access key removed # project repositories /project/{project_id}/repositories: parameters: - $ref: "#/parameters/project_id" get: tags: - repository summary: Get repositories parameters: - name: sort in: query required: true type: string enum: [name, git_url, ssh_key] description: sorting name - name: order in: query required: true type: string format: asc/desc enum: [asc, desc] description: ordering manner responses: 200: description: repositories schema: type: array items: $ref: "#/definitions/Repository" post: tags: - repository summary: Add repository parameters: - name: Repository in: body required: true schema: $ref: "#/definitions/RepositoryRequest" responses: 201: description: Repository created schema: $ref: "#/definitions/Repository" /project/{project_id}/repositories/{repository_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/repository_id" get: tags: - repository summary: Get repository responses: 200: description: repository object schema: $ref: "#/definitions/Repository" put: tags: - repository summary: Updates repository parameters: - name: Repository in: body required: true schema: $ref: "#/definitions/RepositoryRequest" responses: 204: description: Repository updated 400: description: Bad request delete: tags: - repository summary: Removes repository responses: 204: description: repository removed # project inventory /project/{project_id}/inventory: parameters: - $ref: "#/parameters/project_id" get: tags: - inventory summary: Get inventory parameters: - name: sort in: query required: true type: string description: sorting name enum: [name, type] - name: order in: query required: true type: string description: ordering manner enum: [asc, desc] responses: 200: description: inventory schema: type: array items: $ref: "#/definitions/Inventory" post: tags: - inventory summary: create inventory parameters: - name: Inventory in: body required: true schema: $ref: "#/definitions/InventoryRequest" responses: 201: description: inventory created schema: $ref: "#/definitions/Inventory" /project/{project_id}/inventory/{inventory_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/inventory_id" get: tags: - inventory summary: Get inventory responses: 200: description: inventory object schema: $ref: "#/definitions/Inventory" put: tags: - inventory summary: Updates inventory parameters: - name: Inventory in: body required: true schema: $ref: "#/definitions/InventoryRequest" responses: 204: description: Inventory updated delete: tags: - inventory summary: Removes inventory responses: 204: description: inventory removed # project environment /project/{project_id}/environment: parameters: - $ref: "#/parameters/project_id" get: tags: - variable-group summary: Get environment parameters: - name: sort in: query required: true type: string format: name description: sorting name x-example: name - name: order in: query required: true type: string format: asc/desc description: ordering manner x-example: desc responses: 200: description: environment schema: type: array items: $ref: "#/definitions/Environment" post: tags: - variable-group summary: Add environment parameters: - name: environment in: body required: true schema: $ref: "#/definitions/EnvironmentRequest" responses: 201: description: Environment created schema: $ref: "#/definitions/Environment" /project/{project_id}/environment/{environment_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/environment_id" get: tags: - variable-group summary: Get environment responses: 200: description: environment object schema: $ref: "#/definitions/Environment" put: tags: - variable-group summary: Update environment parameters: - name: environment in: body required: true schema: $ref: "#/definitions/EnvironmentRequest" responses: 204: description: Environment Updated delete: tags: - variable-group summary: Removes environment responses: 204: description: environment removed # project templates /project/{project_id}/templates: parameters: - $ref: "#/parameters/project_id" get: tags: - template summary: Get template parameters: - name: sort in: query required: true type: string description: sorting name enum: [name, playbook, ssh_key, inventory, environment, repository] - name: order in: query required: true type: string description: ordering manner enum: [asc, desc] responses: 200: description: template schema: type: array items: $ref: "#/definitions/Template" properties: survey_vars: type: array items: $ref: "#/definitions/TemplateSurveyVar" last_task: $ref: "#/definitions/Task" post: tags: - template summary: create template parameters: - name: template in: body required: true schema: $ref: "#/definitions/TemplateRequest" responses: 201: description: template created schema: $ref: "#/definitions/Template" /project/{project_id}/templates/{template_id}/stop_all_tasks: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/template_id" post: tags: - template summary: Stop all active tasks of template parameters: - name: body in: body required: false schema: type: object properties: force: type: boolean description: Force stop (kill) all tasks immediately responses: 204: description: tasks stopped /project/{project_id}/templates/{template_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/template_id" get: tags: - template summary: Get template responses: 200: description: template object schema: $ref: "#/definitions/Template" put: tags: - template summary: Updates template parameters: - name: template in: body required: true schema: $ref: "#/definitions/TemplateRequest" responses: 204: description: template updated delete: tags: - template summary: Removes template responses: 204: description: template removed # project schedules /project/{project_id}/schedules/{schedule_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/schedule_id" get: tags: - schedule summary: Get schedule responses: 200: description: Schedule schema: $ref: "#/definitions/Schedule" delete: tags: - schedule summary: Deletes schedule responses: 204: description: schedule deleted put: tags: - schedule summary: Updates schedule parameters: - name: schedule in: body required: true schema: $ref: "#/definitions/ScheduleRequest" responses: 204: description: schedule updated /project/{project_id}/schedules: parameters: - $ref: "#/parameters/project_id" post: tags: - schedule summary: create schedule parameters: - name: schedule in: body required: true schema: $ref: "#/definitions/ScheduleRequest" responses: 201: description: schedule created schema: $ref: "#/definitions/Schedule" # project views /project/{project_id}/views: parameters: - $ref: "#/parameters/project_id" get: tags: - project summary: Get view responses: 200: description: view schema: type: array items: $ref: "#/definitions/View" post: tags: - project summary: create view parameters: - name: view in: body required: true schema: $ref: "#/definitions/ViewRequest" responses: 201: description: view created schema: $ref: "#/definitions/View" /project/{project_id}/views/{view_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/view_id" get: tags: - project summary: Get view responses: 200: description: view object schema: $ref: "#/definitions/View" put: tags: - project summary: Updates view parameters: - name: view in: body required: true schema: $ref: "#/definitions/ViewRequest" responses: 204: description: view updated delete: tags: - project summary: Removes view responses: 204: description: view removed # tasks /project/{project_id}/tasks: parameters: - $ref: "#/parameters/project_id" get: tags: - task summary: Get Tasks related to current project responses: 200: description: Array of tasks in chronological order schema: type: array items: $ref: '#/definitions/Task' post: tags: - task summary: Starts a job parameters: - name: task in: body required: true schema: type: object properties: template_id: type: integer debug: type: boolean dry_run: type: boolean diff: type: boolean playbook: type: string environment: type: string limit: type: string git_branch: type: string message: type: string arguments: type: string inventory_id: type: integer responses: 201: description: Task queued schema: $ref: "#/definitions/Task" /project/{project_id}/tasks/last: parameters: - $ref: "#/parameters/project_id" get: tags: - task summary: Get last 200 Tasks related to current project responses: 200: description: Array of tasks in chronological order schema: type: array items: $ref: '#/definitions/Task' /project/{project_id}/tasks/{task_id}/stop: parameters: - $ref: "#/parameters/project_id" - $ref: '#/parameters/task_id' post: tags: - task summary: Stop a job parameters: - name: body in: body required: false schema: type: object properties: force: type: boolean description: Force stop (kill) the task immediately responses: 204: description: Task queued /project/{project_id}/tasks/{task_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/task_id" get: tags: - task summary: Get a single task responses: 200: description: Task schema: $ref: "#/definitions/Task" delete: tags: - task summary: Deletes task (including output) responses: 204: description: task deleted /project/{project_id}/tasks/{task_id}/output: parameters: - $ref: '#/parameters/project_id' - $ref: '#/parameters/task_id' get: tags: - task summary: Get task output responses: 200: description: output schema: type: array items: $ref: "#/definitions/TaskOutput" /project/{project_id}/tasks/{task_id}/raw_output: parameters: - $ref: '#/parameters/project_id' - $ref: '#/parameters/task_id' get: tags: - task summary: Get task raw output responses: 200: description: output headers: content-type: type: string x-example: text/plain; charset=utf-8 /apps: get: summary: Get apps responses: 200: description: Apps schema: type: array items: $ref: "#/definitions/App" /project/{project_id}/notifications/test: post: tags: - project summary: Send test notification description: Sends a test notification to all enabled messengers for the project parameters: - $ref: "#/parameters/project_id" responses: 409: description: Alerts not enabled for the project # 204: # description: Test notification dispatched (or alerts disabled) ================================================ FILE: cli/cmd/migrate.go ================================================ package cmd import ( "errors" "fmt" "os" "github.com/semaphoreui/semaphore/db/bolt" "github.com/semaphoreui/semaphore/db/factory" "github.com/semaphoreui/semaphore/db/migration" "github.com/semaphoreui/semaphore/util" "github.com/spf13/cobra" ) var migrationArgs struct { undoTo string applyTo string fromBoltDb string errLogSize int skipTaskOutput bool mergeExistingUsers bool } func init() { migrateCmd.PersistentFlags().StringVar(&migrationArgs.undoTo, "undo-to", "", "Undo to specific version") migrateCmd.PersistentFlags().StringVar(&migrationArgs.applyTo, "apply-to", "", "Apply to specific version") migrateCmd.PersistentFlags().StringVar(&migrationArgs.fromBoltDb, "from-boltdb", "", "Path to boltDB data file") migrateCmd.PersistentFlags().IntVar(&migrationArgs.errLogSize, "err-log-size", 0, "Error log size") migrateCmd.PersistentFlags().BoolVar(&migrationArgs.skipTaskOutput, "skip-task-output", false, "Skip task output importing during migration") migrateCmd.PersistentFlags().BoolVar(&migrationArgs.mergeExistingUsers, "merge-existing-users", false, "Reuse existing users matched by username instead of failing on conflict") rootCmd.AddCommand(migrateCmd) } var migrateCmd = &cobra.Command{ Use: "migrate", Short: "Execute migrations", Run: func(cmd *cobra.Command, args []string) { if migrationArgs.undoTo != "" && migrationArgs.applyTo != "" { panic("Cannot specify both --undo-to and --apply-to") } var undoTo, applyTo *string if migrationArgs.undoTo != "" { undoTo = &migrationArgs.undoTo } if migrationArgs.applyTo != "" { applyTo = &migrationArgs.applyTo } store := createStoreWithMigrationVersion("migrate", undoTo, applyTo) defer store.Close("migrate") util.Config.PrintDbInfo() if migrationArgs.fromBoltDb != "" { migrateBoltDb(migrationArgs.fromBoltDb) } }, } func migrateBoltDb(boltDbPath string) { boltCfg := util.DbConfig{ Dialect: util.DbDriverBolt, Hostname: boltDbPath, } if boltCfg.Dialect != util.DbDriverBolt { fmt.Printf("Error: Source database must be BoltDB (dialect: %s)\n", boltCfg.Dialect) return } file, err := os.Stat(boltDbPath) if err != nil { if errors.Is(err, os.ErrNotExist) { fmt.Println("File does not exist") } else { fmt.Printf("Error: %v\n", err) } return } if file.Size() > 1024*1024*1024 { fmt.Println("File is too big ", file.Size()) } boltStore := bolt.CreateBoltDB() boltStore.Filename = boltDbPath boltStore.Connect("migrate") defer boltStore.Close("migrate") util.ConfigInit(persistentFlags.configPath, persistentFlags.noConfig) dialect, err := util.Config.GetDialect() if err != nil { fmt.Printf("Error reading SQL DB config: %v\n", err) return } if dialect == util.DbDriverBolt { fmt.Println("Error: Destination database must be a SQL database") return } sqlStore := factory.CreateStore() sqlStore.Connect("import") // 3. Connect and migrate fmt.Println("Starting migration...") migrator := &migration.Migrator{ OldStore: boltStore, NewStore: sqlStore, ErrLogSize: migrationArgs.errLogSize, SkipTaskOutput: migrationArgs.skipTaskOutput, MergeExistingUsers: migrationArgs.mergeExistingUsers, } err = migrator.Migrate() if err != nil { fmt.Printf("Migration failed: %v\n", err) return } defer sqlStore.Close("import") fmt.Println("Migration finished successfully.") } ================================================ FILE: cli/cmd/project.go ================================================ package cmd import ( "os" "github.com/spf13/cobra" ) func init() { rootCmd.AddCommand(projectCmd) } var projectCmd = &cobra.Command{ Use: "projects", Aliases: []string{"project"}, Short: "Manage projects", Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() os.Exit(0) }, } ================================================ FILE: cli/cmd/project_export.go ================================================ package cmd import ( "fmt" "os" "strings" projectService "github.com/semaphoreui/semaphore/services/project" "github.com/spf13/cobra" ) type projectExportArgs struct { projectID int projectName string file string } var targetProjectExportArgs projectExportArgs func init() { projectExportCmd.PersistentFlags().IntVar(&targetProjectExportArgs.projectID, "project-id", 0, "Project ID to export") projectExportCmd.PersistentFlags().StringVar(&targetProjectExportArgs.projectName, "project-name", "", "Project name to export") projectExportCmd.PersistentFlags().StringVar(&targetProjectExportArgs.file, "file", "", "Output file path (default: stdout)") projectCmd.AddCommand(projectExportCmd) } var projectExportCmd = &cobra.Command{ Use: "export", Short: "Export project backup", Run: func(cmd *cobra.Command, args []string) { ok := true if targetProjectExportArgs.projectID == 0 && targetProjectExportArgs.projectName == "" { fmt.Println("Argument --project-id or --project-name required") ok = false } if targetProjectExportArgs.projectID != 0 && targetProjectExportArgs.projectName != "" { fmt.Println("Only one of --project-id or --project-name can be specified") ok = false } if !ok { fmt.Println("Use command `semaphore project export --help` for details.") os.Exit(1) } store := createStore("") defer store.Close("") projectID := targetProjectExportArgs.projectID if targetProjectExportArgs.projectName != "" { projects, err := store.GetAllProjects() if err != nil { fmt.Printf("Failed to get projects: %v\n", err) os.Exit(1) } found := false searchName := strings.ToLower(targetProjectExportArgs.projectName) for _, p := range projects { if strings.ToLower(p.Name) == searchName { projectID = p.ID found = true break } } if !found { fmt.Printf("Project with name '%s' not found\n", targetProjectExportArgs.projectName) os.Exit(1) } } backup, err := projectService.GetBackup(projectID, store) if err != nil { fmt.Printf("Failed to create backup: %v\n", err) os.Exit(1) } data, err := backup.Marshal() if err != nil { fmt.Printf("Failed to marshal backup: %v\n", err) os.Exit(1) } if targetProjectExportArgs.file == "" { fmt.Println(data) } else { if err := os.WriteFile(targetProjectExportArgs.file, []byte(data), 0644); err != nil { fmt.Printf("Failed to write file: %v\n", err) os.Exit(1) } fmt.Printf("Project exported to %s\n", targetProjectExportArgs.file) } }, } ================================================ FILE: cli/cmd/project_import.go ================================================ package cmd import ( "errors" "fmt" "io/fs" "os" "path/filepath" "sort" "strings" "github.com/semaphoreui/semaphore/db" projectService "github.com/semaphoreui/semaphore/services/project" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) type projectImportArgs struct { dir string file string projectName string } var targetProjectImportArgs projectImportArgs func init() { projectImportCmd.PersistentFlags().StringVar(&targetProjectImportArgs.dir, "dir", "", "Directory path with project backups to import") projectImportCmd.PersistentFlags().StringVar(&targetProjectImportArgs.file, "file", "", "Backup file path to import") projectImportCmd.PersistentFlags().StringVar(&targetProjectImportArgs.projectName, "project-name", "", "Override project name (only valid with --file)") projectCmd.AddCommand(projectImportCmd) } var projectImportCmd = &cobra.Command{ Use: "import", Short: "Import project(s)", Run: func(cmd *cobra.Command, args []string) { ok := true if targetProjectImportArgs.dir == "" && targetProjectImportArgs.file == "" { fmt.Println("Argument --dir or --file required") ok = false } if targetProjectImportArgs.dir != "" && targetProjectImportArgs.file != "" { fmt.Println("Only one of --dir or --file can be specified") ok = false } if targetProjectImportArgs.projectName != "" && targetProjectImportArgs.dir != "" { fmt.Println("Option --project-name can only be used with --file, not --dir") ok = false } if !ok { fmt.Println("Use command `semaphore project import --help` for details.") os.Exit(1) } store := createStore("") defer store.Close("") user, err := resolveImportUser(store) if err != nil { log.Errorf("cannot resolve user for import: %v", err) os.Exit(1) } files := make([]string, 0) if targetProjectImportArgs.file != "" { files = append(files, targetProjectImportArgs.file) } if targetProjectImportArgs.dir != "" { dir := targetProjectImportArgs.dir err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } if d.IsDir() { return nil } // include likely backup files lower := strings.ToLower(d.Name()) if strings.HasSuffix(lower, ".json") || strings.HasSuffix(lower, ".backup") || strings.HasSuffix(lower, ".bk") { files = append(files, path) } return nil }) if err != nil { return } } if len(files) == 0 { fmt.Println("No backup files found to import") os.Exit(1) } // sort for deterministic order sort.Strings(files) okCount := 0 for _, f := range files { if err := importProjectFromFile(f, targetProjectImportArgs.projectName, user, store); err != nil { log.Errorf("failed to import %s: %v", f, err) continue } fmt.Printf("Imported project from %s\n", f) okCount++ } if okCount == 0 { os.Exit(1) } fmt.Printf("Project(s) imported: %d/%d\n", okCount, len(files)) }, } func resolveImportUser(store db.Store) (res db.User, err error) { admins, err := store.GetAllAdmins() if err != nil { return } if len(admins) > 0 { res = admins[0] return } users, err := store.GetUsers(db.RetrieveQueryParams{}) if err != nil { return } if len(users) == 0 { err = errors.New("no admins found in database; create a admin first") return } res = users[0] return } func importProjectFromFile(path string, projectName string, user db.User, store db.Store) error { data, err := os.ReadFile(path) if err != nil { return err } var backup projectService.BackupFormat if err := backup.Unmarshal(string(data)); err != nil { return err } if err := backup.Verify(); err != nil { return err } if projectName != "" { backup.Meta.Name = projectName } _, err = backup.Restore(user, store) return err } ================================================ FILE: cli/cmd/root.go ================================================ package cmd import ( "fmt" "net/http" "net/url" "os" "strings" "github.com/gorilla/handlers" "github.com/semaphoreui/semaphore/api" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/api/sockets" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/db/factory" proFactory "github.com/semaphoreui/semaphore/pro/db/factory" proHA "github.com/semaphoreui/semaphore/pro/services/ha" proServer "github.com/semaphoreui/semaphore/pro/services/server" proTasks "github.com/semaphoreui/semaphore/pro/services/tasks" "github.com/semaphoreui/semaphore/services/schedules" "github.com/semaphoreui/semaphore/services/server" "github.com/semaphoreui/semaphore/services/tasks" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) var persistentFlags struct { configPath string noConfig bool logLevel string } var rootCmd = &cobra.Command{ Use: "semaphore", 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/semaphoreui/semaphore. Complete documentation is available at https://semaphoreui.com.`, Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() os.Exit(0) }, PersistentPreRun: func(cmd *cobra.Command, args []string) { str := persistentFlags.logLevel if str == "" { str = os.Getenv("SEMAPHORE_LOG_LEVEL") } if str == "" { return } lvl, err := log.ParseLevel(str) if err != nil { log.Panic(err) } fmt.Println("Log level set to", lvl) log.SetLevel(lvl) }, } func Execute() { rootCmd.PersistentFlags().StringVar(&persistentFlags.logLevel, "log-level", "", "Log level: DEBUG, INFO, WARN, ERROR, FATAL, PANIC") rootCmd.PersistentFlags().StringVar(&persistentFlags.configPath, "config", "", "Configuration file path") rootCmd.PersistentFlags().BoolVar(&persistentFlags.noConfig, "no-config", false, "Don't use configuration file") if err := rootCmd.Execute(); err != nil { _, _ = fmt.Fprintln(os.Stderr, err) os.Exit(1) } } func runService() { store := createStore("root") initSyslog(util.Config.Syslog) // Initialize HA node identity before any component that uses it. util.InitHANodeID() state := proTasks.NewTaskStateStore() terraformStore := proFactory.NewTerraformStore(store) ansibleTaskRepo := proFactory.NewAnsibleTaskRepository(store) projectService := server.NewProjectService(store, store) encryptionService := server.NewAccessKeyEncryptionService(store, store, store) accessKeyInstallationService := server.NewAccessKeyInstallationService(encryptionService) integrationService := server.NewIntegrationService(store, encryptionService) inventoryService := server.NewInventoryService( store, store, store, encryptionService, ) accessKeyService := server.NewAccessKeyService(store, encryptionService, store) secretStorageService := server.NewSecretStorageService(store, store, accessKeyService, encryptionService) environmentService := server.NewEnvironmentService(store, encryptionService) subscriptionService := proServer.NewSubscriptionService(store, store, store, terraformStore) logWriteService := proServer.NewLogWriteService() taskPool := tasks.CreateTaskPool( store, state, ansibleTaskRepo, inventoryService, encryptionService, accessKeyInstallationService, logWriteService, ) schedulePool := schedules.CreateSchedulePool( store, &taskPool, accessKeyInstallationService, encryptionService, ) defer schedulePool.Destroy() // --- Active-Active HA Setup --- // When HA is enabled, multiple Semaphore nodes share the same Redis-backed // task state and coordinate via Pub/Sub. The following components ensure: // 1. Node registry: heartbeat-based cluster membership // 2. Schedule deduplication: only one node fires each schedule occurrence // 3. WebSocket broadcaster: real-time events reach clients on all nodes // 4. Orphan cleaner: tasks from dead nodes are marked as failed if nodeRegistry := proHA.NewNodeRegistry(); nodeRegistry != nil { if err := nodeRegistry.Start(); err != nil { log.WithError(err).Fatal("failed to start HA node registry") } defer nodeRegistry.Stop() log.WithField("node_id", nodeRegistry.NodeID()).Info("HA active-active mode enabled") } if dedup := proHA.NewScheduleDeduplicator(); dedup != nil { schedulePool.SetDeduplicator(dedup) } if orphanCleaner := proHA.NewOrphanCleaner(store); orphanCleaner != nil { orphanCleaner.Start() defer orphanCleaner.Stop() } util.Config.PrintDbInfo() port := util.Config.Port if !strings.HasPrefix(port, ":") { port = ":" + port } fmt.Printf("Tmp Path (projects home) %v\n", util.Config.TmpPath) fmt.Printf("Semaphore %v\n", util.Version()) fmt.Printf("Interface %v\n", util.Config.Interface) fmt.Printf("Port %v\n", util.Config.Port) subscriptionService.StartValidationCron() // Start the WebSocket hub before the broadcaster so that h.broadcast // channel is being consumed when LocalBroadcast is called. go sockets.StartWS() if wsBroadcaster := proHA.NewWSBroadcaster(); wsBroadcaster != nil { sockets.SetBroadcaster(wsBroadcaster) wsBroadcaster.Start() defer wsBroadcaster.Stop() } go schedulePool.Run() go taskPool.Run() route := api.Route( store, terraformStore, ansibleTaskRepo, &taskPool, projectService, integrationService, encryptionService, accessKeyInstallationService, secretStorageService, accessKeyService, environmentService, subscriptionService, ) route.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { r = helpers.SetContextValue(r, "store", store) r = helpers.SetContextValue(r, "schedule_pool", schedulePool) r = helpers.SetContextValue(r, "task_pool", &taskPool) r = helpers.SetContextValue(r, "log_writer", logWriteService) next.ServeHTTP(w, r) }) }) var router http.Handler = route router = handlers.ProxyHeaders(router) http.Handle("/", router) fmt.Println("Server is running") if store.PermanentConnection() { defer store.Close("root") } else { store.Close("root") } var err error if util.Config.TLS.Enabled { if util.Config.TLS.HTTPRedirectPort != nil { go func() { httpRedirectPort := fmt.Sprintf(":%d", *util.Config.TLS.HTTPRedirectPort) err = http.ListenAndServe(httpRedirectPort, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { target := "https://" if util.Config.WebHost != "" { webHost, err2 := url.Parse(util.Config.WebHost) if err2 != nil { log.Panic(err2) } target += webHost.Host + r.URL.Path } else { hostParts := strings.Split(r.Host, ":") host := hostParts[0] target += host + port + r.URL.Path } if len(r.URL.RawQuery) > 0 { target += "?" + r.URL.RawQuery } if r.Method != "GET" && r.Method != "HEAD" && r.Method != "OPTIONS" { http.Error(w, "http requests forbidden", http.StatusForbidden) return } http.Redirect(w, r, target, http.StatusTemporaryRedirect) })) if err != nil { log.Panic(err) } }() } err = http.ListenAndServeTLS(util.Config.Interface+port, util.Config.TLS.CertFile, util.Config.TLS.KeyFile, cropTrailingSlashMiddleware(router)) if err != nil { log.Panic(err) } } else { err = http.ListenAndServe(util.Config.Interface+port, cropTrailingSlashMiddleware(router)) } if err != nil { log.WithError(err).Panic("Error starting server") } } func createStoreWithMigrationVersion(token string, undoTo *string, applyTo *string) db.Store { util.ConfigInit(persistentFlags.configPath, persistentFlags.noConfig) store := factory.CreateStore() store.Connect(token) var err error if undoTo != nil { err = db.Rollback(store, *undoTo) } else { err = db.Migrate(store, applyTo) } if err != nil { panic(err) } err = db.FillConfigFromDB(store) if err != nil { panic(err) } util.LookupDefaultApps() return store } func createStore(token string) db.Store { return createStoreWithMigrationVersion(token, nil, nil) } ================================================ FILE: cli/cmd/runner.go ================================================ package cmd import ( "github.com/semaphoreui/semaphore/pkg/ssh" "github.com/semaphoreui/semaphore/services/runners" "github.com/spf13/cobra" "os" ) func createRunnerJobPool() *runners.JobPool { return runners.NewJobPool(&ssh.KeyInstaller{}) } func init() { rootCmd.AddCommand(runnerCmd) } var runnerCmd = &cobra.Command{ Use: "runner", Short: "Run in runner mode", Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() os.Exit(0) }, } ================================================ FILE: cli/cmd/runner_register.go ================================================ package cmd import ( "io" "os" "strings" "github.com/semaphoreui/semaphore/util" "github.com/spf13/cobra" ) var runnerRegisterArgs struct { stdinRegistrationToken bool } func init() { runnerRegisterCmd.PersistentFlags().BoolVar(&runnerRegisterArgs.stdinRegistrationToken, "stdin-registration-token", false, "Read registration token from stdin") runnerCmd.AddCommand(runnerRegisterCmd) } func initRunnerRegistrationToken() { if !runnerRegisterArgs.stdinRegistrationToken { return } tokenBytes, err := io.ReadAll(os.Stdin) if err != nil { panic(err) } if len(tokenBytes) == 0 { panic("Empty token") } util.Config.Runner.RegistrationToken = strings.TrimSpace(string(tokenBytes)) } func registerRunner() { configFile := util.ConfigInit(persistentFlags.configPath, persistentFlags.noConfig) initRunnerRegistrationToken() taskPool := createRunnerJobPool() err := taskPool.Register(configFile) if err != nil { panic(err) } } var runnerRegisterCmd = &cobra.Command{ Use: "register", Short: "Register runner on the server", Run: func(cmd *cobra.Command, args []string) { registerRunner() }, } ================================================ FILE: cli/cmd/runner_setup.go ================================================ package cmd import ( "fmt" "github.com/semaphoreui/semaphore/cli/setup" "github.com/semaphoreui/semaphore/util" "github.com/spf13/cobra" ) func init() { runnerCmd.AddCommand(runnerSetupCmd) } var runnerSetupCmd = &cobra.Command{ Use: "setup", Short: "Perform interactive setup", Run: func(cmd *cobra.Command, args []string) { doRunnerSetup() }, } // nolint: gocyclo func doRunnerSetup() int { config := &util.ConfigType{} setup.InteractiveRunnerSetup(config) resultConfigPath := setup.SaveConfig(config, "config.runner.json", persistentFlags.configPath) util.ConfigInit(resultConfigPath, false) if util.Config.Runner.RegistrationToken == "" && config.Runner.RegistrationToken != "" { util.Config.Runner.RegistrationToken = config.Runner.RegistrationToken } if util.Config.Runner.RegistrationToken != "" { taskPool := createRunnerJobPool() err := taskPool.Register(&resultConfigPath) if err != nil { panic(err) } } fmt.Printf(" Re-launch this program pointing to the configuration file\n\n./semaphore runner start --config %v\n\n", resultConfigPath) fmt.Printf(" To run as daemon:\n\nnohup ./semaphore runner start --config %v &\n\n", resultConfigPath) return 0 } ================================================ FILE: cli/cmd/runner_start.go ================================================ package cmd import ( "time" "github.com/semaphoreui/semaphore/util" "github.com/spf13/cobra" ) var runnerStartArgs struct { register bool } func init() { runnerStartCmd.PersistentFlags().BoolVar(&runnerStartArgs.register, "register", false, "Register new runner if not registered") runnerCmd.AddCommand(runnerStartCmd) } func runRunner() { configFile := util.ConfigInit(persistentFlags.configPath, persistentFlags.noConfig) taskPool := createRunnerJobPool() // If --register is passed, try to register the runner if not already registered if runnerStartArgs.register { initRunnerRegistrationToken() if util.Config.Runner.Token == "" { for { err := taskPool.Register(configFile) if err == nil { break } time.Sleep(5 * time.Second) } _ = util.ConfigInit(persistentFlags.configPath, persistentFlags.noConfig) } } taskPool.Run() } var runnerStartCmd = &cobra.Command{ Use: "start", Short: "Run in runner mode", Run: func(cmd *cobra.Command, args []string) { runRunner() }, } ================================================ FILE: cli/cmd/runner_unregister.go ================================================ package cmd import ( "github.com/semaphoreui/semaphore/util" "github.com/spf13/cobra" ) func init() { runnerCmd.AddCommand(runnerUnregisterCmd) } func unregisterRunner() { util.ConfigInit(persistentFlags.configPath, persistentFlags.noConfig) taskPool := createRunnerJobPool() err := taskPool.Unregister() if err != nil { panic(err) } } var runnerUnregisterCmd = &cobra.Command{ Use: "unregister", Short: "Unregister runner from the server", Run: func(cmd *cobra.Command, args []string) { unregisterRunner() }, } ================================================ FILE: cli/cmd/server.go ================================================ package cmd import ( "github.com/spf13/cobra" "net/http" "strings" ) func init() { rootCmd.AddCommand(serverCmd) } var serverCmd = &cobra.Command{ Use: "server", Short: "Run in server mode", Aliases: []string{"service"}, Run: func(cmd *cobra.Command, args []string) { runService() }, } func cropTrailingSlashMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { r.URL.Path = strings.TrimSuffix(r.URL.Path, "/") } next.ServeHTTP(w, r) }) } ================================================ FILE: cli/cmd/setup.go ================================================ package cmd import ( "bufio" "fmt" "os" "strings" "github.com/semaphoreui/semaphore/cli/setup" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/db/factory" "github.com/semaphoreui/semaphore/util" "github.com/spf13/cobra" ) func init() { rootCmd.AddCommand(setupCmd) } var setupCmd = &cobra.Command{ Use: "setup", Short: "Perform interactive setup", Run: func(cmd *cobra.Command, args []string) { doSetup() }, } // nolint: gocyclo func doSetup() int { config := &util.ConfigType{} config.GenerateSecrets() setup.InteractiveSetup(config) resultConfigPath := setup.SaveConfig(config, "config.json", persistentFlags.configPath) util.ConfigInit(resultConfigPath, false) fmt.Println(" Pinging db..") store := factory.CreateStore() defer store.Close("setup") store.Connect("setup") fmt.Println("Running db Migrations..") if err := db.Migrate(store, nil); err != nil { fmt.Printf("Database migrations failed!\n %v\n", err.Error()) os.Exit(1) } stdin := bufio.NewReader(os.Stdin) var user db.UserWithPwd user.Username = readNewline("\n\n > Username: ", stdin) user.Username = strings.ToLower(user.Username) user.Email = readNewline(" > Email: ", stdin) user.Email = strings.ToLower(user.Email) existingUser, err := store.GetUserByLoginOrEmail(user.Username, user.Email) util.LogWarning(err) if existingUser.ID > 0 { // user already exists fmt.Printf("\n Welcome back, %v! (a user with this username/email is already set up..)\n\n", existingUser.Name) } else { user.Name = readNewline(" > Your name: ", stdin) user.Pwd = readNewline(" > Password: ", stdin) user.Admin = true if _, err := store.CreateUser(user); err != nil { fmt.Printf(" Inserting user failed. If you already have a user, you can disregard this error.\n %v\n", err.Error()) os.Exit(1) } fmt.Printf("\n You are all setup %v!\n", user.Name) } fmt.Printf(" Re-launch this program pointing to the configuration file\n\n./semaphore server --config %v\n\n", resultConfigPath) fmt.Printf(" To run as daemon:\n\nnohup ./semaphore server --config %v &\n\n", resultConfigPath) fmt.Printf(" You can login with %v or %v.\n", user.Email, user.Username) return 0 } func readNewline(pre string, stdin *bufio.Reader) string { fmt.Print(pre) str, err := stdin.ReadString('\n') util.LogWarning(err) str = strings.ReplaceAll(strings.ReplaceAll(str, "\n", ""), "\r", "") return str } ================================================ FILE: cli/cmd/syslog.go ================================================ //go:build !windows // +build !windows package cmd import ( "fmt" "log/syslog" "net" "os" "strings" "sync" "time" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" lSyslog "github.com/sirupsen/logrus/hooks/syslog" ) var localSyslogPaths = []string{"/dev/log", "/var/run/syslog", "/var/run/log"} func initSyslog(conf *util.SyslogConfig) { if !conf.Enabled { return } switch conf.Format { case util.SyslogRFC5424: hook, err := newRFC5424Hook(conf.Network, conf.Address, conf.Tag) if err != nil { log.WithError(err).Fatal("Failed to create syslog hook") return } log.AddHook(hook) log.Info("Syslog logging enabled (RFC 5424)") default: hook, err := lSyslog.NewSyslogHook(conf.Network, conf.Address, syslog.LOG_DEBUG, conf.Tag) if err != nil { log.WithError(err).Fatal("Failed to create syslog hook") return } log.AddHook(hook) log.Info("Syslog logging enabled") } } type rfc5424Hook struct { conn net.Conn tag string hostname string mu sync.Mutex } func newRFC5424Hook(network, address, tag string) (*rfc5424Hook, error) { var conn net.Conn var err error if network != "" && address != "" { conn, err = net.Dial(network, address) } else { for _, path := range localSyslogPaths { conn, err = net.Dial("unixgram", path) if err == nil { break } } } if err != nil { return nil, err } hostname, _ := os.Hostname() return &rfc5424Hook{ conn: conn, tag: tag, hostname: hostname, }, nil } var levelToSeverity = map[log.Level]syslog.Priority{ log.PanicLevel: syslog.LOG_CRIT, log.FatalLevel: syslog.LOG_CRIT, log.ErrorLevel: syslog.LOG_ERR, log.WarnLevel: syslog.LOG_WARNING, log.InfoLevel: syslog.LOG_INFO, log.DebugLevel: syslog.LOG_DEBUG, log.TraceLevel: syslog.LOG_DEBUG, } func (h *rfc5424Hook) Levels() []log.Level { return log.AllLevels } func (h *rfc5424Hook) Fire(entry *log.Entry) error { severity, ok := levelToSeverity[entry.Level] if !ok { severity = syslog.LOG_INFO } pri := syslog.LOG_USER | severity sd := "-" if len(entry.Data) > 0 { var pairs []string for k, v := range entry.Data { pairs = append(pairs, fmt.Sprintf(`%s="%s"`, k, escapeSDValue(fmt.Sprintf("%v", v)))) } sd = fmt.Sprintf("[%s@0 %s]", h.tag, strings.Join(pairs, " ")) } // RFC 5424: VERSION SP TIMESTAMP SP HOSTNAME SP APP-NAME SP PROCID SP MSGID SP STRUCTURED-DATA [SP MSG] msg := fmt.Sprintf("<%d>1 %s %s %s %d - %s %s", pri, entry.Time.Format(time.RFC3339), h.hostname, h.tag, os.Getpid(), sd, entry.Message, ) h.mu.Lock() defer h.mu.Unlock() _, err := fmt.Fprintln(h.conn, msg) return err } func escapeSDValue(v string) string { v = strings.ReplaceAll(v, `\`, `\\`) v = strings.ReplaceAll(v, `"`, `\"`) v = strings.ReplaceAll(v, `]`, `\]`) return v } ================================================ FILE: cli/cmd/syslog_windows.go ================================================ //go:build windows // +build windows package cmd import ( "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" ) // initSyslog is disabled on Windows because the standard syslog package is not supported. func initSyslog(conf *util.SyslogConfig) { if conf != nil && conf.Enabled { log.Warn("Syslog is not supported on Windows. The syslog log channel will be disabled.") } // no-op on Windows } ================================================ FILE: cli/cmd/token.go ================================================ package cmd ================================================ FILE: cli/cmd/user.go ================================================ package cmd import ( "os" "github.com/spf13/cobra" ) type userArgs struct { login string name string email string password string admin bool totp bool external bool } var targetUserArgs userArgs func init() { rootCmd.AddCommand(userCmd) } var userCmd = &cobra.Command{ Use: "users", Aliases: []string{"user"}, Short: "Manage users", Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() os.Exit(0) }, } ================================================ FILE: cli/cmd/user_add.go ================================================ package cmd import ( "fmt" "github.com/semaphoreui/semaphore/db" "github.com/spf13/cobra" "os" ) func init() { userAddCmd.PersistentFlags().StringVar(&targetUserArgs.login, "login", "", "New user login") userAddCmd.PersistentFlags().StringVar(&targetUserArgs.name, "name", "", "New user name") userAddCmd.PersistentFlags().StringVar(&targetUserArgs.email, "email", "", "New user email") userAddCmd.PersistentFlags().StringVar(&targetUserArgs.password, "password", "", "New user password") userAddCmd.PersistentFlags().BoolVar(&targetUserArgs.admin, "admin", false, "Mark new user as admin") userAddCmd.PersistentFlags().BoolVar(&targetUserArgs.external, "external", false, "Mark new user as external (LDAP or OIDC user)") userCmd.AddCommand(userAddCmd) } var userAddCmd = &cobra.Command{ Use: "add", Short: "Add new user", Run: func(cmd *cobra.Command, args []string) { ok := true if targetUserArgs.name == "" { fmt.Println("Argument --name required") ok = false } if targetUserArgs.login == "" { fmt.Println("Argument --login required") ok = false } if targetUserArgs.email == "" { fmt.Println("Argument --email required") ok = false } if targetUserArgs.external { if targetUserArgs.password != "" { fmt.Println("Argument --password not allowed for external users") ok = false } } else { if targetUserArgs.password == "" { fmt.Println("Argument --password required") ok = false } } if !ok { fmt.Println("Use command `semaphore user add --help` for details.") os.Exit(1) } store := createStore("") defer store.Close("") if _, err := store.CreateUser(db.UserWithPwd{ Pwd: targetUserArgs.password, User: db.User{ Name: targetUserArgs.name, Username: targetUserArgs.login, Email: targetUserArgs.email, Admin: targetUserArgs.admin, External: targetUserArgs.external, }, }); err != nil { panic(err) } fmt.Printf("User %s <%s> added!\n", targetUserArgs.login, targetUserArgs.email) }, } ================================================ FILE: cli/cmd/user_change.go ================================================ package cmd import ( "fmt" "github.com/semaphoreui/semaphore/db" "github.com/spf13/cobra" "os" ) func init() { for _, cmd := range []*cobra.Command{userChangeByLoginCmd, userChangeByEmailCmd} { cmd.PersistentFlags().StringVar(&targetUserArgs.login, "login", "", "User login") cmd.PersistentFlags().StringVar(&targetUserArgs.name, "name", "", "User's new name") cmd.PersistentFlags().StringVar(&targetUserArgs.email, "email", "", "User's new email") cmd.PersistentFlags().StringVar(&targetUserArgs.password, "password", "", "User's new password") cmd.PersistentFlags().BoolVar(&targetUserArgs.admin, "admin", false, "Mark user as admin") userCmd.AddCommand(cmd) } } func applyChangeUserArgsForUser(user db.User, store db.Store) { if targetUserArgs.name != "" { user.Name = targetUserArgs.name } if targetUserArgs.email != "" { user.Email = targetUserArgs.email } if targetUserArgs.login != "" { user.Username = targetUserArgs.login } if targetUserArgs.name != "" { user.Name = targetUserArgs.name } if targetUserArgs.admin { user.Admin = true } if err := store.UpdateUser(db.UserWithPwd{ User: user, Pwd: targetUserArgs.password, }); err != nil { panic(err) } fmt.Printf("User %s <%s> changed!\n", user.Username, user.Email) } var userChangeByLoginCmd = &cobra.Command{ Use: "change-by-login", Short: "Change user found by login", Run: func(cmd *cobra.Command, args []string) { ok := true if targetUserArgs.login == "" { fmt.Println("Argument --login required") ok = false } if !ok { fmt.Println("Use command `semaphore user change-by-login --help` for details.") os.Exit(1) } store := createStore("") defer store.Close("") user, err := store.GetUserByLoginOrEmail(targetUserArgs.login, "") if err != nil { panic(err) } applyChangeUserArgsForUser(user, store) }, } var userChangeByEmailCmd = &cobra.Command{ Use: "change-by-email", Short: "Change user found by email", Run: func(cmd *cobra.Command, args []string) { ok := true if targetUserArgs.email == "" { fmt.Println("Argument --email required") ok = false } if !ok { fmt.Println("Use command `semaphore user change-by-email --help` for details.") os.Exit(1) } store := createStore("") defer store.Close("") user, err := store.GetUserByLoginOrEmail("", targetUserArgs.email) if err != nil { panic(err) } applyChangeUserArgsForUser(user, store) }, } ================================================ FILE: cli/cmd/user_delete.go ================================================ package cmd import ( "fmt" "github.com/spf13/cobra" "os" ) func init() { userDeleteCmd.PersistentFlags().StringVar(&targetUserArgs.login, "login", "", "Login of the user you want to delete") userDeleteCmd.PersistentFlags().StringVar(&targetUserArgs.email, "email", "", "Email of the user you want to delete") userCmd.AddCommand(userDeleteCmd) } var userDeleteCmd = &cobra.Command{ Use: "delete", Short: "Remove existing user", Run: func(cmd *cobra.Command, args []string) { ok := true if targetUserArgs.login == "" && targetUserArgs.email == "" { fmt.Println("Argument --email or --login required") ok = false } if !ok { fmt.Println("Use command `semaphore user delete --help` for details.") os.Exit(1) } store := createStore("") defer store.Close("") user, err := store.GetUserByLoginOrEmail(targetUserArgs.login, targetUserArgs.email) if err != nil { panic(err) } if err := store.DeleteUser(user.ID); err != nil { panic(err) } fmt.Printf("User %s <%s> deleted!\n", user.Username, user.Email) }, } ================================================ FILE: cli/cmd/user_get.go ================================================ package cmd import ( "errors" "fmt" "github.com/semaphoreui/semaphore/db" "github.com/spf13/cobra" "os" ) func init() { userGetCmd.PersistentFlags().StringVar(&targetUserArgs.login, "login", "", "Login of the user you want to see") userGetCmd.PersistentFlags().StringVar(&targetUserArgs.email, "email", "", "Email of the user you want to see") userCmd.AddCommand(userGetCmd) } var userGetCmd = &cobra.Command{ Use: "get", Short: "Show user's data", Run: func(cmd *cobra.Command, args []string) { ok := true if targetUserArgs.login == "" && targetUserArgs.email == "" { fmt.Println("Argument --email or --login required") ok = false } if !ok { fmt.Println("Use command `semaphore user get --help` for details.") os.Exit(1) } store := createStore("") defer store.Close("") user, err := store.GetUserByLoginOrEmail(targetUserArgs.login, targetUserArgs.email) if errors.Is(err, db.ErrNotFound) { fmt.Printf("User with login %s or email %s not found\n", targetUserArgs.login, targetUserArgs.email) os.Exit(1) } if err != nil { panic(err) } fmt.Printf("ID: %d\n", user.ID) fmt.Printf("Created: %s\n", user.Created) fmt.Printf("Login: %s\n", user.Username) fmt.Printf("Name: %s\n", user.Name) fmt.Printf("Email: %s\n", user.Email) fmt.Printf("Admin: %t\n", user.Admin) }, } ================================================ FILE: cli/cmd/user_list.go ================================================ package cmd import ( "fmt" "github.com/semaphoreui/semaphore/db" "github.com/spf13/cobra" ) func init() { userCmd.AddCommand(userListCmd) } var userListCmd = &cobra.Command{ Use: "list", Short: "Print all users", Run: func(cmd *cobra.Command, args []string) { store := createStore("") defer store.Close("") users, err := store.GetUsers(db.RetrieveQueryParams{}) if err != nil { panic(err) } for _, user := range users { fmt.Println(user.Username) } }, } ================================================ FILE: cli/cmd/user_totp.go ================================================ package cmd import ( "fmt" "github.com/mdp/qrterminal/v3" "github.com/pquerna/otp/totp" "github.com/semaphoreui/semaphore/util" "github.com/spf13/cobra" "os" ) func init() { for _, cmd := range []*cobra.Command{totpEnableCmd, totpDisableCmd, totpShowCmd} { cmd.PersistentFlags().StringVar(&targetUserArgs.login, "login", "", "User login") totpCmd.AddCommand(cmd) } userCmd.AddCommand(totpCmd) } var totpCmd = &cobra.Command{ Use: "totp", Short: "Manage TOTP verification", Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() os.Exit(0) }, } var totpEnableCmd = &cobra.Command{ Use: "enable", Short: "Enable TOTP verification", Run: func(cmd *cobra.Command, args []string) { if targetUserArgs.login == "" { fmt.Println("Argument --login required") os.Exit(1) } store := createStore("") defer store.Close("") user, err := store.GetUserByLoginOrEmail(targetUserArgs.login, "") if err != nil { panic(err) } if user.Totp != nil { fmt.Println("TOTP already enabled") os.Exit(1) } issuer := "Semaphore" if util.Config.Auth.Totp.Issuer != "" { issuer = util.Config.Auth.Totp.Issuer } key, err := totp.Generate(totp.GenerateOpts{ Issuer: issuer, AccountName: user.Email, }) if err != nil { panic(err) } code, hash, err := util.GenerateRecoveryCode() if err != nil { panic(err) } totp, err := store.AddTotpVerification(user.ID, key.URL(), hash) if err != nil { panic(err) } fmt.Println() fmt.Println("Recovery code: ", code) fmt.Println() fmt.Println(totp.URL) fmt.Println() qrterminal.GenerateWithConfig(totp.URL, qrterminal.Config{ Writer: os.Stdout, Level: qrterminal.L, BlackChar: qrterminal.BLACK, WhiteChar: qrterminal.WHITE, QuietZone: 2, }) fmt.Println() }, } var totpDisableCmd = &cobra.Command{ Use: "disable", Short: "Disable TOTP verification", Run: func(cmd *cobra.Command, args []string) { if targetUserArgs.login == "" { fmt.Println("Argument --login required") os.Exit(1) } store := createStore("") defer store.Close("") user, err := store.GetUserByLoginOrEmail(targetUserArgs.login, "") if err != nil { panic(err) } if user.Totp == nil { fmt.Println("TOTP not enabled") os.Exit(1) } err = store.DeleteTotpVerification(user.ID, user.Totp.ID) if err != nil { panic(err) } }, } var totpShowCmd = &cobra.Command{ Use: "show", Short: "Show TOTP details", Run: func(cmd *cobra.Command, args []string) { if targetUserArgs.login == "" { fmt.Println("Argument --login required") os.Exit(1) } store := createStore("") defer store.Close("") user, err := store.GetUserByLoginOrEmail(targetUserArgs.login, "") if err != nil { panic(err) } if user.Totp == nil { fmt.Println("TOTP disabled") } else { fmt.Println() fmt.Println(user.Totp.URL) fmt.Println() qrterminal.GenerateWithConfig(user.Totp.URL, qrterminal.Config{ Writer: os.Stdout, Level: qrterminal.L, BlackChar: qrterminal.BLACK, WhiteChar: qrterminal.WHITE, QuietZone: 2, }) fmt.Println() } }, } ================================================ FILE: cli/cmd/vault.go ================================================ package cmd import ( "os" "github.com/spf13/cobra" ) type vaultArgs struct { oldKey string } var targetVaultArgs vaultArgs func init() { rootCmd.AddCommand(vaultCmd) } var vaultCmd = &cobra.Command{ Use: "vaults", Aliases: []string{"vault"}, Short: "Manage access keys and other secrets", Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() os.Exit(0) }, } ================================================ FILE: cli/cmd/vault_rekey.go ================================================ package cmd import ( "github.com/spf13/cobra" ) func init() { vaultRekeyCmd.PersistentFlags().StringVar(&targetVaultArgs.oldKey, "old-key", "", "Old encryption key") vaultCmd.AddCommand(vaultRekeyCmd) } var vaultRekeyCmd = &cobra.Command{ Use: "rekey", Short: "Re-encrypt Key Store in database with using current encryption key", Long: "To update the encryption key, modify it within the configuration file and " + "then employ the 'vault rekey --old-key ' command to ensure the re-encryption of the " + "pre-existing keys stored in the database.", Run: func(cmd *cobra.Command, args []string) { store := createStore("") defer store.Close("") err := store.RekeyAccessKeys(targetVaultArgs.oldKey) if err != nil { panic(err) } }, } ================================================ FILE: cli/cmd/version.go ================================================ package cmd import ( "fmt" "github.com/semaphoreui/semaphore/util" "github.com/spf13/cobra" ) func init() { rootCmd.AddCommand(versionCmd) } var versionCmd = &cobra.Command{ Use: "version", Short: "Print the version of Semaphore", Run: func(cmd *cobra.Command, args []string) { fmt.Println(util.Version()) }, } ================================================ FILE: cli/main.go ================================================ package main import ( "github.com/semaphoreui/semaphore/cli/cmd" ) func main() { cmd.Execute() } ================================================ FILE: cli/setup/setup.go ================================================ package setup import ( "fmt" "os" "path/filepath" "strings" log "github.com/sirupsen/logrus" "github.com/semaphoreui/semaphore/util" ) const interactiveSetupBlurb = ` Hello! You will now be guided through a setup to: 1. Set up configuration for a MySQL/MariaDB database 2. Set up a path for your playbooks (auto-created) 3. Run database Migrations 4. Set up initial semaphore user & password ` func InteractiveRunnerSetup(conf *util.ConfigType) { askValue("Semaphore server URL", "", &conf.WebHost) conf.Runner = &util.RunnerConfig{} needTokenFile := false askConfirmation("Do you want to store token in external file?", false, &needTokenFile) if needTokenFile { askValue("Path to the file where runner token will be stored", "", &conf.Runner.TokenFile) } needToken := false askConfirmation("Do you have runner's token?", false, &needToken) if needToken { token := "" for { askValue("Enter valid runner token", "", &token) if token == "" { fmt.Println("Invalid token") continue } break } conf.Runner.Token = token hasPrivateKey := false askConfirmation("Do you have runner's private key file?", false, &hasPrivateKey) if hasPrivateKey { pkFile := "" for { askValue("Enter path to the private key file", "", &pkFile) if pkFile == "" { fmt.Println("Invalid private key file path") continue } break } conf.Runner.PrivateKeyFile = pkFile } return } needRegistration := false askConfirmation("Do you want to register new runner on the server?", false, &needRegistration) if needRegistration { regToken := "" for { askValue("Enter runner registration token", "", ®Token) if regToken == "" { fmt.Println("Invalid registration token") continue } break } conf.Runner.RegistrationToken = regToken pkFile := "" for { askValue("Enter path to the private key file (will be generated if not exists)", "", &pkFile) if pkFile == "" { fmt.Println("Invalid private key file path") continue } break } conf.Runner.PrivateKeyFile = pkFile return } return } func InteractiveSetup(conf *util.ConfigType) { fmt.Print(interactiveSetupBlurb) dbPrompt := `What database to use: 1 - MySQL 2 - BoltDB (DEPRECATED!!!) 3 - PostgreSQL 4 - SQLite ` var db int askValue(dbPrompt, "1", &db) switch db { case 1: conf.Dialect = util.DbDriverMySQL scanMySQL(conf) case 2: conf.Dialect = util.DbDriverBolt scanBoltDb(conf) case 3: conf.Dialect = util.DbDriverPostgres scanPostgres(conf) case 4: conf.Dialect = util.DbDriverSQLite scanSQLite(conf) } defaultPlaybookPath := filepath.Join(os.TempDir(), "semaphore") askValue("Playbook path", defaultPlaybookPath, &conf.TmpPath) conf.TmpPath = filepath.Clean(conf.TmpPath) askValue("Public URL (optional, example: https://example.com/semaphore)", "", &conf.WebHost) askConfirmation("Enable email alerts?", false, &conf.EmailAlert) if conf.EmailAlert { askValue("Mail server host", "localhost", &conf.EmailHost) askValue("Mail server port", "25", &conf.EmailPort) askValue("Mail sender address", "semaphore@localhost", &conf.EmailSender) } askConfirmation("Enable telegram alerts?", false, &conf.TelegramAlert) if conf.TelegramAlert { askValue("Telegram bot token (you can get it from @BotFather)", "", &conf.TelegramToken) askValue("Telegram chat ID", "", &conf.TelegramChat) } askConfirmation("Enable slack alerts?", false, &conf.SlackAlert) if conf.SlackAlert { askValue("Slack Webhook URL", "", &conf.SlackUrl) } askConfirmation("Enable Rocket.Chat alerts?", false, &conf.RocketChatAlert) if conf.RocketChatAlert { askValue("Rocket.Chat Webhook URL", "", &conf.RocketChatUrl) } askConfirmation("Enable Microsoft Team Channel alerts?", false, &conf.MicrosoftTeamsAlert) if conf.MicrosoftTeamsAlert { askValue("Microsoft Teams Webhook URL", "", &conf.MicrosoftTeamsUrl) } askConfirmation("Enable LDAP authentication?", false, &conf.LdapEnable) if conf.LdapEnable { conf.LdapMappings = &util.LdapMappings{} askValue("LDAP server host", "localhost:389", &conf.LdapServer) askConfirmation("Enable LDAP TLS connection", false, &conf.LdapNeedTLS) askValue("LDAP DN for bind", "cn=user,ou=users,dc=example", &conf.LdapBindDN) askValue("Password for LDAP bind user", "pa55w0rd", &conf.LdapBindPassword) askValue("LDAP DN for user search", "ou=users,dc=example", &conf.LdapSearchDN) askValue("LDAP search filter", `(uid=%s)`, &conf.LdapSearchFilter) askValue("LDAP mapping for DN field", "dn", &conf.LdapMappings.DN) askValue("LDAP mapping for username field", "uid", &conf.LdapMappings.UID) askValue("LDAP mapping for full name field", "cn", &conf.LdapMappings.CN) askValue("LDAP mapping for email field", "mail", &conf.LdapMappings.Mail) } } func scanBoltDb(conf *util.ConfigType) { conf.BoltDb = scanFileDB("database.boltdb") } func scanSQLite(conf *util.ConfigType) { conf.SQLite = scanFileDB("database.sqlite") } func scanMySQL(conf *util.ConfigType) { conf.MySQL = &util.DbConfig{} askValue("db Hostname", "127.0.0.1:3306", &conf.MySQL.Hostname) askValue("db User", "root", &conf.MySQL.Username) askValue("db Password", "", &conf.MySQL.Password) askValue("db Name", "semaphore", &conf.MySQL.DbName) } func scanPostgres(conf *util.ConfigType) { conf.Postgres = &util.DbConfig{} askValue("db Hostname", "127.0.0.1:5432", &conf.Postgres.Hostname) askValue("db User", "root", &conf.Postgres.Username) askValue("db Password", "", &conf.Postgres.Password) askValue("db Name", "semaphore", &conf.Postgres.DbName) if conf.Postgres.Options == nil { conf.Postgres.Options = make(map[string]string) } if _, exists := conf.Postgres.Options["sslmode"]; !exists { conf.Postgres.Options["sslmode"] = "disable" } } func scanFileDB(defaultDbFile string) *util.DbConfig { workingDirectory, err := os.Getwd() if err != nil { workingDirectory = os.TempDir() } defaultDBPath := filepath.Join(workingDirectory, defaultDbFile) conf := &util.DbConfig{} askValue("db Hostname", defaultDBPath, &conf.Hostname) return conf } func scanErrorChecker(n int, err error) { if err != nil && err.Error() != "unexpected newline" { log.Warn("An input error occurred: " + err.Error()) } } type IConfig interface { ToJSON() ([]byte, error) } func SaveConfig(config IConfig, defaultFilename string, requiredConfigPath string) (configPath string) { if requiredConfigPath == "" { configDirectory, err := os.Getwd() if err != nil { configDirectory, err = os.UserConfigDir() if err != nil { // Final fallback configDirectory = "/etc/semaphore" } configDirectory = filepath.Join(configDirectory, "semaphore") } askValue("Config output directory", configDirectory, &configDirectory) configPath = filepath.Join(configDirectory, defaultFilename) } else { configPath = requiredConfigPath } configDirectory := filepath.Dir(configPath) fmt.Printf("Running: mkdir -p %v..\n", configDirectory) var err error if _, err = os.Stat(configDirectory); err != nil { if os.IsNotExist(err) { err = os.MkdirAll(configDirectory, 0755) } } if err != nil { log.Panic("Could not create config directory: " + err.Error()) } // Marshal config to json bytes, err := config.ToJSON() if err != nil { panic(err) } if err = os.WriteFile(configPath, bytes, 0644); err != nil { panic(err) } fmt.Printf("Configuration written to %v..\n", configPath) return } func askValue(prompt string, defaultValue string, item any) { // Print prompt with optional default value fmt.Print(prompt) if len(defaultValue) != 0 { fmt.Print(" (default " + defaultValue + ")") } fmt.Print(": ") _, _ = fmt.Sscanln(defaultValue, item) n, err := fmt.Scanln(item) scanErrorChecker(n, err) // Empty line after prompt fmt.Println("") } func askConfirmation(prompt string, defaultValue bool, item *bool) { defString := "yes" if !defaultValue { defString = "no" } fmt.Print(prompt + " (yes/no) (default " + defString + "): ") var answer string n, err := fmt.Scanln(&answer) scanErrorChecker(n, err) switch strings.ToLower(answer) { case "y", "yes": *item = true case "n", "no": *item = false default: *item = defaultValue } // Empty line after prompt fmt.Println("") } ================================================ FILE: db/APIToken.go ================================================ package db import "time" // APIToken is given to a user to allow API access type APIToken struct { ID string `db:"id" json:"id"` Created time.Time `db:"created" json:"created"` Expired bool `db:"expired" json:"expired"` UserID int `db:"user_id" json:"user_id"` } ================================================ FILE: db/AccessKey.go ================================================ package db import ( "fmt" ) type AccessKeyType string type AccessKeyOwner string type AccessKeySourceStorageType string const ( AccessKeySSH AccessKeyType = "ssh" AccessKeyNone AccessKeyType = "none" AccessKeyLoginPassword AccessKeyType = "login_password" AccessKeyString AccessKeyType = "string" ) const ( AccessKeyEnvironment AccessKeyOwner = "environment" AccessKeyVariable AccessKeyOwner = "variable" AccessKeySecretStorage AccessKeyOwner = "vault" AccessKeyShared AccessKeyOwner = "" ) const ( AccessKeySourceStorageVault AccessKeySourceStorageType = "vault" AccessKeySourceStorageEnv AccessKeySourceStorageType = "env" AccessKeySourceStorageFile AccessKeySourceStorageType = "file" ) // AccessKey represents a key used to access a machine with ansible from semaphore type AccessKey struct { ID int `db:"id" json:"id" backup:"-"` Name string `db:"name" json:"name" binding:"required"` // 'ssh/login_password/none' Type AccessKeyType `db:"type" json:"type" binding:"required"` ProjectID *int `db:"project_id" json:"project_id" backup:"-"` // Secret used internally, do not assign this field. // You should use methods SerializeSecret to fill this field. Secret *string `db:"secret" json:"-" backup:"-"` Plain *string `db:"plain" json:"plain,omitempty"` IgnorePlain bool String string `db:"-" json:"string"` LoginPassword LoginPassword `db:"-" json:"login_password"` SshKey SshKey `db:"-" json:"ssh"` OverrideSecret bool `db:"-" json:"override_secret,omitempty"` StorageID *int `db:"storage_id" json:"-" backup:"-"` // EnvironmentID is an ID of environment which owns the access key. EnvironmentID *int `db:"environment_id" json:"-" backup:"-"` // UserID is an ID of a user which owns the access key. UserID *int `db:"user_id" json:"-" backup:"-"` Empty bool `db:"-" json:"empty,omitempty"` Owner AccessKeyOwner `db:"owner" json:"owner,omitempty"` // SourceStorageID represents the ID of the source storage associated with the access key, used for reference purposes. SourceStorageID *int `db:"source_storage_id" json:"source_storage_id,omitempty" backup:"-"` // SourceStorageKey is an optional reference to a specific storage key associated with the source storage. // For example, for HashiCorp Vault, this is the path to the secret. // If SourceStorageID is nil, this field is references to an environment variable. SourceStorageKey *string `db:"source_storage_key" json:"source_storage_key,omitempty"` SourceStorageType *AccessKeySourceStorageType `db:"source_storage_type" json:"source_storage_type,omitempty"` } func (key *AccessKey) IsNativelyReadOnly() bool { if key.SourceStorageType == nil { return false } return *key.SourceStorageType == AccessKeySourceStorageFile || *key.SourceStorageType == AccessKeySourceStorageEnv } func (key *AccessKey) IsEmpty() bool { if key == nil { return true } if key.Type == AccessKeyNone { return false } if key.SourceStorageType != nil { switch *key.SourceStorageType { case AccessKeySourceStorageEnv, AccessKeySourceStorageFile: return key.SourceStorageKey == nil || *key.SourceStorageKey == "" case AccessKeySourceStorageVault: return key.SourceStorageID == nil default: return true } } if key.Secret != nil && *key.Secret != "" { return false } switch key.Type { case AccessKeyString: return key.String == "" case AccessKeySSH: return key.SshKey.PrivateKey == "" case AccessKeyLoginPassword: return key.LoginPassword.Password == "" default: return true } } type LoginPassword struct { Login string `json:"login"` Password string `json:"password"` } type SshKey struct { Login string `json:"login"` Passphrase string `json:"passphrase"` PrivateKey string `json:"private_key"` } type AccessKeyRole int const ( AccessKeyRoleAnsibleUser = iota AccessKeyRoleAnsibleBecomeUser AccessKeyRoleAnsiblePasswordVault AccessKeyRoleGit ) func (key *AccessKey) Validate(validateSecretFields bool) error { if key.Name == "" { return fmt.Errorf("name can not be empty") } //if !validateSecretFields { // return nil //} //switch key.Type { //case AccessKeySSH: // if key.SshKey.PrivateKey == "" { // return fmt.Errorf("private key can not be empty") // } //case AccessKeyLoginPassword: // if key.LoginPassword.Password == "" { // return fmt.Errorf("password can not be empty") // } //} return nil } func (key *AccessKey) IsEnvironmentVariable() bool { return key.SourceStorageID == nil && key.SourceStorageKey != nil } ================================================ FILE: db/Alias.go ================================================ package db type Alias struct { ID int Alias string ProjectID int } type Aliasable interface { ToAlias() Alias } ================================================ FILE: db/BackupEntity.go ================================================ package db type BackupEntity interface { GetID() int GetName() string } type BackupSluggedEntity interface { GetSlug() string GetName() string } func (e View) GetID() int { return e.ID } func (e View) GetName() string { return e.Title } func (e Schedule) GetName() string { return e.Name } func (e Template) GetID() int { return e.ID } func (e Template) GetName() string { return e.Name } func (e Inventory) GetID() int { return e.ID } func (e Inventory) GetName() string { return e.Name } func (key AccessKey) GetID() int { return key.ID } func (key AccessKey) GetName() string { return key.Name } func (e Repository) GetID() int { return e.ID } func (e Repository) GetName() string { return e.Name } func (e Environment) GetID() int { return e.ID } func (e Environment) GetName() string { return e.Name } func (e SecretStorage) GetID() int { return e.ID } func (e SecretStorage) GetName() string { return e.Name } func (e Role) GetID() int { panic("Role does not implement GetID") } func (e Role) GetSlug() string { return e.Slug } func (e Role) GetName() string { if e.ProjectID == nil { return e.Slug } return e.Name } func (e TemplateVault) GetID() int { return e.ID } func (e Task) GetID() int { return e.ID } func (e Integration) GetID() int { return e.ID } func (e Project) GetID() int { return e.ID } func (e User) GetID() int { return e.ID } ================================================ FILE: db/Environment.go ================================================ package db import ( "encoding/json" "errors" ) type EnvironmentSecretOperation string const ( EnvironmentSecretCreate EnvironmentSecretOperation = "create" EnvironmentSecretUpdate EnvironmentSecretOperation = "update" EnvironmentSecretDelete EnvironmentSecretOperation = "delete" ) type EnvironmentSecretType string const ( EnvironmentSecretVar EnvironmentSecretType = "var" EnvironmentSecretEnv EnvironmentSecretType = "env" ) func (t EnvironmentSecretType) GetAccessKeyOwner() AccessKeyOwner { switch t { case EnvironmentSecretVar: return AccessKeyVariable case EnvironmentSecretEnv: return AccessKeyEnvironment default: panic("unknown secret type: " + t) } } type EnvironmentSecret struct { ID int `json:"id"` Type EnvironmentSecretType `json:"type"` Name string `json:"name"` Secret string `json:"secret"` Operation EnvironmentSecretOperation `json:"operation"` } // Environment is used to pass additional arguments, in json form to ansible type Environment struct { ID int `db:"id" json:"id" backup:"-"` Name string `db:"name" json:"name" binding:"required"` ProjectID int `db:"project_id" json:"project_id" backup:"-"` Password *string `db:"password" json:"password"` JSON string `db:"json" json:"json" binding:"required"` ENV *string `db:"env" json:"env" binding:"required"` // Secrets is a field which used to update secrets associated with the environment. Secrets []EnvironmentSecret `db:"-" json:"secrets,omitempty" backup:"-"` SecretStorageID *int `db:"secret_storage_id" json:"secret_storage_id,omitempty" backup:"-"` SecretStorageKeyPrefix *string `db:"secret_storage_key_prefix" json:"secret_storage_key_prefix,omitempty"` } func (s *EnvironmentSecret) Validate() error { if s.Type == EnvironmentSecretVar || s.Type == EnvironmentSecretEnv { return nil } if s.Secret == "" { return errors.New("missing secret") } return errors.New("invalid environment secret type") } func validateJSON(s string, mustValuesBeScalar bool) error { if s == "" { return nil } var data map[string]any err := json.Unmarshal([]byte(s), &data) if err != nil { return errors.New("must be valid JSON") } for k, v := range data { if k == "" { return errors.New("key can not be empty") } if mustValuesBeScalar { switch v.(type) { case []any, map[string]any: return errors.New("values must be scalar") } } } return nil } func (env *Environment) Validate() (err error) { if env.Name == "" { err = &ValidationError{"Environment name can not be empty"} return } err = validateJSON(env.JSON, false) if err != nil { err = &ValidationError{"Extra variables " + err.Error()} return } if env.ENV == nil { return } err = validateJSON(*env.ENV, true) if err != nil { err = &ValidationError{"Environment variables " + err.Error()} } return } ================================================ FILE: db/Environment_test.go ================================================ package db import ( "github.com/stretchr/testify/assert" "testing" ) func Test_EnvironmentValidate_EmptyName_ReturnsError(t *testing.T) { env := &Environment{ Name: "", JSON: "{}", ENV: nil, } err := env.Validate() assert.Error(t, err) assert.Equal(t, "Environment name can not be empty", err.Error()) } func Test_EnvironmentValidate_InvalidJSON_ReturnsError(t *testing.T) { env := &Environment{ Name: "TestEnv", JSON: "{invalid_json}", ENV: nil, } err := env.Validate() assert.Error(t, err) assert.Equal(t, "Extra variables must be valid JSON", err.Error()) } func Test_EnvironmentValidate_ValidJSON_ReturnsNoError(t *testing.T) { env := &Environment{ Name: "TestEnv", JSON: `{"key": "value"}`, ENV: nil, } err := env.Validate() assert.NoError(t, err) } func Test_EnvironmentValidate_InvalidEnvJSON_ReturnsError(t *testing.T) { envVar := "{invalid_json}" env := &Environment{ Name: "TestEnv", JSON: `{"key": "value"}`, ENV: &envVar, } err := env.Validate() assert.Error(t, err) assert.Equal(t, "Environment variables must be valid JSON", err.Error()) } func Test_EnvironmentValidate_EmptyJsonName_ReturnsError(t *testing.T) { env := &Environment{ Name: "TestEnv", JSON: `{"": "value"}`, ENV: nil, } err := env.Validate() assert.Error(t, err) assert.Equal(t, "Extra variables key can not be empty", err.Error()) } func Test_EnvironmentValidate_NonScalarEnvValues_ReturnsError(t *testing.T) { envVar := `{"key": {"nested": "value"}}` env := &Environment{ Name: "TestEnv", JSON: `{"key": "value"}`, ENV: &envVar, } err := env.Validate() assert.Error(t, err) assert.Equal(t, "Environment variables values must be scalar", err.Error()) } func Test_EnvironmentValidate_ValidEnvJSON_ReturnsNoError(t *testing.T) { envVar := `{"key": "value"}` env := &Environment{ Name: "TestEnv", JSON: `{"key": "value"}`, ENV: &envVar, } err := env.Validate() assert.NoError(t, err) } ================================================ FILE: db/Event.go ================================================ package db import ( log "github.com/sirupsen/logrus" "time" ) // Event represents information generated by ansible or api action captured to the database during execution type Event struct { ID int `db:"id" json:"-"` UserID *int `db:"user_id" json:"user_id"` ProjectID *int `db:"project_id" json:"project_id"` IntegrationID *int `db:"integration_id" json:"integration_id"` ObjectID *int `db:"object_id" json:"object_id"` ObjectType *EventObjectType `db:"object_type" json:"object_type"` Description *string `db:"description" json:"description"` Created time.Time `db:"created" json:"created"` ObjectName string `db:"-" json:"object_name"` ProjectName *string `db:"project_name" json:"project_name"` Username *string `db:"-" json:"username"` } func (event Event) ToFields() (logFields log.Fields) { logFields = log.Fields{} if event.ProjectID != nil { logFields["project"] = *event.ProjectID } if event.ObjectType != nil { logFields["type"] = *event.ObjectType } if event.ObjectID != nil { logFields["object"] = *event.ObjectID } if event.UserID != nil { logFields["user"] = *event.UserID } if event.IntegrationID != nil { logFields["integration"] = *event.IntegrationID } return } type EventObjectType string const ( EventTask EventObjectType = "task" EventEnvironment EventObjectType = "environment" EventInventory EventObjectType = "inventory" EventKey EventObjectType = "key" EventProject EventObjectType = "project" EventRepository EventObjectType = "repository" EventSchedule EventObjectType = "schedule" EventTemplate EventObjectType = "template" EventUser EventObjectType = "user" EventView EventObjectType = "view" EventIntegration EventObjectType = "integration" EventIntegrationExtractValue EventObjectType = "integrationextractvalue" EventIntegrationMatcher EventObjectType = "integrationmatcher" EventTerraformInventoryAlias EventObjectType = "terraform_inventory_alias" ) func FillEvents(d Store, events []Event) (err error) { usernames := make(map[int]string) for i, evt := range events { var objName string objName, err = getEventObjectName(d, evt) if err != nil { return } if objName != "" { events[i].ObjectName = objName } if evt.UserID == nil { continue } var username string username, ok := usernames[*evt.UserID] if !ok { username, err = getEventUsername(d, evt) if err != nil { return } if username == "" { continue } usernames[*evt.UserID] = username } events[i].Username = &username } return } func getEventObjectName(d Store, evt Event) (string, error) { if evt.ObjectID == nil || evt.ObjectType == nil { return "", nil } switch *evt.ObjectType { case EventTask: task, err := d.GetTask(*evt.ProjectID, *evt.ObjectID) if err != nil { // Task can be deleted, it is ok, just return empty string return "", nil } return task.Playbook, nil default: return "", nil } } func getEventUsername(d Store, evt Event) (username string, err error) { if evt.UserID == nil { return "", nil } user, err := d.GetUser(*evt.UserID) if err != nil { return "", err } return user.Username, nil } ================================================ FILE: db/ExportEntityType.go ================================================ package db import ( "strconv" ) func NewKeyFromInt(key int) string { return strconv.Itoa(key) } func (e TemplateVault) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e Task) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e Integration) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e Project) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e User) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e Template) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e Environment) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e Repository) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e SecretStorage) GetDbKey() string { return NewKeyFromInt(e.ID) } func (key AccessKey) GetDbKey() string { return NewKeyFromInt(key.ID) } func (e Inventory) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e Role) GetDbKey() string { return e.Slug } func (e View) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e IntegrationAlias) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e IntegrationExtractValue) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e IntegrationMatcher) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e Schedule) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e TaskStage) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e TemplateRolePerm) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e TaskParams) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e ProjectUser) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e TaskOutput) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e TaskStageResult) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e Option) GetDbKey() string { return e.Key } func (e Event) GetDbKey() string { return NewKeyFromInt(e.ID) } func (e Runner) GetDbKey() string { return NewKeyFromInt(e.ID) } ================================================ FILE: db/Integration.go ================================================ package db import ( "strconv" "strings" ) type IntegrationAuthMethod string const ( IntegrationAuthNone = "" IntegrationAuthGitHub = "github" IntegrationAuthToken = "token" IntegrationAuthHmac = "hmac" IntegrationAuthBitbucket = "bitbucket" IntegrationAuthBasic = "basic" ) type IntegrationMatchType string const ( IntegrationMatchHeader IntegrationMatchType = "header" IntegrationMatchBody IntegrationMatchType = "body" ) type IntegrationMatchMethodType string const ( IntegrationMatchMethodEquals IntegrationMatchMethodType = "equals" IntegrationMatchMethodUnEquals IntegrationMatchMethodType = "unequals" IntegrationMatchMethodContains IntegrationMatchMethodType = "contains" ) type IntegrationBodyDataType string const ( IntegrationBodyDataJSON IntegrationBodyDataType = "json" IntegrationBodyDataString IntegrationBodyDataType = "string" ) type IntegrationVariableType string const ( IntegrationVariableEnvironment IntegrationVariableType = "environment" IntegrationVariableTaskParam IntegrationVariableType = "task" ) type IntegrationMatcher struct { ID int `db:"id" json:"id" backup:"-"` IntegrationID int `db:"integration_id" json:"integration_id" backup:"-"` Name string `db:"name" json:"name"` MatchType IntegrationMatchType `db:"match_type" json:"match_type"` Method IntegrationMatchMethodType `db:"method" json:"method"` BodyDataType IntegrationBodyDataType `db:"body_data_type" json:"body_data_type"` Key string `db:"key" json:"key"` Value string `db:"value" json:"value"` } type IntegrationExtractValueSource string const ( IntegrationExtractBodyValue IntegrationExtractValueSource = "body" IntegrationExtractHeaderValue IntegrationExtractValueSource = "header" ) type IntegrationExtractValue struct { ID int `db:"id" json:"id" backup:"-"` IntegrationID int `db:"integration_id" json:"integration_id" backup:"-"` Name string `db:"name" json:"name"` ValueSource IntegrationExtractValueSource `db:"value_source" json:"value_source"` BodyDataType IntegrationBodyDataType `db:"body_data_type" json:"body_data_type"` Key string `db:"key" json:"key"` Variable string `db:"variable" json:"variable"` VariableType IntegrationVariableType `db:"variable_type" json:"variable_type"` } type IntegrationAlias struct { ID int `db:"id" json:"-" backup:"-"` Alias string `db:"alias" json:"alias"` ProjectID int `db:"project_id" json:"project_id" backup:"-"` IntegrationID *int `db:"integration_id" json:"integration_id" backup:"-"` } type IntegrationAliasLevel int const ( IntegrationAliasProject = iota IntegrationAliasSingle ) type Integration struct { ID int `db:"id" json:"id" backup:"-"` Name string `db:"name" json:"name"` ProjectID int `db:"project_id" json:"project_id" backup:"-"` TemplateID int `db:"template_id" json:"template_id" backup:"-"` AuthMethod IntegrationAuthMethod `db:"auth_method" json:"auth_method"` AuthSecretID *int `db:"auth_secret_id" json:"auth_secret_id" backup:"-"` AuthHeader string `db:"auth_header" json:"auth_header"` AuthSecret AccessKey `db:"-" json:"-" backup:"-"` Searchable bool `db:"searchable" json:"searchable"` //TaskParams MapStringAnyField `db:"task_params" json:"task_params"` TaskParamsID *int `db:"task_params_id" json:"-" backup:"-"` TaskParams *TaskParams `db:"-" json:"task_params,omitempty" backup:"task_params"` } func (alias IntegrationAlias) ToAlias() Alias { return Alias{ ID: alias.ID, Alias: alias.Alias, ProjectID: alias.ProjectID, } } func (env *Integration) Validate() error { if env.Name == "" { return &ValidationError{"No Name set for integration"} } return nil } func (env *IntegrationMatcher) Validate() error { if env.MatchType == "" { return &ValidationError{"No Match Type set"} } else { if env.Key == "" { return &ValidationError{"No key set"} } if env.Value == "" { return &ValidationError{"No value set"} } } if env.Name == "" { return &ValidationError{"No Name set for integration"} } return nil } func (env *IntegrationExtractValue) Validate() error { if env.ValueSource == "" { return &ValidationError{"No Value Source defined"} } if env.Name == "" { return &ValidationError{"No Name set for integration"} } if env.ValueSource == IntegrationExtractBodyValue { if env.BodyDataType == "" { return &ValidationError{"Value Source but no body data type set"} } if env.BodyDataType == IntegrationBodyDataJSON { if env.Key == "" { return &ValidationError{"No Key set for JSON Body Data extraction."} } } } if env.ValueSource == IntegrationExtractHeaderValue { if env.Key == "" { return &ValidationError{"Value Source set but no Key set"} } } return nil } func (matcher *IntegrationMatcher) String() string { var builder strings.Builder // ID:1234 body/json key == value on Extractor: 1234 builder.WriteString("ID:" + strconv.Itoa(matcher.ID) + " " + string(matcher.MatchType)) if matcher.MatchType == IntegrationMatchBody { builder.WriteString("/" + string(matcher.BodyDataType)) } builder.WriteString(" " + matcher.Key + " ") switch matcher.Method { case IntegrationMatchMethodEquals: builder.WriteString("==") case IntegrationMatchMethodUnEquals: builder.WriteString("!=") case IntegrationMatchMethodContains: builder.WriteString(" contains ") default: } builder.WriteString(matcher.Value + ", on Extractor: " + strconv.Itoa(matcher.IntegrationID)) return builder.String() } func (value *IntegrationExtractValue) String() string { var builder strings.Builder // ID:1234 body/json from key as argument builder.WriteString("ID:" + strconv.Itoa(value.ID) + " " + string(value.ValueSource)) if value.ValueSource == IntegrationExtractBodyValue { builder.WriteString("/" + string(value.BodyDataType)) } builder.WriteString(" from " + value.Key + " as " + value.Variable) return builder.String() } ================================================ FILE: db/Inventory.go ================================================ package db type InventoryType string const ( InventoryStatic InventoryType = "static" InventoryStaticYaml InventoryType = "static-yaml" // InventoryFile means that it is path to the Ansible inventory file InventoryFile InventoryType = "file" InventoryTerraformWorkspace InventoryType = "terraform-workspace" InventoryTofuWorkspace InventoryType = "tofu-workspace" InventoryTerragruntWorkspace InventoryType = "terragrunt-workspace" ) func (i InventoryType) IsStatic() bool { return i == InventoryStatic || i == InventoryStaticYaml } // Inventory is the model of an ansible inventory file type Inventory struct { ID int `db:"id" json:"id" backup:"-"` Name string `db:"name" json:"name" binding:"required"` ProjectID int `db:"project_id" json:"project_id" backup:"-"` Inventory string `db:"inventory" json:"inventory"` // accesses hosts in inventory SSHKeyID *int `db:"ssh_key_id" json:"ssh_key_id" backup:"-"` SSHKey AccessKey `db:"-" json:"-" backup:"-"` BecomeKeyID *int `db:"become_key_id" json:"become_key_id" backup:"-"` BecomeKey AccessKey `db:"-" json:"-" backup:"-"` // static/file Type InventoryType `db:"type" json:"type"` // TemplateID is an ID of template which holds the inventory // It is not used now but can be used in feature for // inventories which can not be used more than one template // at once. TemplateID *int `db:"template_id" json:"template_id" backup:"-"` // RepositoryID is an ID of repo where inventory stored. // If null than inventory will be got from template repository. RepositoryID *int `db:"repository_id" json:"repository_id" backup:"-"` Repository *Repository `db:"-" json:"-" backup:"-"` // RunnerTag is a tag which allow join inventory to the runner. RunnerTag *string `db:"runner_tag" json:"runner_tag,omitempty"` } func (e Inventory) GetFilename() string { if e.Type != InventoryFile { return "" } return e.Inventory //return strings.TrimPrefix(e.Inventory, "/") } func (e Inventory) Validate() error { if e.RunnerTag == nil && *e.RunnerTag == "" { return &ValidationError{"template runner tag can not be empty"} } return nil } ================================================ FILE: db/Migration.go ================================================ package db import ( "fmt" "math" "slices" "strconv" "strings" "time" "github.com/semaphoreui/semaphore/pkg/tz" "github.com/semaphoreui/semaphore/util" ) // Migration represents sql schema version type Migration struct { Version string `db:"version" json:"version"` UpgradedDate *time.Time `db:"upgraded_date" json:"upgraded_date"` Notes *string `db:"notes" json:"notes"` } // HumanoidVersion adds a v to the VersionString func (m Migration) HumanoidVersion() string { return "v" + m.Version } func GetMigrations(dialect string) []Migration { var initScripts []Migration if dialect == util.DbDriverSQLite { initScripts = []Migration{{Version: "2.15.1.sqlite"}} } else { initScripts = []Migration{ {Version: "0.0.0"}, {Version: "1.0.0"}, {Version: "1.2.0"}, {Version: "1.3.0"}, {Version: "1.4.0"}, {Version: "1.5.0"}, {Version: "1.6.0"}, {Version: "1.7.0"}, {Version: "1.8.0"}, {Version: "1.9.0"}, {Version: "2.2.1"}, {Version: "2.3.0"}, {Version: "2.3.1"}, {Version: "2.3.2"}, {Version: "2.4.0"}, {Version: "2.5.0"}, {Version: "2.5.2"}, {Version: "2.7.1"}, {Version: "2.7.4"}, {Version: "2.7.6"}, {Version: "2.7.8"}, {Version: "2.7.9"}, {Version: "2.7.10"}, {Version: "2.7.12"}, {Version: "2.7.13"}, {Version: "2.8.0"}, {Version: "2.8.1"}, {Version: "2.8.7"}, {Version: "2.8.8"}, {Version: "2.8.20"}, {Version: "2.8.25"}, {Version: "2.8.26"}, {Version: "2.8.36"}, {Version: "2.8.38"}, {Version: "2.8.39"}, {Version: "2.8.40"}, {Version: "2.8.42"}, {Version: "2.8.51"}, {Version: "2.8.57"}, {Version: "2.8.58"}, {Version: "2.8.91"}, {Version: "2.9.6"}, {Version: "2.9.46"}, {Version: "2.9.60"}, {Version: "2.9.61"}, {Version: "2.9.62"}, {Version: "2.9.70"}, {Version: "2.9.97"}, {Version: "2.9.100"}, {Version: "2.10.12"}, {Version: "2.10.15"}, {Version: "2.10.16"}, {Version: "2.10.24"}, {Version: "2.10.26"}, {Version: "2.10.28"}, {Version: "2.10.33"}, {Version: "2.10.46"}, {Version: "2.11.5"}, {Version: "2.12.0"}, {Version: "2.12.3"}, {Version: "2.12.4"}, {Version: "2.12.5"}, {Version: "2.12.15"}, {Version: "2.13.0"}, {Version: "2.14.0"}, {Version: "2.14.1"}, {Version: "2.14.5"}, {Version: "2.14.7"}, {Version: "2.14.12"}, {Version: "2.15.0"}, {Version: "2.15.1"}, } } // add any new scripts here commonScripts := []Migration{ {Version: "2.15.2"}, {Version: "2.15.3"}, {Version: "2.15.4"}, {Version: "2.16.0"}, {Version: "2.16.1"}, {Version: "2.16.2"}, {Version: "2.16.3"}, {Version: "2.16.8"}, {Version: "2.16.50"}, {Version: "2.17.0"}, {Version: "2.17.1"}, {Version: "2.17.2"}, {Version: "2.17.15"}, } return append(initScripts, commonScripts...) } func (m Migration) Validate() error { if m.Version == "" { return fmt.Errorf("migration version is empty") } return nil } type MigrationVersion struct { Major int Minor int Patch int } func (m Migration) ParseVersion() (res MigrationVersion, err error) { parts := strings.Split(m.Version, ".") if len(parts) < 2 { err = fmt.Errorf("invalid migration version format %s", m.Version) return } res.Major, err = strconv.Atoi(parts[0]) if err != nil { err = fmt.Errorf("invalid migration version major part %s", parts[0]) return } res.Minor, err = strconv.Atoi(parts[1]) if err != nil { err = fmt.Errorf("invalid migration version minor part %s", parts[1]) return } if len(parts) < 3 { res.Patch = math.MaxInt return } res.Patch, err = strconv.Atoi(parts[2]) if err != nil { err = fmt.Errorf("invalid migration version patch part %s", parts[2]) return } return } func (v MigrationVersion) Compare(o MigrationVersion) int { if v.Major < o.Major { return -1 } else if v.Major > o.Major { return 1 } if v.Minor < o.Minor { return -1 } else if v.Minor > o.Minor { return 1 } if v.Patch < o.Patch { return -1 } else if v.Patch > o.Patch { return 1 } return 0 } func (m Migration) Compare(o Migration) int { mVer, err := m.ParseVersion() if err != nil { panic(err) } oVer, err := o.ParseVersion() if err != nil { panic(err) } return mVer.Compare(oVer) } func Rollback(d Store, targetVersion string) error { didRun := false migrations := GetMigrations(d.GetDialect()) slices.Reverse(migrations) for _, version := range migrations { if version.Compare(Migration{Version: targetVersion}) <= 0 { break } applied, err := d.IsMigrationApplied(version) if err != nil { return err } if !applied { continue } d.TryRollbackMigration(version) didRun = true } if didRun { fmt.Println("Rollback Finished") } return nil } func Migrate(d Store, targetVersion *string) error { didRun := false for _, version := range GetMigrations(d.GetDialect()) { if targetVersion != nil && version.Compare(Migration{Version: *targetVersion}) > 0 { break } if exists, err := d.IsMigrationApplied(version); err != nil || exists { if exists { continue } return err } didRun = true fmt.Printf("Executing migration %s (at %v)...\n", version.HumanoidVersion(), tz.Now()) if err := d.ApplyMigration(version); err != nil { fmt.Printf("Rolling back %s (time: %v)...\n", version.HumanoidVersion(), tz.Now()) d.TryRollbackMigration(version) return err } } if didRun { fmt.Println("Migrations Finished") } return nil } ================================================ FILE: db/Option.go ================================================ package db import ( "fmt" "regexp" ) type Option struct { Key string `db:"key" json:"key"` Value string `db:"value" json:"value"` } func ValidateOptionKey(key string) error { m, err := regexp.Match(`^[\w.]+$`, []byte(key)) if err != nil { return err } if !m { return fmt.Errorf("invalid key format") } return nil } ================================================ FILE: db/Project.go ================================================ package db import ( "time" ) // Project is the top level structure in Semaphore type Project struct { ID int `db:"id" json:"id" backup:"-"` Name string `db:"name" json:"name" binding:"required"` Created time.Time `db:"created" json:"created" backup:"-"` Alert bool `db:"alert" json:"alert,omitempty"` AlertChat *string `db:"alert_chat" json:"alert_chat,omitempty"` MaxParallelTasks int `db:"max_parallel_tasks" json:"max_parallel_tasks,omitempty"` Type string `db:"type" json:"type"` DefaultSecretStorageID *int `db:"default_secret_storage_id" json:"default_secret_storage_id,omitempty" backup:"-"` } ================================================ FILE: db/ProjectInvite.go ================================================ package db import ( "time" ) type ProjectInviteStatus string const ( ProjectInvitePending ProjectInviteStatus = "pending" ProjectInviteAccepted ProjectInviteStatus = "accepted" ProjectInviteDeclined ProjectInviteStatus = "declined" ProjectInviteExpired ProjectInviteStatus = "expired" ) func (s ProjectInviteStatus) IsValid() bool { switch s { case ProjectInvitePending, ProjectInviteAccepted, ProjectInviteDeclined, ProjectInviteExpired: return true default: return false } } type ProjectInvite struct { ID int `db:"id" json:"id" backup:"-"` ProjectID int `db:"project_id" json:"project_id"` UserID *int `db:"user_id" json:"user_id,omitempty"` // Can be null for email invites Email *string `db:"email" json:"email,omitempty"` // For email-based invites Role ProjectUserRole `db:"role" json:"role"` Status ProjectInviteStatus `db:"status" json:"status"` Token string `db:"token" json:"-"` // Secret token for accepting invite InviterUserID int `db:"inviter_user_id" json:"inviter_user_id"` // User who created the invite Created time.Time `db:"created" json:"created" backup:"-"` ExpiresAt *time.Time `db:"expires_at" json:"expires_at,omitempty"` AcceptedAt *time.Time `db:"accepted_at" json:"accepted_at,omitempty"` } type ProjectInviteWithUser struct { ProjectInvite InvitedByUser *User `json:"inviter_user,omitempty"` User *User `json:"user,omitempty"` } ================================================ FILE: db/ProjectInvite_test.go ================================================ package db import ( "testing" "time" ) func TestProjectInviteStatus_IsValid(t *testing.T) { tests := []struct { status ProjectInviteStatus valid bool }{ {ProjectInvitePending, true}, {ProjectInviteAccepted, true}, {ProjectInviteDeclined, true}, {ProjectInviteExpired, true}, {ProjectInviteStatus("invalid"), false}, {ProjectInviteStatus(""), false}, } for _, test := range tests { if test.status.IsValid() != test.valid { t.Errorf("Status %q: expected valid=%v, got %v", test.status, test.valid, test.status.IsValid()) } } } func TestProjectInvite_EmailBasedInvite(t *testing.T) { email := "test@example.com" invite := ProjectInvite{ ID: 1, ProjectID: 1, Email: &email, Role: ProjectManager, Status: ProjectInvitePending, Token: "test-token", InviterUserID: 1, Created: time.Now(), } if invite.UserID != nil { t.Error("Email-based invite should not have UserID") } if invite.Email == nil || *invite.Email != "test@example.com" { t.Errorf("Expected email 'test@example.com', got %v", invite.Email) } if invite.Status != ProjectInvitePending { t.Errorf("Expected status 'pending', got %s", invite.Status) } } func TestProjectInvite_UserBasedInvite(t *testing.T) { userID := 42 invite := ProjectInvite{ ID: 1, ProjectID: 1, UserID: &userID, Role: ProjectTaskRunner, Status: ProjectInvitePending, Token: "test-token", InviterUserID: 1, Created: time.Now(), } if invite.Email != nil { t.Error("User-based invite should not have Email") } if invite.UserID == nil || *invite.UserID != 42 { t.Errorf("Expected user_id 42, got %v", invite.UserID) } if invite.Role != ProjectTaskRunner { t.Errorf("Expected role 'task_runner', got %s", invite.Role) } } func TestProjectInvite_WithExpiration(t *testing.T) { expiresAt := time.Now().Add(7 * 24 * time.Hour) email := "test@example.com" invite := ProjectInvite{ ID: 1, ProjectID: 1, Email: &email, Role: ProjectManager, Status: ProjectInvitePending, Token: "test-token", InviterUserID: 1, Created: time.Now(), ExpiresAt: &expiresAt, } if invite.ExpiresAt == nil { t.Error("Expected ExpiresAt to be set") } if invite.AcceptedAt != nil { t.Error("AcceptedAt should be nil for pending invite") } } func TestProjectInvite_AcceptedInvite(t *testing.T) { acceptedAt := time.Now() email := "test@example.com" invite := ProjectInvite{ ID: 1, ProjectID: 1, Email: &email, Role: ProjectManager, Status: ProjectInviteAccepted, Token: "test-token", InviterUserID: 1, Created: time.Now().Add(-1 * time.Hour), AcceptedAt: &acceptedAt, } if invite.Status != ProjectInviteAccepted { t.Errorf("Expected status 'accepted', got %s", invite.Status) } if invite.AcceptedAt == nil { t.Error("AcceptedAt should be set for accepted invite") } } func TestProjectInviteWithUser_Structure(t *testing.T) { email := "test@example.com" invite := ProjectInvite{ ID: 1, ProjectID: 1, Email: &email, Role: ProjectManager, Status: ProjectInvitePending, Token: "test-token", InviterUserID: 1, Created: time.Now(), } invitedByUser := User{ ID: 1, Username: "admin", Email: "admin@example.com", Name: "Administrator", } inviteWithUser := ProjectInviteWithUser{ ProjectInvite: invite, InvitedByUser: &invitedByUser, User: nil, // Email-based invite } if inviteWithUser.ProjectInvite.ID != invite.ID { t.Error("ProjectInvite should be embedded correctly") } if inviteWithUser.InvitedByUser == nil { t.Error("InvitedByUser should be set") } if inviteWithUser.InvitedByUser.Username != "admin" { t.Errorf("Expected inviter username 'admin', got %s", inviteWithUser.InvitedByUser.Username) } if inviteWithUser.User != nil { t.Error("User should be nil for email-based invite") } } ================================================ FILE: db/ProjectStats.go ================================================ package db type ProjectStats struct { } ================================================ FILE: db/ProjectUser.go ================================================ package db type ProjectUserRole string const ( ProjectOwner ProjectUserRole = "owner" ProjectManager ProjectUserRole = "manager" ProjectTaskRunner ProjectUserRole = "task_runner" ProjectGuest ProjectUserRole = "guest" ProjectNone ProjectUserRole = "" ) type ProjectUserPermission int64 const ( CanRunProjectTasks ProjectUserPermission = 1 << iota CanUpdateProject CanManageProjectResources CanManageProjectUsers ) var rolePermissions = map[ProjectUserRole]ProjectUserPermission{ ProjectOwner: CanRunProjectTasks | CanManageProjectResources | CanUpdateProject | CanManageProjectUsers, ProjectManager: CanRunProjectTasks | CanManageProjectResources, ProjectTaskRunner: CanRunProjectTasks, ProjectGuest: 0, } func (r ProjectUserRole) IsValid() bool { _, ok := rolePermissions[r] return ok } type ProjectUser struct { ID int `db:"id" json:"-"` ProjectID int `db:"project_id" json:"project_id"` UserID int `db:"user_id" json:"user_id"` Role ProjectUserRole `db:"role" json:"role"` } func (r ProjectUserRole) Can(permissions ProjectUserPermission) bool { return (rolePermissions[r] & permissions) == permissions } func (r ProjectUserRole) GetPermissions() ProjectUserPermission { return rolePermissions[r] } ================================================ FILE: db/ProjectUser_test.go ================================================ package db import ( "testing" "github.com/stretchr/testify/assert" ) func TestProjectUsers_RoleCan(t *testing.T) { assert.True(t, ProjectManager.Can(CanManageProjectResources)) assert.False(t, ProjectManager.Can(CanUpdateProject)) } ================================================ FILE: db/Repository.go ================================================ package db import ( "fmt" "path" "regexp" "strconv" "strings" "github.com/semaphoreui/semaphore/util" ) type RepositoryType string const ( RepositoryGit RepositoryType = "git" RepositorySSH RepositoryType = "ssh" RepositoryHTTP RepositoryType = "https" RepositoryFile RepositoryType = "file" RepositoryLocal RepositoryType = "local" ) // Repository is the model for code stored in a git repository type Repository struct { ID int `db:"id" json:"id" backup:"-"` Name string `db:"name" json:"name" binding:"required"` ProjectID int `db:"project_id" json:"project_id" backup:"-"` GitURL string `db:"git_url" json:"git_url" binding:"required"` GitBranch string `db:"git_branch" json:"git_branch" binding:"required"` SSHKeyID int `db:"ssh_key_id" json:"ssh_key_id" binding:"required" backup:"-"` SSHKey AccessKey `db:"-" json:"-" backup:"-"` } func (r Repository) ClearCache() error { return util.ClearDir(util.Config.GetProjectTmpDir(r.ProjectID), true, r.getDirNamePrefix()) } func (r Repository) getDirNamePrefix() string { return "repository_" + strconv.Itoa(r.ID) + "_" } func (r Repository) GetDirName(templateID int) string { return r.getDirNamePrefix() + "template_" + strconv.Itoa(templateID) } // GetHomePath returns the per-template "home" directory with a "_home" suffix. // Currently this path is used for home-like directories such as ANSIBLE_HOME so // that parallel tasks from different templates get isolated home directories // (preventing concurrent ansible-galaxy writes to the same collections path), // while keeping these artifacts separate from the repository files. func (r Repository) GetHomePath(templateID int) string { return path.Join(util.Config.GetProjectTmpDir(r.ProjectID), r.GetDirName(templateID)+"_home") } // GetFullPath returns the path where the repository source code lives. // The repository is cloned directly into the template directory // (e.g. repository_15_template_114) without any subdirectory. func (r Repository) GetFullPath(templateID int) string { if r.GetType() == RepositoryLocal { return r.GetGitURL(true) } return path.Join(util.Config.GetProjectTmpDir(r.ProjectID), r.GetDirName(templateID)) } func (r Repository) GetGitURL(secure bool) string { url := r.GitURL if secure { return url } if r.GetType() == RepositoryHTTP { auth := "" switch r.SSHKey.Type { case AccessKeyLoginPassword: if r.SSHKey.LoginPassword.Login == "" { auth = r.SSHKey.LoginPassword.Password } else { auth = r.SSHKey.LoginPassword.Login + ":" + r.SSHKey.LoginPassword.Password } } if auth != "" { auth += "@" } re := regexp.MustCompile(`^(https?)://`) m := re.FindStringSubmatch(url) var protocol string if m == nil { panic(fmt.Errorf("invalid git url: %s", url)) } protocol = m[1] url = protocol + "://" + auth + r.GitURL[len(protocol)+3:] } return url } func (r Repository) GetType() RepositoryType { if strings.HasPrefix(r.GitURL, "/") { return RepositoryLocal } re := regexp.MustCompile(`^(\w+)://`) m := re.FindStringSubmatch(r.GitURL) if m == nil { return RepositorySSH } protocol := m[1] switch protocol { case "http", "https": return RepositoryHTTP default: return RepositoryType(protocol) } } func (r Repository) Validate() error { if r.Name == "" { return &ValidationError{"repository name can't be empty"} } if r.GitURL == "" { return &ValidationError{"repository url can't be empty"} } if r.GetType() != RepositoryLocal && r.GitBranch == "" { return &ValidationError{"repository branch can't be empty"} } return nil } ================================================ FILE: db/Repository_test.go ================================================ package db import ( "math/rand" "os" "path" "testing" "github.com/semaphoreui/semaphore/util" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestRepository_GetSchema(t *testing.T) { repo := Repository{GitURL: "https://example.com/hello/world"} schema := repo.GetType() assert.Equal(t, RepositoryHTTP, schema) } func TestRepository_ClearCache(t *testing.T) { util.Config = &util.ConfigType{ TmpPath: path.Join(os.TempDir(), util.RandString(rand.Intn(10-4)+4)), } repoDir := path.Join(util.Config.TmpPath, "project_0", "repository_123_55") err := os.MkdirAll(repoDir, 0755) require.NoError(t, err) repo := Repository{ID: 123} err = repo.ClearCache() require.NoError(t, err) _, err = os.Stat(repoDir) require.Error(t, err, "repo directory not deleted") assert.True(t, os.IsNotExist(err)) } func TestRepository_GetGitURL(t *testing.T) { for _, v := range []struct { Repository Repository ExpectedGitUrl string }{ { Repository: Repository{GitURL: "https://github.com/user/project.git", SSHKey: AccessKey{ Type: AccessKeyLoginPassword, LoginPassword: LoginPassword{ Login: "login", Password: "password", }, }, }, ExpectedGitUrl: "https://login:password@github.com/user/project.git", }, { Repository: Repository{GitURL: "https://github.com/user/project.git", SSHKey: AccessKey{ Type: AccessKeyLoginPassword, LoginPassword: LoginPassword{ Password: "password", }, }, }, ExpectedGitUrl: "https://password@github.com/user/project.git", }, } { gitUrl := v.Repository.GetGitURL(false) assert.Equal(t, v.ExpectedGitUrl, gitUrl, "wrong gitUrl") } } ================================================ FILE: db/Role.go ================================================ package db type Role struct { Slug string `db:"slug" json:"slug" backup:"-"` Name string `db:"name" json:"name"` Permissions ProjectUserPermission `db:"permissions" json:"permissions"` ProjectID *int `db:"project_id" json:"project_id"` } func ValidateRole(role Role) error { if role.Name == "" { return &ValidationError{Message: "Role name cannot be empty"} } return nil } type TemplateRolePerm struct { ID int `db:"id" json:"id"` RoleSlug string `db:"role_slug" json:"role_slug"` TemplateID int `db:"template_id" json:"template_id"` ProjectID int `db:"project_id" json:"project_id"` Permissions ProjectUserPermission `db:"permissions" json:"permissions"` } ================================================ FILE: db/Runner.go ================================================ package db import "time" type RunnerState string type Runner struct { ID int `db:"id" json:"id"` Token string `db:"token" json:"-"` ProjectID *int `db:"project_id" json:"project_id"` Webhook string `db:"webhook" json:"webhook"` MaxParallelTasks int `db:"max_parallel_tasks" json:"max_parallel_tasks"` Active bool `db:"active" json:"active"` Name string `db:"name" json:"name"` Tag string `db:"tag" json:"tag"` Touched *time.Time `db:"touched" json:"touched"` CleaningRequested *time.Time `db:"cleaning_requested" json:"cleaning_requested"` PublicKey *string `db:"public_key" json:"-"` } type RunnerTag struct { Tag string `db:"-" json:"tag"` NumberOfRunners int `db:"-" json:"number_of_runners"` } ================================================ FILE: db/Schedule.go ================================================ package db import "time" const ( ScheduleTypeCron = "" ScheduleTypeRunAt = "run_at" ) type Schedule struct { ID int `db:"id" json:"id" backup:"-"` ProjectID int `db:"project_id" json:"project_id" backup:"-"` TemplateID int `db:"template_id" json:"template_id" backup:"-"` CronFormat string `db:"cron_format" json:"cron_format"` Name string `db:"name" json:"name"` Active bool `db:"active" json:"active"` Type string `db:"type" json:"type"` DeleteAfterRun bool `db:"delete_after_run" json:"delete_after_run"` LastCommitHash *string `db:"last_commit_hash" json:"-" backup:"-"` RepositoryID *int `db:"repository_id" json:"repository_id" backup:"-"` RunAt *time.Time `db:"run_at" json:"run_at,omitempty"` TaskParamsID *int `db:"task_params_id" json:"-" backup:"-"` TaskParams *TaskParams `db:"-" json:"task_params,omitempty" backup:"task_params"` } type ScheduleWithTpl struct { Schedule TemplateName string `db:"tpl_name" json:"tpl_name"` } ================================================ FILE: db/SecretStorage.go ================================================ package db type SecretStorageType string const ( SecretStorageTypeLocal SecretStorageType = "local" SecretStorageTypeVault SecretStorageType = "vault" SecretStorageTypeDvls SecretStorageType = "dvls" ) type SecretStorage struct { ID int `db:"id" json:"id" backup:"-"` ProjectID int `db:"project_id" json:"project_id" backup:"-"` Name string `db:"name" json:"name"` Type SecretStorageType `db:"type" json:"type"` Params MapStringAnyField `db:"params" json:"params"` ReadOnly bool `db:"readonly" json:"readonly"` SourceStorageType *AccessKeySourceStorageType `db:"-" json:"source_storage_type,omitempty" backup:"-"` // Secret is a source value: literal secret for local storage, // env var name for "env", or file path for "file". Secret string `db:"-" json:"secret,omitempty" backup:"-"` } ================================================ FILE: db/Session.go ================================================ package db import "time" type SessionVerificationMethod int const ( SessionVerificationNone SessionVerificationMethod = iota SessionVerificationTotp SessionVerificationEmail ) // Session is a connection to the API type Session struct { ID int `db:"id" json:"id"` UserID int `db:"user_id" json:"user_id"` Created time.Time `db:"created" json:"created"` LastActive time.Time `db:"last_active" json:"last_active"` IP string `db:"ip" json:"ip"` UserAgent string `db:"user_agent" json:"user_agent"` Expired bool `db:"expired" json:"expired"` VerificationMethod SessionVerificationMethod `db:"verification_method" json:"verification_method"` Verified bool `db:"verified" json:"verified"` } func (s *Session) IsVerified() bool { if s.VerificationMethod == SessionVerificationNone { return true } return s.Verified } ================================================ FILE: db/Store.go ================================================ package db import ( "database/sql/driver" "encoding/json" "errors" "reflect" "strings" "time" "github.com/semaphoreui/semaphore/pkg/task_logger" log "github.com/sirupsen/logrus" ) const databaseTimeFormat = "2006-01-02T15:04:05:99Z" // GetParsedTime returns the timestamp as it will retrieved from the database // This allows us to create timestamp consistency on return values from create requests func GetParsedTime(t time.Time) time.Time { parsedTime, err := time.Parse(databaseTimeFormat, t.Format(databaseTimeFormat)) if err != nil { log.Error(err) } return parsedTime } func ObjectToJSON(obj any) *string { if obj == nil || (reflect.ValueOf(obj).Kind() == reflect.Ptr && reflect.ValueOf(obj).IsNil()) || (reflect.ValueOf(obj).Kind() == reflect.Slice && reflect.ValueOf(obj).IsZero()) { return nil } bytes, err := json.Marshal(obj) if err != nil { return nil } str := string(bytes) return &str } type OwnershipFilter struct { WithoutOwnerOnly bool TemplateID *int EnvironmentID *int } type RetrieveQueryParams struct { Offset int Count int SortBy string SortInverted bool Filter string Ownership OwnershipFilter TaskFilter *TaskFilter } type ObjectReferrer struct { ID int `json:"id"` Name string `json:"name"` } type ObjectReferrers struct { Templates []ObjectReferrer `json:"templates"` Inventories []ObjectReferrer `json:"inventories"` Repositories []ObjectReferrer `json:"repositories"` Integrations []ObjectReferrer `json:"integrations"` Schedules []ObjectReferrer `json:"schedules"` AccessKeys []ObjectReferrer `json:"access_keys"` } type IntegrationReferrers struct { IntegrationMatchers []ObjectReferrer `json:"matchers"` IntegrationExtractValues []ObjectReferrer `json:"values"` } type IntegrationExtractorChildReferrers struct { Integrations []ObjectReferrer `json:"integrations"` } func containsStr(arr []string, str string) bool { for _, a := range arr { if a == str { return true } } return false } func (p *RetrieveQueryParams) Validate(props ObjectProps) (res RetrieveQueryParams, err error) { if p.Offset > 0 && p.Count <= 0 { err = &ValidationError{"offset cannot be without limit"} return } if p.Count < 0 { err = &ValidationError{"count must be positive"} return } if p.Offset < 0 { err = &ValidationError{"offset must be positive"} return } if p.SortBy != "" { if !containsStr(props.SortableColumns, p.SortBy) { err = &ValidationError{"invalid sort column"} return } } res = *p return } func (f *OwnershipFilter) GetOwnerID(ownership ObjectProps) *int { switch ownership.ReferringColumnSuffix { case "template_id": return f.TemplateID case "environment_id": return f.EnvironmentID default: return nil } } func (f *OwnershipFilter) SetOwnerID(ownership ObjectProps, ownerID int) { switch ownership.ReferringColumnSuffix { case "template_id": f.TemplateID = &ownerID case "environment_id": f.EnvironmentID = &ownerID } } // ObjectProps describe database entities. // It mainly used for NoSQL implementations (currently BoltDB) to preserve same // data structure of different implementations and easy change it if required. type ObjectProps struct { TableName string Type reflect.Type // to which type the table bust be mapped. IsGlobal bool // doesn't belong to other table, for example to project or user. ReferringColumnSuffix string PrimaryColumnName string SortableColumns []string DefaultSortingColumn string SortInverted bool // sort from high to low object ID by default. It is useful for some NoSQL implementations. Ownerships []*ObjectProps SelectColumns []string } var ErrNotFound = errors.New("no rows in result set") var ErrInvalidOperation = errors.New("invalid operation") type ValidationError struct { Message string } func NewValidationError(message string) *ValidationError { return &ValidationError{Message: message} } func (e *ValidationError) Error() string { return e.Message } type TaskStatUnit string const TaskStatUnitDay TaskStatUnit = "day" const TaskStatUnitWeek TaskStatUnit = "week" const TaskStatUnitMonth TaskStatUnit = "month" type TaskFilter struct { Start *time.Time `json:"start"` End *time.Time `json:"end"` UserID *int `json:"user_id"` Status []task_logger.TaskStatus } type TaskStat struct { Date string `json:"date"` CountByStatus map[task_logger.TaskStatus]int `json:"count_by_status"` AvgDuration int `json:"avg_duration"` } // ConnectionManager handles database connection lifecycle type ConnectionManager interface { // Connect connects to the database. // Token parameter used if PermanentConnection returns false. // Token used for debugging of session connections. Connect(token string) Close(token string) // PermanentConnection returns true if connection should be kept from start to finish of the app. // This mode is suitable for MySQL and Postgres but not for BoltDB. // For BoltDB we should reconnect for each request because BoltDB support only one connection at time. PermanentConnection() bool } // MigrationManager handles database migrations type MigrationManager interface { GetDialect() string // IsInitialized indicates is database already initialized, or it is empty. // The method is useful for creating required entities in database during first run. IsInitialized() (bool, error) // IsMigrationApplied queries the database to see if a migration table with // this version id exists already IsMigrationApplied(version Migration) (bool, error) // ApplyMigration runs executes a database migration ApplyMigration(version Migration) error // TryRollbackMigration attempts to roll back the database to an earlier version // if a rollback exists TryRollbackMigration(version Migration) } // OptionsManager handles system options type OptionsManager interface { GetOptions(params RetrieveQueryParams) (map[string]string, error) GetOption(key string) (string, error) SetOption(key string, value string) error DeleteOption(key string) error DeleteOptions(filter string) error } // UserManager handles user-related operations type UserManager interface { GetProUserCount() (int, error) GetUserCount() (int, error) GetUsers(params RetrieveQueryParams) ([]User, error) CreateUserWithoutPassword(user User) (User, error) CreateUser(user UserWithPwd) (User, error) DeleteUser(userID int) error UpdateUser(user UserWithPwd) error ImportUser(user UserWithPwd) (User, error) SetUserPassword(userID int, password string) error AddTotpVerification(userID int, url string, recoveryHash string) (UserTotp, error) DeleteTotpVerification(userID int, totpID int) error AddEmailOtpVerification(userID int, code string) (UserEmailOtp, error) DeleteEmailOtpVerification(userID int, totpID int) error GetUser(userID int) (User, error) GetUserByLoginOrEmail(login string, email string) (User, error) GetAllAdmins() ([]User, error) GetNodeCount() (int, error) GetUiCount() (int, error) } // ProjectStore handles project-related operations type ProjectStore interface { GetProject(projectID int) (Project, error) GetAllProjects() ([]Project, error) GetProjects(userID int) ([]Project, error) CreateProject(project Project) (Project, error) DeleteProject(projectID int) error UpdateProject(project Project) error GetProjectUsers(projectID int, params RetrieveQueryParams) ([]UserWithProjectRole, error) CreateProjectUser(projectUser ProjectUser) (ProjectUser, error) DeleteProjectUser(projectID int, userID int) error GetProjectUser(projectID int, userID int) (ProjectUser, error) UpdateProjectUser(projectUser ProjectUser) error } type ProjectInviteRepository interface { // Project invites GetProjectInvites(projectID int, params RetrieveQueryParams) ([]ProjectInviteWithUser, error) CreateProjectInvite(invite ProjectInvite) (ProjectInvite, error) GetProjectInvite(projectID int, inviteID int) (ProjectInvite, error) GetProjectInviteByToken(token string) (ProjectInvite, error) UpdateProjectInvite(invite ProjectInvite) error DeleteProjectInvite(projectID int, inviteID int) error } // TemplateManager handles template-related operations type TemplateManager interface { GetTemplates(projectID int, filter TemplateFilter, params RetrieveQueryParams) ([]Template, error) GetTemplatesWithPermissions(projectID int, userID int, filter TemplateFilter, params RetrieveQueryParams) ([]TemplateWithPerms, error) GetTemplateRefs(projectID int, templateID int) (ObjectReferrers, error) CreateTemplate(template Template) (Template, error) UpdateTemplate(template Template) error GetTemplate(projectID int, templateID int) (Template, error) DeleteTemplate(projectID int, templateID int) error SetTemplateDescription(projectID int, templateID int, description string) error GetTemplateVaults(projectID int, templateID int) ([]TemplateVault, error) CreateTemplateVault(vault TemplateVault) (TemplateVault, error) UpdateTemplateVaults(projectID int, templateID int, vaults []TemplateVault) error GetTemplatePermission(projectID int, templateID int, userID int) (ProjectUserPermission, error) GetTemplateRoles(projectID int, templateID int) ([]TemplateRolePerm, error) CreateTemplateRole(role TemplateRolePerm) (TemplateRolePerm, error) DeleteTemplateRole(projectID int, templateID int, permID int) error UpdateTemplateRole(role TemplateRolePerm) error GetTemplateRole(projectID int, templateID int, permID int) (TemplateRolePerm, error) } // InventoryManager handles inventory-related operations type InventoryManager interface { GetInventory(projectID int, inventoryID int) (Inventory, error) GetInventoryRefs(projectID int, inventoryID int) (ObjectReferrers, error) GetInventories(projectID int, params RetrieveQueryParams, types []InventoryType) ([]Inventory, error) UpdateInventory(inventory Inventory) error CreateInventory(inventory Inventory) (Inventory, error) DeleteInventory(projectID int, inventoryID int) error } // RepositoryManager handles repository-related operations type RepositoryManager interface { GetRepository(projectID int, repositoryID int) (Repository, error) GetRepositoryRefs(projectID int, repositoryID int) (ObjectReferrers, error) GetRepositories(projectID int, params RetrieveQueryParams) ([]Repository, error) UpdateRepository(repository Repository) error CreateRepository(repository Repository) (Repository, error) DeleteRepository(projectID int, repositoryID int) error } // EnvironmentManager handles environment-related operations type EnvironmentManager interface { GetEnvironment(projectID int, environmentID int) (Environment, error) GetEnvironmentRefs(projectID int, environmentID int) (ObjectReferrers, error) GetEnvironments(projectID int, params RetrieveQueryParams) ([]Environment, error) UpdateEnvironment(env Environment) error CreateEnvironment(env Environment) (Environment, error) DeleteEnvironment(projectID int, templateID int) error GetEnvironmentSecrets(projectID int, environmentID int) ([]AccessKey, error) } type GetAccessKeyOptions struct { Owner AccessKeyOwner IgnoreOwner bool EnvironmentID *int StorageID *int SourceStorageID *int } // AccessKeyManager handles access key-related operations type AccessKeyManager interface { GetAccessKey(projectID int, accessKeyID int) (AccessKey, error) GetAccessKeyRefs(projectID int, accessKeyID int) (ObjectReferrers, error) GetAccessKeys(projectID int, options GetAccessKeyOptions, params RetrieveQueryParams) ([]AccessKey, error) RekeyAccessKeys(oldKey string) error UpdateAccessKey(accessKey AccessKey) error CreateAccessKey(accessKey AccessKey) (AccessKey, error) DeleteAccessKey(projectID int, accessKeyID int) error } // IntegrationManager handles integration-related operations type IntegrationManager interface { CreateIntegration(integration Integration) (newIntegration Integration, err error) GetIntegrations(projectID int, params RetrieveQueryParams, includeTaskParams bool) ([]Integration, error) GetIntegration(projectID int, integrationID int) (integration Integration, err error) UpdateIntegration(integration Integration) error GetIntegrationRefs(projectID int, integrationID int) (IntegrationReferrers, error) DeleteIntegration(projectID int, integrationID int) error CreateIntegrationExtractValue(projectId int, value IntegrationExtractValue) (newValue IntegrationExtractValue, err error) GetIntegrationExtractValues(projectID int, params RetrieveQueryParams, integrationID int) ([]IntegrationExtractValue, error) GetIntegrationExtractValue(projectID int, valueID int, integrationID int) (value IntegrationExtractValue, err error) UpdateIntegrationExtractValue(projectID int, integrationExtractValue IntegrationExtractValue) error GetIntegrationExtractValueRefs(projectID int, valueID int, integrationID int) (IntegrationExtractorChildReferrers, error) DeleteIntegrationExtractValue(projectID int, valueID int, integrationID int) error CreateIntegrationMatcher(projectID int, matcher IntegrationMatcher) (newMatcher IntegrationMatcher, err error) GetIntegrationMatchers(projectID int, params RetrieveQueryParams, integrationID int) ([]IntegrationMatcher, error) GetIntegrationMatcher(projectID int, matcherID int, integrationID int) (matcher IntegrationMatcher, err error) UpdateIntegrationMatcher(projectID int, integrationMatcher IntegrationMatcher) error GetIntegrationMatcherRefs(projectID int, matcherID int, integrationID int) (IntegrationExtractorChildReferrers, error) DeleteIntegrationMatcher(projectID int, matcherID int, integrationID int) error CreateIntegrationAlias(alias IntegrationAlias) (IntegrationAlias, error) GetIntegrationAliases(projectID int, integrationID *int) ([]IntegrationAlias, error) GetIntegrationsByAlias(alias string) ([]Integration, IntegrationAliasLevel, error) DeleteIntegrationAlias(projectID int, aliasID int) error } // SessionManager handles session-related operations type SessionManager interface { GetSession(userID int, sessionID int) (Session, error) CreateSession(session Session) (Session, error) ExpireSession(userID int, sessionID int) error TouchSession(userID int, sessionID int) error SetSessionVerificationMethod(userID int, sessionID int, verificationMethod SessionVerificationMethod) error VerifySession(userID int, sessionID int) error } // TokenManager handles token-related operations type TokenManager interface { GetAPITokens(userID int) ([]APIToken, error) CreateAPIToken(token APIToken) (APIToken, error) GetAPIToken(tokenID string) (APIToken, error) ExpireAPIToken(userID int, tokenID string) error DeleteAPIToken(userID int, tokenID string) error } // TaskManager handles task-related operations type TaskManager interface { CreateTask(task Task, maxTasks int) (Task, error) UpdateTask(task Task) error GetTemplateTasks(projectID int, templateID int, params RetrieveQueryParams) ([]TaskWithTpl, error) GetProjectTasks(projectID int, params RetrieveQueryParams) ([]TaskWithTpl, error) GetTask(projectID int, taskID int) (Task, error) DeleteTaskWithOutputs(projectID int, taskID int) error GetTaskOutputs(projectID int, taskID int, params RetrieveQueryParams) ([]TaskOutput, error) CreateTaskOutput(output TaskOutput) (TaskOutput, error) InsertTaskOutputBatch(output []TaskOutput) error CreateTaskStage(stage TaskStage) (TaskStage, error) EndTaskStage(taskID int, stageID int, end time.Time) error CreateTaskStageResult(taskID int, stageID int, result map[string]any) error GetTaskStages(projectID int, taskID int) ([]TaskStageWithResult, error) GetTaskStageResult(projectID int, taskID int, stageID int) (TaskStageResult, error) GetTaskStageOutputs(projectID int, taskID int, stageID int) ([]TaskOutput, error) GetTaskStats(projectID int, templateID *int, unit TaskStatUnit, filter TaskFilter) ([]TaskStat, error) } type AnsibleTaskRepository interface { CreateAnsibleTaskHost(host AnsibleTaskHost) error CreateAnsibleTaskError(error AnsibleTaskError) error GetAnsibleTaskHosts(projectID int, taskID int) ([]AnsibleTaskHost, error) GetAnsibleTaskErrors(projectID int, taskID int) ([]AnsibleTaskError, error) } // ScheduleManager handles schedule-related operations type ScheduleManager interface { GetSchedules() ([]Schedule, error) GetProjectSchedules(projectID int, includeTaskParams bool, includeCommitCheckers bool) ([]ScheduleWithTpl, error) GetTemplateSchedules(projectID int, templateID int, onlyCommitCheckers bool) ([]Schedule, error) CreateSchedule(schedule Schedule) (Schedule, error) UpdateSchedule(schedule Schedule) error SetScheduleCommitHash(projectID int, scheduleID int, hash string) error SetScheduleActive(projectID int, scheduleID int, active bool) error GetSchedule(projectID int, scheduleID int) (Schedule, error) DeleteSchedule(projectID int, scheduleID int) error } // ViewManager handles view-related operations type ViewManager interface { GetView(projectID int, viewID int) (View, error) GetViews(projectID int) ([]View, error) UpdateView(view View) error CreateView(view View) (View, error) DeleteView(projectID int, viewID int) error SetViewPositions(projectID int, viewPositions map[int]int) error } // RunnerManager handles runner-related operations type RunnerManager interface { GetRunner(projectID int, runnerID int) (Runner, error) GetRunners(projectID int, activeOnly bool, tag *string) ([]Runner, error) DeleteRunner(projectID int, runnerID int) error GetRunnerByToken(token string) (Runner, error) GetGlobalRunner(runnerID int) (Runner, error) GetAllRunners(activeOnly bool, globalOnly bool) ([]Runner, error) DeleteGlobalRunner(runnerID int) error UpdateRunner(runner Runner) error CreateRunner(runner Runner) (Runner, error) TouchRunner(runner Runner) (err error) ClearRunnerCache(runner Runner) (err error) GetRunnerTags(projectID int) ([]RunnerTag, error) GetRunnerCount() (int, error) } // EventManager handles event-related operations type EventManager interface { CreateEvent(event Event) (Event, error) GetUserEvents(userID int, params RetrieveQueryParams) ([]Event, error) GetEvents(projectID int, params RetrieveQueryParams) ([]Event, error) GetAllEvents(params RetrieveQueryParams) ([]Event, error) } type SecretStorageRepository interface { GetSecretStorages(projectID int) ([]SecretStorage, error) CreateSecretStorage(storage SecretStorage) (SecretStorage, error) GetSecretStorage(projectID int, storageID int) (SecretStorage, error) UpdateSecretStorage(storage SecretStorage) error GetSecretStorageRefs(projectID int, storageID int) (ObjectReferrers, error) DeleteSecretStorage(projectID int, storageID int) error } type RoleRepository interface { GetGlobalRoleBySlug(slug string) (Role, error) GetProjectOrGlobalRoleBySlug(projectID int, slug string) (Role, error) GetProjectRole(projectID int, slug string) (Role, error) GetProjectRoles(projectID int) ([]Role, error) GetGlobalRoles() ([]Role, error) UpdateRole(role Role) error CreateRole(role Role) (Role, error) DeleteRole(slug string) error } // Store is the main interface that aggregates all specialized interfaces type Store interface { ConnectionManager MigrationManager OptionsManager UserManager ProjectStore ProjectInviteRepository TemplateManager InventoryManager RepositoryManager EnvironmentManager AccessKeyManager IntegrationManager SessionManager TokenManager TaskManager ScheduleManager ViewManager RunnerManager EventManager SecretStorageRepository RoleRepository } var AccessKeyProps = ObjectProps{ TableName: "access_key", Type: reflect.TypeOf(AccessKey{}), PrimaryColumnName: "id", ReferringColumnSuffix: "key_id", SortableColumns: []string{"name", "type"}, DefaultSortingColumn: "name", } var IntegrationProps = ObjectProps{ TableName: "project__integration", Type: reflect.TypeOf(Integration{}), PrimaryColumnName: "id", ReferringColumnSuffix: "integration_id", SortableColumns: []string{"name"}, DefaultSortingColumn: "name", } var TaskParamsProps = ObjectProps{ TableName: "project__task_params", Type: reflect.TypeOf(TaskParams{}), PrimaryColumnName: "id", ReferringColumnSuffix: "params_id", } var IntegrationExtractValueProps = ObjectProps{ TableName: "project__integration_extract_value", Type: reflect.TypeOf(IntegrationExtractValue{}), PrimaryColumnName: "id", SortableColumns: []string{"name"}, DefaultSortingColumn: "name", } var IntegrationMatcherProps = ObjectProps{ TableName: "project__integration_matcher", Type: reflect.TypeOf(IntegrationMatcher{}), PrimaryColumnName: "id", SortableColumns: []string{"name"}, DefaultSortingColumn: "name", } var IntegrationAliasProps = ObjectProps{ TableName: "project__integration_alias", Type: reflect.TypeOf(IntegrationAlias{}), PrimaryColumnName: "id", } var EnvironmentProps = ObjectProps{ TableName: "project__environment", Type: reflect.TypeOf(Environment{}), PrimaryColumnName: "id", ReferringColumnSuffix: "environment_id", SortableColumns: []string{"name"}, DefaultSortingColumn: "name", } var InventoryProps = ObjectProps{ TableName: "project__inventory", Type: reflect.TypeOf(Inventory{}), PrimaryColumnName: "id", ReferringColumnSuffix: "inventory_id", SortableColumns: []string{"name"}, DefaultSortingColumn: "name", Ownerships: []*ObjectProps{&TemplateProps}, } var RepositoryProps = ObjectProps{ TableName: "project__repository", Type: reflect.TypeOf(Repository{}), PrimaryColumnName: "id", ReferringColumnSuffix: "repository_id", DefaultSortingColumn: "name", } var TemplateProps = ObjectProps{ TableName: "project__template", Type: reflect.TypeOf(Template{}), PrimaryColumnName: "id", ReferringColumnSuffix: "template_id", SortableColumns: []string{"name", "playbook", "inventory", "environment", "repository"}, DefaultSortingColumn: "name", } var ProjectUserProps = ObjectProps{ TableName: "project__user", Type: reflect.TypeOf(ProjectUser{}), PrimaryColumnName: "user_id", } var ProjectInviteProps = ObjectProps{ TableName: "project__invite", Type: reflect.TypeOf(ProjectInvite{}), PrimaryColumnName: "id", ReferringColumnSuffix: "invite_id", SortableColumns: []string{"created", "status", "role"}, DefaultSortingColumn: "created", } var ProjectProps = ObjectProps{ TableName: "project", Type: reflect.TypeOf(Project{}), PrimaryColumnName: "id", ReferringColumnSuffix: "project_id", DefaultSortingColumn: "name", IsGlobal: true, } var ScheduleProps = ObjectProps{ TableName: "project__schedule", Type: reflect.TypeOf(Schedule{}), PrimaryColumnName: "id", Ownerships: []*ObjectProps{&ProjectProps}, } var SecretStorageProps = ObjectProps{ TableName: "project__secret_storage", ReferringColumnSuffix: "storage_id", Type: reflect.TypeOf(SecretStorage{}), PrimaryColumnName: "id", Ownerships: []*ObjectProps{&ProjectProps}, } var RoleProps = ObjectProps{ TableName: "role", Type: reflect.TypeOf(Role{}), PrimaryColumnName: "slug", IsGlobal: true, SortableColumns: []string{"name"}, } var UserProps = ObjectProps{ TableName: "user", Type: reflect.TypeOf(User{}), PrimaryColumnName: "id", IsGlobal: true, SortableColumns: []string{"name", "username", "email", "role"}, } var SessionProps = ObjectProps{ TableName: "session", Type: reflect.TypeOf(Session{}), PrimaryColumnName: "id", } var TokenProps = ObjectProps{ TableName: "user__token", Type: reflect.TypeOf(APIToken{}), PrimaryColumnName: "id", } var TaskProps = ObjectProps{ TableName: "task", Type: reflect.TypeOf(Task{}), PrimaryColumnName: "id", IsGlobal: true, SortInverted: true, } var TaskOutputProps = ObjectProps{ TableName: "task__output", Type: reflect.TypeOf(TaskOutput{}), } var TaskStageProps = ObjectProps{ TableName: "task__stage", Type: reflect.TypeOf(TaskStage{}), } var TaskStageResultProps = ObjectProps{ TableName: "task__stage_result", Type: reflect.TypeOf(TaskStageResult{}), } var ViewProps = ObjectProps{ TableName: "project__view", Type: reflect.TypeOf(View{}), PrimaryColumnName: "id", DefaultSortingColumn: "position", } var GlobalRunnerProps = ObjectProps{ TableName: "runner", Type: reflect.TypeOf(Runner{}), PrimaryColumnName: "id", DefaultSortingColumn: "id", SortInverted: true, IsGlobal: true, } var OptionProps = ObjectProps{ TableName: "option", Type: reflect.TypeOf(Option{}), PrimaryColumnName: "key", IsGlobal: true, } var TemplateVaultProps = ObjectProps{ TableName: "project__template_vault", Type: reflect.TypeOf(TemplateVault{}), PrimaryColumnName: "id", ReferringColumnSuffix: "template_id", } var UserTotpProps = ObjectProps{ TableName: "user__totp", Type: reflect.TypeOf(UserTotp{}), PrimaryColumnName: "id", } func (p ObjectProps) GetReferringFieldsFrom(t reflect.Type) (fields []string, err error) { if p.ReferringColumnSuffix == "" { err = errors.New("referring column suffix is not set") return } n := t.NumField() for i := 0; i < n; i++ { if !strings.HasSuffix(t.Field(i).Tag.Get("db"), p.ReferringColumnSuffix) { continue } fields = append(fields, t.Field(i).Tag.Get("db")) } for i := 0; i < n; i++ { if t.Field(i).Tag != "" || t.Field(i).Type.Kind() != reflect.Struct { continue } var nested []string nested, err = p.GetReferringFieldsFrom(t.Field(i).Type) if err != nil { return } fields = append(fields, nested...) } return } func StoreSession(store Store, token string, callback func()) { if !store.PermanentConnection() { store.Connect(token) } callback() if !store.PermanentConnection() { store.Close(token) } } func ValidateRepository(store Store, repo *Repository) (err error) { _, err = store.GetAccessKey(repo.ProjectID, repo.SSHKeyID) return } func ValidateInventory(store Store, inventory *Inventory) (err error) { if inventory.SSHKeyID != nil { _, err = store.GetAccessKey(inventory.ProjectID, *inventory.SSHKeyID) } if err != nil { return } if inventory.BecomeKeyID != nil { _, err = store.GetAccessKey(inventory.ProjectID, *inventory.BecomeKeyID) } if err != nil { return } if inventory.TemplateID != nil { _, err = store.GetTemplate(inventory.ProjectID, *inventory.TemplateID) } return } type StringArrayField []string func (m *StringArrayField) Scan(value any) error { if value == nil { *m = nil return nil } switch v := value.(type) { case []byte: return json.Unmarshal(v, m) case string: return json.Unmarshal([]byte(v), m) default: return errors.New("unsupported type for MapStringAnyField") } } // Value implements the driver.Valuer interface for MapStringAnyField func (m *StringArrayField) Value() (driver.Value, error) { if m == nil { return nil, nil } return json.Marshal(m) } type MapStringAnyField map[string]any func (m *MapStringAnyField) Scan(value any) error { if value == nil { *m = nil return nil } switch v := value.(type) { case []byte: return json.Unmarshal(v, m) case string: return json.Unmarshal([]byte(v), m) default: return errors.New("unsupported type for MapStringAnyField") } } // Value implements the driver.Valuer interface for MapStringAnyField // DO NOT ADD *, It breaks method call func (m MapStringAnyField) Value() (driver.Value, error) { if m == nil { return nil, nil } return json.Marshal(m) } ================================================ FILE: db/Store_test.go ================================================ package db import ( "testing" "github.com/stretchr/testify/assert" ) func TestObjectToJSON(t *testing.T) { v := &SurveyVar{ Name: "test", Title: "Test", } s := ObjectToJSON(v) assert.NotNil(t, s) assert.Equal(t, "{\"name\":\"test\",\"title\":\"Test\"}", *s) } func TestObjectToJSON2(t *testing.T) { var v *SurveyVar = nil s := ObjectToJSON(v) assert.Nil(t, s) } func TestObjectToJSON3(t *testing.T) { v := SurveyVar{ Name: "test", Title: "Test", } s := ObjectToJSON(v) assert.NotNil(t, s) assert.Equal(t, "{\"name\":\"test\",\"title\":\"Test\"}", *s) } ================================================ FILE: db/Task.go ================================================ package db import ( "encoding/json" "errors" "fmt" "strings" "time" "github.com/semaphoreui/semaphore/pkg/tz" "github.com/go-gorp/gorp/v3" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/util" ) type DefaultTaskParams struct { } type TerraformTaskParams struct { Plan bool `json:"plan"` Destroy bool `json:"destroy"` AutoApprove bool `json:"auto_approve"` Upgrade bool `json:"upgrade"` Reconfigure bool `json:"reconfigure"` } type AnsibleTaskParams struct { Debug bool `json:"debug"` DebugLevel int `json:"debug_level"` DryRun bool `json:"dry_run"` Diff bool `json:"diff"` Limit []string `json:"limit"` Tags []string `json:"tags"` SkipTags []string `json:"skip_tags"` } // Task is a model of a task which will be executed by the runner type Task struct { ID int `db:"id" json:"id"` TemplateID int `db:"template_id" json:"template_id" binding:"required"` ProjectID int `db:"project_id" json:"project_id"` Status task_logger.TaskStatus `db:"status" json:"status"` // override variables Playbook string `db:"playbook" json:"playbook"` Environment string `db:"environment" json:"environment,omitempty"` Secret string `db:"-" json:"secret,omitempty"` Arguments *string `db:"arguments" json:"arguments,omitempty"` GitBranch *string `db:"git_branch" json:"git_branch,omitempty"` UserID *int `db:"user_id" json:"user_id,omitempty"` IntegrationID *int `db:"integration_id" json:"integration_id,omitempty"` ScheduleID *int `db:"schedule_id" json:"schedule_id,omitempty"` Created time.Time `db:"created" json:"created"` Start *time.Time `db:"start" json:"start,omitempty"` End *time.Time `db:"end" json:"end,omitempty"` Message string `db:"message" json:"message,omitempty"` // CommitHash is a git commit hash of playbook repository which // was active when task was created. CommitHash *string `db:"commit_hash" json:"commit_hash,omitempty"` // CommitMessage contains message retrieved from git repository after checkout to CommitHash. // It is readonly by API. CommitMessage string `db:"commit_message" json:"commit_message,omitempty"` BuildTaskID *int `db:"build_task_id" json:"build_task_id,omitempty"` // Version is a build version. // This field available only for Build tasks. Version *string `db:"version" json:"version,omitempty"` InventoryID *int `db:"inventory_id" json:"inventory_id,omitempty"` Params MapStringAnyField `db:"params" json:"params,omitempty"` // Limit is deprecated, use Params.Limit instead Limit string `db:"-" json:"limit"` } func (task *Task) ExtractParams(target any) (err error) { content, err := json.Marshal(task.Params) if err != nil { return } err = json.Unmarshal(content, target) return } // PreInsert is a hook which is called before inserting task into database. // Called directly in BoltDB implementation. func (task *Task) PreInsert(gorp.SqlExecutor) error { task.Created = tz.In(task.Created) if _, ok := task.Params["limit"]; !ok { if task.Params == nil { task.Params = make(MapStringAnyField) } if task.Limit != "" { limits := strings.Split(task.Limit, ",") for i := range limits { limits[i] = strings.TrimSpace(limits[i]) } task.Params["limit"] = limits } } return nil } func (task *Task) PreUpdate(gorp.SqlExecutor) error { if task.Start != nil { start := tz.In(*task.Start) task.Start = &start } if task.End != nil { end := tz.In(*task.End) task.End = &end } return nil } func (task *Task) GetIncomingVersion(d Store) *string { if task.BuildTaskID == nil { return nil } buildTask, err := d.GetTask(task.ProjectID, *task.BuildTaskID) if err != nil { return nil } tpl, err := d.GetTemplate(task.ProjectID, buildTask.TemplateID) if err != nil { return nil } if tpl.Type == TemplateBuild { return buildTask.Version } return buildTask.GetIncomingVersion(d) } func (task *Task) GetUrl() *string { if util.Config.WebHost != "" { taskUrl := fmt.Sprintf("%s/project/%d/history?t=%d", util.Config.WebHost, task.ProjectID, task.ID) return &taskUrl } return nil } func (task *Task) ValidateNewTask(template Template) error { var params any switch template.App { case AppAnsible: params = &AnsibleTaskParams{} case AppTerraform, AppTofu, AppTerragrunt: params = &TerraformTaskParams{} default: params = &DefaultTaskParams{} } return task.ExtractParams(params) } func (task *TaskWithTpl) Fill(d Store) error { if task.BuildTaskID != nil { build, err := d.GetTask(task.ProjectID, *task.BuildTaskID) if errors.Is(err, ErrNotFound) { return nil } if err != nil { return err } task.BuildTask = &build } return nil } // TaskWithTpl is the task data with additional fields type TaskWithTpl struct { Task TemplatePlaybook string `db:"tpl_playbook" json:"tpl_playbook"` TemplateAlias string `db:"tpl_alias" json:"tpl_alias"` TemplateType TemplateType `db:"tpl_type" json:"tpl_type,omitempty"` TemplateApp TemplateApp `db:"tpl_app" json:"tpl_app,omitempty"` UserName *string `db:"user_name" json:"user_name,omitempty"` BuildTask *Task `db:"-" json:"build_task,omitempty"` } // TaskOutput is the ansible log output from the task type TaskOutput struct { ID int `db:"id" json:"id"` TaskID int `db:"task_id" json:"task_id"` Time time.Time `db:"time" json:"time"` Output string `db:"output" json:"output"` StageID *int `db:"stage_id" json:"stage_id"` } type TaskStageType string const ( TaskStageInit TaskStageType = "init" TaskStageTerraformPlan TaskStageType = "terraform_plan" TaskStageRunning TaskStageType = "running" TaskStagePrintResult TaskStageType = "print_result" ) type TaskStage struct { ID int `db:"id" json:"id"` TaskID int `db:"task_id" json:"task_id"` Start *time.Time `db:"start" json:"start"` End *time.Time `db:"end" json:"end"` Type TaskStageType `db:"type" json:"type"` } type TaskStageWithResult struct { ID int `db:"id" json:"id"` TaskID int `db:"task_id" json:"task_id"` Start *time.Time `db:"start" json:"start"` End *time.Time `db:"end" json:"end"` StartOutputID *int `db:"start_output_id" json:"start_output_id"` EndOutputID *int `db:"end_output_id" json:"end_output_id"` Type TaskStageType `db:"type" json:"type"` JSON string `db:"json" json:"-"` Result any `db:"-" json:"result"` } type TaskStageResult struct { ID int `db:"id" json:"id"` TaskID int `db:"task_id" json:"task_id"` StageID int `db:"stage_id" json:"stage_id"` JSON string `db:"json" json:"json"` } ================================================ FILE: db/TaskParams.go ================================================ package db type TaskParams struct { ID int `db:"id" json:"-" backup:"-"` ProjectID int `db:"project_id" json:"-" backup:"-"` Environment string `db:"environment" json:"environment,omitempty"` Arguments *string `db:"arguments" json:"arguments,omitempty"` GitBranch *string `db:"git_branch" json:"git_branch,omitempty"` Message string `db:"message" json:"message,omitempty"` // Version is a build version. // This field available only for Build tasks. Version *string `db:"version" json:"version,omitempty"` InventoryID *int `db:"inventory_id" json:"inventory_id,omitempty" backup:"-"` InventoryName *string `db:"-" json:"-" backup:"inventory_name"` Params MapStringAnyField `db:"params" json:"params,omitempty"` } func (p TaskParams) CreateTask(templateID int) (task Task) { task = Task{ ProjectID: p.ProjectID, Environment: p.Environment, Arguments: p.Arguments, GitBranch: p.GitBranch, Message: p.Message, Version: p.Version, InventoryID: p.InventoryID, Params: p.Params, TemplateID: templateID, } return } ================================================ FILE: db/Template.go ================================================ package db import ( "encoding/json" "github.com/semaphoreui/semaphore/pkg/common_errors" log "github.com/sirupsen/logrus" ) type TemplateType string const ( TemplateTask TemplateType = "" TemplateBuild TemplateType = "build" TemplateDeploy TemplateType = "deploy" ) type TemplateApp string const ( AppAnsible TemplateApp = "ansible" AppTerraform TemplateApp = "terraform" AppTofu TemplateApp = "tofu" AppTerragrunt TemplateApp = "terragrunt" AppBash TemplateApp = "bash" AppPowerShell TemplateApp = "powershell" AppPython TemplateApp = "python" AppPulumi TemplateApp = "pulumi" ) func (t TemplateApp) InventoryTypes() []InventoryType { switch t { case AppAnsible: return []InventoryType{InventoryStatic, InventoryStaticYaml, InventoryFile} case AppTerraform: return []InventoryType{InventoryTerraformWorkspace} case AppTofu: return []InventoryType{InventoryTofuWorkspace} case AppTerragrunt: return []InventoryType{InventoryTerragruntWorkspace} default: return []InventoryType{} } } func (t TemplateApp) HasInventoryType(inventoryType InventoryType) bool { types := t.InventoryTypes() for _, typ := range types { if typ == inventoryType { return true } } return false } func (t TemplateApp) IsTerraform() bool { return t == AppTerraform || t == AppTofu || t == AppTerragrunt } type SurveyVarType string const ( SurveyVarStr TemplateType = "" SurveyVarInt TemplateType = "int" SurveyVarEnum TemplateType = "enum" ) type AnsibleTemplateParams struct { AllowDebug bool `json:"allow_debug"` AllowOverrideInventory bool `json:"allow_override_inventory"` AllowOverrideLimit bool `json:"allow_override_limit"` AllowOverrideTags bool `json:"allow_override_tags"` AllowOverrideSkipTags bool `json:"allow_override_skip_tags"` Limit []string `json:"limit"` Tags []string `json:"tags"` SkipTags []string `json:"skip_tags"` } type TerraformTemplateParams struct { AllowDestroy bool `json:"allow_destroy,omitempty"` AllowAutoApprove bool `json:"allow_auto_approve,omitempty"` AutoApprove bool `json:"auto_approve,omitempty"` OverrideBackend bool `json:"override_backend,omitempty"` // override backend if internal backend is used BackendFilename string `json:"backend_filename,omitempty"` } type SurveyVarEnumValue struct { Name string `json:"name" backup:"name"` Value string `json:"value" backup:"value"` } type SurveyVar struct { Name string `json:"name" backup:"name"` Title string `json:"title" backup:"title"` Required bool `json:"required,omitempty" backup:"required"` Type SurveyVarType `json:"type,omitempty" backup:"type"` Description string `json:"description,omitempty" backup:"description"` Values []SurveyVarEnumValue `json:"values,omitempty" backup:"values"` DefaultValue string `json:"default_value,omitempty" backup:"default_value"` } type TemplateFilter struct { ViewID *int BuildTemplateID *int AutorunOnly bool App *TemplateApp } // Template is a user defined model that is used to run a task type Template struct { ID int `db:"id" json:"id" backup:"-"` ProjectID int `db:"project_id" json:"project_id" backup:"-"` InventoryID *int `db:"inventory_id" json:"inventory_id,omitempty" backup:"-"` RepositoryID int `db:"repository_id" json:"repository_id" backup:"-"` EnvironmentID *int `db:"environment_id" json:"environment_id,omitempty" backup:"-"` // Name as described in https://github.com/semaphoreui/semaphore/issues/188 Name string `db:"name" json:"name"` // playbook name in the form of "some_play.yml" Playbook string `db:"playbook" json:"playbook"` // to fit into []string Arguments *string `db:"arguments" json:"arguments,omitempty"` // if true, semaphore will not prepend any arguments to `arguments` like inventory, etc AllowOverrideArgsInTask bool `db:"allow_override_args_in_task" json:"allow_override_args_in_task,omitempty"` Description *string `db:"description" json:"description,omitempty"` Vaults []TemplateVault `db:"-" json:"vaults,omitempty" backup:"-"` Type TemplateType `db:"type" json:"type,omitempty"` StartVersion *string `db:"start_version" json:"start_version,omitempty"` BuildTemplateID *int `db:"build_template_id" json:"build_template_id,omitempty" backup:"-"` ViewID *int `db:"view_id" json:"view_id,omitempty" backup:"-"` LastTask *TaskWithTpl `db:"-" json:"last_task,omitempty" backup:"-"` Autorun bool `db:"autorun" json:"autorun,omitempty"` // override variables GitBranch *string `db:"git_branch" json:"git_branch,omitempty"` // SurveyVarsJSON used internally for read from database. // It is not used for store survey vars to database. // Do not use it in your code. Use SurveyVars instead. SurveyVarsJSON *string `db:"survey_vars" json:"-" backup:"-"` SurveyVars []SurveyVar `db:"-" json:"survey_vars,omitempty" backup:"survey_vars"` SuppressSuccessAlerts bool `db:"suppress_success_alerts" json:"suppress_success_alerts,omitempty"` App TemplateApp `db:"app" json:"app,omitempty"` Tasks int `db:"tasks" json:"tasks" backup:"-"` TaskParams MapStringAnyField `db:"task_params" json:"task_params,omitempty"` RunnerTag *string `db:"runner_tag" json:"runner_tag,omitempty"` AllowOverrideBranchInTask bool `db:"allow_override_branch_in_task" json:"allow_override_branch_in_task,omitempty"` AllowParallelTasks bool `db:"allow_parallel_tasks" json:"allow_parallel_tasks,omitempty"` } type TemplateWithPerms struct { Template Permissions *ProjectUserPermission `db:"permissions" json:"permissions"` } func (tpl *Template) FillParams(target any) error { content, err := json.Marshal(tpl.TaskParams) if err != nil { return nil } err = json.Unmarshal(content, target) return err } func (tpl *Template) CanOverrideInventory() (ok bool, err error) { switch tpl.App { case AppAnsible, "": var params AnsibleTemplateParams err = tpl.FillParams(¶ms) if err != nil { return } ok = params.AllowOverrideInventory } return } func (tpl *Template) Validate() error { if tpl.RunnerTag != nil && *tpl.RunnerTag == "" { return &ValidationError{"template runner tag can not be empty"} } switch tpl.App { case AppAnsible: if tpl.InventoryID == nil { return &ValidationError{"template inventory can not be empty"} } } if tpl.Name == "" { return &ValidationError{"template name can not be empty"} } if !tpl.App.IsTerraform() && tpl.Playbook == "" { return &ValidationError{"template playbook can not be empty"} } if tpl.Arguments != nil { if !json.Valid([]byte(*tpl.Arguments)) { return &ValidationError{"template arguments must be valid JSON"} } } return nil } func FillTemplate(d Store, template *Template) (err error) { var vaults []TemplateVault vaults, err = d.GetTemplateVaults(template.ProjectID, template.ID) if err != nil { return } template.Vaults = vaults var tasks []TaskWithTpl tasks, err = d.GetTemplateTasks(template.ProjectID, template.ID, RetrieveQueryParams{Count: 1}) if err != nil { return } if len(tasks) > 0 { template.LastTask = &tasks[0] } if template.SurveyVarsJSON != nil { if err2 := json.Unmarshal([]byte(*template.SurveyVarsJSON), &template.SurveyVars); err2 != nil { log.WithFields(log.Fields{ "context": common_errors.GetErrorContext(), "project_id": &template.ProjectID, "template_id": template.ID, "hint": "validate JSON array in project__template.survey_vars", }).Error("failed to unmarshal template survey vars") } } return } ================================================ FILE: db/TemplateVault.go ================================================ package db type TemplateVaultType string const ( TemplateVaultPassword TemplateVaultType = "password" TemplateVaultScript TemplateVaultType = "script" ) type TemplateVault struct { ID int `db:"id" json:"id" backup:"-"` ProjectID int `db:"project_id" json:"project_id" backup:"-"` TemplateID int `db:"template_id" json:"template_id" backup:"-"` VaultKeyID *int `db:"vault_key_id" json:"vault_key_id,omitempty" backup:"-"` Name *string `db:"name" json:"name,omitempty"` Type TemplateVaultType `db:"type" json:"type"` Script *string `db:"script" json:"script,omitempty"` Vault *AccessKey `db:"-" json:"-"` } func FillTemplateVault(d Store, projectID int, templateVault *TemplateVault) (err error) { if templateVault.Type == TemplateVaultPassword && templateVault.VaultKeyID != nil { var vault AccessKey vault, err = d.GetAccessKey(projectID, *templateVault.VaultKeyID) if err != nil { return } templateVault.Vault = &vault } return } ================================================ FILE: db/Template_alias.go ================================================ package db func (t TemplateApp) NeedTaskAlias() bool { return t.IsTerraform() } ================================================ FILE: db/TerraformInventoryAlias.go ================================================ package db import "reflect" type TerraformInventoryAlias struct { ProjectID int `db:"project_id" json:"project_id"` InventoryID int `db:"inventory_id" json:"inventory_id"` AuthKeyID int `db:"auth_key_id" json:"auth_key_id"` Alias string `db:"alias" json:"alias"` TaskID *int `db:"-" json:"-"` } var TerraformInventoryAliasProps = ObjectProps{ TableName: "project__terraform_inventory_alias", Type: reflect.TypeOf(TerraformInventoryAlias{}), PrimaryColumnName: "alias", } func (alias TerraformInventoryAlias) ToAlias() Alias { return Alias{ //ID: alias.ID, Alias: alias.Alias, ProjectID: alias.ProjectID, } } ================================================ FILE: db/TerraformInventoryState_pro.go ================================================ package db import ( "reflect" "time" ) type TerraformInventoryState struct { ID int `db:"id" json:"id"` Created time.Time `db:"created" json:"created"` TaskID *int `db:"task_id" json:"task_id,omitempty"` ProjectID int `db:"project_id" json:"project_id"` InventoryID int `db:"inventory_id" json:"inventory_id"` State string `db:"state" json:"state,omitempty"` } var TerraformInventoryStateProps = ObjectProps{ TableName: "project__terraform_inventory_state", Type: reflect.TypeOf(TerraformInventoryState{}), PrimaryColumnName: "id", SortableColumns: []string{"created"}, DefaultSortingColumn: "created", SortInverted: true, } ================================================ FILE: db/TerraformInventoryStore_pro.go ================================================ package db type TerraformStore interface { CreateTerraformInventoryAlias(alias TerraformInventoryAlias) (TerraformInventoryAlias, error) GetTerraformInventoryAliasByAlias(alias string) (TerraformInventoryAlias, error) GetTerraformInventoryAlias(projectID int, inventoryID int, aliasID string) (TerraformInventoryAlias, error) GetTerraformInventoryAliases(projectID, inventoryID int) ([]TerraformInventoryAlias, error) UpdateTerraformInventoryAlias(alias TerraformInventoryAlias) error DeleteTerraformInventoryAlias(projectID int, inventoryID int, aliasID string) error CreateTerraformInventoryState(State TerraformInventoryState) (TerraformInventoryState, error) GetTerraformInventoryState(projectID int, inventoryId int, stateID int) (TerraformInventoryState, error) GetTerraformInventoryStates(projectID, inventoryID int, params RetrieveQueryParams) ([]TerraformInventoryState, error) DeleteTerraformInventoryState(projectID int, inventoryId int, stateID int) error GetTerraformStateCount() (int, error) } ================================================ FILE: db/User.go ================================================ package db import ( "time" "github.com/semaphoreui/semaphore/pkg/tz" ) // User is the model for an entity which has access to the API type User struct { ID int `db:"id" json:"id"` Created time.Time `db:"created" json:"created"` Username string `db:"username" json:"username" binding:"required"` Name string `db:"name" json:"name" binding:"required"` Email string `db:"email" json:"email" binding:"required"` Password string `db:"password" json:"-"` // password hash Admin bool `db:"admin" json:"admin"` External bool `db:"external" json:"external"` Alert bool `db:"alert" json:"alert"` Pro bool `db:"pro" json:"pro"` Totp *UserTotp `db:"-" json:"totp,omitempty"` EmailOtp *UserEmailOtp `db:"-" json:"email_otp,omitempty"` } type UserTotp struct { ID int `db:"id" json:"id"` Created time.Time `db:"created" json:"created"` UserID int `db:"user_id" json:"user_id"` URL string `db:"url" json:"url"` RecoveryHash string `db:"recovery_hash" json:"-"` RecoveryCode string `db:"-" json:"recovery_code,omitempty"` } type UserEmailOtp struct { ID int `db:"id" json:"id"` Created time.Time `db:"created" json:"created"` UserID int `db:"user_id" json:"user_id"` Code string `db:"code" json:"code"` } type UserWithProjectRole struct { Role ProjectUserRole `db:"role" json:"role"` User } // UserWithPwd extends User structure with field for unhashed password received from JSON. type UserWithPwd struct { Pwd string `db:"-" json:"password"` // unhashed password from JSON User } func ValidateUser(user User) error { if user.Username == "" { return &ValidationError{Message: "Username cannot be empty"} } if user.Email == "" { return &ValidationError{Message: "Email cannot be empty"} } if user.Name == "" { return &ValidationError{Message: "Name cannot be empty"} } return nil } func (o *UserEmailOtp) IsExpired() bool { // Email OTP is valid for 10 minutes return tz.Now().Sub(o.Created) > 10*time.Minute } ================================================ FILE: db/View.go ================================================ package db type ViewType string const ( ViewTypeAll ViewType = "all" ViewTypeCustom ViewType = "" ) type View struct { ID int `db:"id" json:"id" backup:"-"` ProjectID int `db:"project_id" json:"project_id" backup:"-"` Title string `db:"title" json:"title"` Position int `db:"position" json:"position"` Type ViewType `db:"type" json:"type,omitempty"` Hidden bool `db:"hidden" json:"hidden,omitempty"` Filter *MapStringAnyField `db:"filter" json:"filter,omitempty"` SortColumn *string `db:"sort_column" json:"sort_column,omitempty"` SortReverse bool `db:"sort_reverse" json:"sort_reverse,omitempty"` } func (view *View) Validate() error { if view.Title == "" { return &ValidationError{"title can not be empty"} } return nil } ================================================ FILE: db/ansible.go ================================================ package db import "time" type AnsibleTaskHost struct { ID int `json:"id" db:"id"` TaskID int `json:"task_id" db:"task_id"` ProjectID int `json:"project_id" db:"project_id"` Host string `json:"host" db:"host"` Changed int `json:"changed" db:"changed"` Failed int `json:"failed" db:"failed"` Ignored int `json:"ignored" db:"ignored"` Ok int `json:"ok" db:"ok"` Rescued int `json:"rescued" db:"rescued"` Skipped int `json:"skipped" db:"skipped"` Unreachable int `json:"unreachable" db:"unreachable"` Created time.Time `db:"created" json:"created"` } type AnsibleTaskError struct { ID int `json:"id" db:"id"` TaskID int `json:"task_id" db:"task_id"` ProjectID int `json:"project_id" db:"project_id"` Host string `json:"host" db:"host"` Task string `json:"task" db:"task"` Error string `json:"error" db:"error"` Created time.Time `db:"created" json:"created"` } ================================================ FILE: db/bolt/BoltDb.go ================================================ package bolt import ( "bytes" "encoding/json" "errors" "fmt" "reflect" "sort" "strings" "sync" "time" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/util" "go.etcd.io/bbolt" ) const MaxID = 2147483647 type enumerable interface { First() (key []byte, value []byte) Next() (key []byte, value []byte) } type emptyEnumerable struct{} func (d emptyEnumerable) First() (key []byte, value []byte) { return nil, nil } func (d emptyEnumerable) Next() (key []byte, value []byte) { return nil, nil } type BoltDb struct { Filename string db *bbolt.DB connections map[string]bool mu sync.Mutex integrationAlias publicAlias terraformAlias publicAlias } func (d *BoltDb) GetDialect() string { return util.DbDriverBolt } var terraformAliasProps = db.ObjectProps{ TableName: "terraform_alias", Type: reflect.TypeOf(db.TerraformInventoryAlias{}), PrimaryColumnName: "alias", } func CreateBoltDB() *BoltDb { res := BoltDb{} res.integrationAlias = publicAlias{ aliasProps: db.IntegrationAliasProps, publicAliasProps: integrationAliasProps, db: &res, } res.terraformAlias = publicAlias{ aliasProps: db.TerraformInventoryAliasProps, publicAliasProps: terraformAliasProps, db: &res, } return &res } type objectID interface { ToBytes() []byte } type intObjectID int type strObjectID string func (d intObjectID) ToBytes() []byte { return fmt.Appendf(nil, "%010d", d) } func (d strObjectID) ToBytes() []byte { return []byte(d) } func makeBucketId(props db.ObjectProps, ids ...int) []byte { n := len(ids) id := props.TableName if !props.IsGlobal { for i := 0; i < n; i++ { id += fmt.Sprintf("_%010d", ids[i]) } } return []byte(id) } func (d *BoltDb) openDbFile() { var filename string if d.Filename == "" { config, err := util.Config.GetDBConfig() if err != nil { panic(err) } filename = config.GetHostname() } else { filename = d.Filename } var err error d.db, err = bbolt.Open(filename, 0666, &bbolt.Options{ Timeout: 5 * time.Second, }) if err != nil { panic(err) } } func (d *BoltDb) openSession(token string) { d.mu.Lock() defer d.mu.Unlock() if d.connections == nil { d.connections = make(map[string]bool) } if _, exists := d.connections[token]; exists { // Use for debugging panic(fmt.Errorf("connection %s already exists", token)) } if len(d.connections) > 0 { d.connections[token] = true return } d.openDbFile() d.connections[token] = true } func (d *BoltDb) Connect(token string) { if d.PermanentConnection() { d.openDbFile() } else { d.openSession(token) } } func (d *BoltDb) closeSession(token string) { d.mu.Lock() defer d.mu.Unlock() _, exists := d.connections[token] if !exists { // Use for debugging panic(fmt.Errorf("can not close closed connection %s", token)) } if len(d.connections) > 1 { delete(d.connections, token) return } err := d.db.Close() if err != nil { panic(err) } d.db = nil delete(d.connections, token) } func (d *BoltDb) Close(token string) { if d.PermanentConnection() { if err := d.db.Close(); err != nil { panic(err) } } else { d.closeSession(token) } } func (d *BoltDb) PermanentConnection() bool { config, err := util.Config.GetDBConfig() if err != nil { panic(err) } isSessionConnection, ok := config.Options["sessionConnection"] if ok && (isSessionConnection == "true" || isSessionConnection == "yes") { return false } return true } func (d *BoltDb) IsInitialized() (initialized bool, err error) { err = d.db.View(func(tx *bbolt.Tx) error { k, _ := tx.Cursor().First() initialized = k != nil return nil }) return } func (d *BoltDb) getObjectTx(tx *bbolt.Tx, bucketID int, props db.ObjectProps, objectID objectID, object any) (err error) { b := tx.Bucket(makeBucketId(props, bucketID)) if b == nil { return db.ErrNotFound } str := b.Get(objectID.ToBytes()) if str == nil { return db.ErrNotFound } return unmarshalObject(str, object, props.SelectColumns) } func (d *BoltDb) getObject(bucketID int, props db.ObjectProps, objectID objectID, object any) (err error) { err = d.db.View(func(tx *bbolt.Tx) error { return d.getObjectTx(tx, bucketID, props, objectID, object) }) return } // getFieldNameByTagSuffix tries to find field by tag name and value in provided type. // It returns error if field not found. func getFieldNameByTagSuffix(t reflect.Type, tagName string, tagValueSuffix string) (string, error) { n := t.NumField() for i := 0; i < n; i++ { if strings.HasSuffix(t.Field(i).Tag.Get(tagName), tagValueSuffix) { return t.Field(i).Name, nil } } for i := 0; i < n; i++ { if t.Field(i).Tag != "" || t.Field(i).Type.Kind() != reflect.Struct { continue } str, err := getFieldNameByTagSuffix(t.Field(i).Type, tagName, tagValueSuffix) if err == nil { return str, nil } } return "", fmt.Errorf("field not found") } func sortObjects(objects any, sortBy string, sortInverted bool) error { objectsValue := reflect.ValueOf(objects).Elem() objType := objectsValue.Type().Elem() fieldName, err := getFieldNameByTagSuffix(objType, "db", sortBy) if err != nil { return err } sort.SliceStable(objectsValue.Interface(), func(i, j int) bool { valueI := objectsValue.Index(i).FieldByName(fieldName) valueJ := objectsValue.Index(j).FieldByName(fieldName) less := false switch valueI.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: less = valueI.Int() < valueJ.Int() case reflect.Float32: case reflect.Float64: less = valueI.Float() < valueJ.Float() case reflect.String: less = valueI.String() < valueJ.String() } if sortInverted { less = !less } return less }) return nil } func createObjectType(t reflect.Type) reflect.Type { if t.Kind() == reflect.Ptr { t = t.Elem() } n := t.NumField() fields := make([]reflect.StructField, n) for i := 0; i < n; i++ { f := t.Field(i) tag := f.Tag.Get("db") if tag != "" { f.Tag = reflect.StructTag(`json:"` + tag + `"`) } else { if f.Type.Kind() == reflect.Struct { f.Type = createObjectType(f.Type) } } fields[i] = f } return reflect.StructOf(fields) } func unmarshalObject(data []byte, obj any, fields []string) error { newType := createObjectType(reflect.TypeOf(obj)) ptr := reflect.New(newType).Interface() err := json.Unmarshal(data, ptr) if err != nil { return err } value := reflect.ValueOf(ptr).Elem() objValue := reflect.ValueOf(obj).Elem() needFieldFilter := len(fields) > 0 if needFieldFilter { fieldMap := make(map[string]struct{}, len(fields)) for _, field := range fields { fieldMap[field] = struct{}{} } for i := 0; i < newType.NumField(); i++ { fieldName := newType.Field(i).Tag.Get("json") if _, exists := fieldMap[fieldName]; !exists { continue } objValue.Field(i).Set(value.Field(i)) } } else { for i := 0; i < newType.NumField(); i++ { objValue.Field(i).Set(value.Field(i)) } } return nil } func copyObject(obj any, newType reflect.Type) any { newValue := reflect.New(newType).Elem() oldValue := reflect.ValueOf(obj) for i := 0; i < newType.NumField(); i++ { var v any if newValue.Field(i).Kind() == reflect.Struct && newValue.Field(i).Type().PkgPath() == "" { v = copyObject(oldValue.Field(i).Interface(), newValue.Field(i).Type()) } else { v = oldValue.Field(i).Interface() } newValue.Field(i).Set(reflect.ValueOf(v)) } return newValue.Interface() } func marshalObject(obj any) ([]byte, error) { newType := createObjectType(reflect.TypeOf(obj)) return json.Marshal(copyObject(obj, newType)) } func apply( rawData enumerable, props db.ObjectProps, params db.RetrieveQueryParams, filter func(any) bool, applier func(any) error, ) (err error) { objType := props.Type i := 0 // offset counter n := 0 // number of added items for k, v := rawData.First(); k != nil; k, v = rawData.Next() { if params.Offset > 0 && i < params.Offset { i++ continue } tmp := reflect.New(objType) ptr := tmp.Interface() err = unmarshalObject(v, ptr, props.SelectColumns) obj := reflect.ValueOf(ptr).Elem().Interface() if err != nil { return } if len(props.Ownerships) > 0 { ownershipMatched := true for _, ownership := range props.Ownerships { if params.Ownership.WithoutOwnerOnly { if f, ok := getReferredValue(*ownership, obj); ok && !f.IsZero() { ownershipMatched = false break } } else { ownerID := params.Ownership.GetOwnerID(*ownership) if ownerID != nil && !isObjectReferredBy(*ownership, intObjectID(*ownerID), obj) { ownershipMatched = false break } } } if !ownershipMatched { continue } } if filter != nil && !filter(obj) { continue } err = applier(obj) if err != nil { return } n++ if params.Count > 0 && n >= params.Count { break } } return } func (d *BoltDb) count(bucketID int, props db.ObjectProps, params db.RetrieveQueryParams, filter func(any) bool) (n int, err error) { n = 0 err = d.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket(makeBucketId(props, bucketID)) if b == nil { return db.ErrNotFound } c := b.Cursor() return apply(c, db.TaskProps, params, filter, func(i any) error { n++ return nil }) }) return } func unmarshalObjects(rawData enumerable, props db.ObjectProps, params db.RetrieveQueryParams, filter func(any) bool, objects any) (err error) { objectsValue := reflect.ValueOf(objects).Elem() objectsValue.Set(reflect.MakeSlice(objectsValue.Type(), 0, 0)) err = apply(rawData, props, params, filter, func(i any) error { newObjectValues := reflect.Append(objectsValue, reflect.ValueOf(i)) objectsValue.Set(newObjectValues) return nil }) if err != nil { return } sortable := false if params.SortBy != "" { for _, v := range props.SortableColumns { if v == params.SortBy { sortable = true break } } } if sortable { err = sortObjects(objects, params.SortBy, params.SortInverted) } return } func (d *BoltDb) getObjectsTx(tx *bbolt.Tx, bucketID int, props db.ObjectProps, params db.RetrieveQueryParams, filter func(any) bool, objects any) error { b := tx.Bucket(makeBucketId(props, bucketID)) var c enumerable if b == nil { c = emptyEnumerable{} } else { c = b.Cursor() } return unmarshalObjects(c, props, params, filter, objects) } func (d *BoltDb) getObjects(bucketID int, props db.ObjectProps, params db.RetrieveQueryParams, filter func(any) bool, objects any) error { return d.db.View(func(tx *bbolt.Tx) error { return d.getObjectsTx(tx, bucketID, props, params, filter, objects) }) } func (d *BoltDb) apply(bucketID int, props db.ObjectProps, params db.RetrieveQueryParams, applier func(any) error) error { return d.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket(makeBucketId(props, bucketID)) var c enumerable if b == nil { c = emptyEnumerable{} } else { c = b.Cursor() } return apply(c, props, params, nil, applier) }) } func (d *BoltDb) deleteObject(bucketID int, props db.ObjectProps, objectID objectID, tx *bbolt.Tx) error { for _, u := range []db.ObjectProps{db.TemplateProps, db.EnvironmentProps, db.InventoryProps, db.RepositoryProps} { inUse, err := d.isObjectInUse(bucketID, props, objectID, u) if err != nil { return err } if inUse { return db.ErrInvalidOperation } } fn := func(tx *bbolt.Tx) error { b := tx.Bucket(makeBucketId(props, bucketID)) if b == nil { return db.ErrNotFound } return b.Delete(objectID.ToBytes()) } if tx != nil { return fn(tx) } return d.db.Update(fn) } func (d *BoltDb) updateObjectTx(tx *bbolt.Tx, bucketID int, props db.ObjectProps, object any) error { b := tx.Bucket(makeBucketId(props, bucketID)) if b == nil { return db.ErrNotFound } idFieldName, err := getFieldNameByTagSuffix(reflect.TypeOf(object), "db", props.PrimaryColumnName) if err != nil { return err } idValue := reflect.ValueOf(object).FieldByName(idFieldName) var objID objectID switch idValue.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: objID = intObjectID(idValue.Int()) case reflect.String: objID = strObjectID(idValue.String()) } if objID == nil { return fmt.Errorf("unsupported ID type") } if b.Get(objID.ToBytes()) == nil { return db.ErrNotFound } str, err := marshalObject(object) if err != nil { return err } return b.Put(objID.ToBytes(), str) } // updateObject updates data for object in database. func (d *BoltDb) updateObject(bucketID int, props db.ObjectProps, object any) error { return d.db.Update(func(tx *bbolt.Tx) error { return d.updateObjectTx(tx, bucketID, props, object) }) } func (d *BoltDb) createObjectTx(tx *bbolt.Tx, bucketID int, props db.ObjectProps, object any) (any, error) { b, err := tx.CreateBucketIfNotExists(makeBucketId(props, bucketID)) if err != nil { return nil, err } objPtr := reflect.ValueOf(&object).Elem() tmpObj := reflect.New(objPtr.Elem().Type()).Elem() tmpObj.Set(objPtr.Elem()) var objID objectID if props.PrimaryColumnName != "" { idFieldName, err2 := getFieldNameByTagSuffix(reflect.TypeOf(object), "db", props.PrimaryColumnName) if err2 != nil { return nil, err2 } idValue := tmpObj.FieldByName(idFieldName) switch idValue.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: if idValue.Int() == 0 { id, err3 := b.NextSequence() if err3 != nil { return nil, err3 } if props.SortInverted { id = MaxID - id } idValue.SetInt(int64(id)) } objID = intObjectID(idValue.Int()) case reflect.String: if idValue.String() == "" { return nil, fmt.Errorf("object ID can not be empty string") } objID = strObjectID(idValue.String()) case reflect.Invalid: id, err3 := b.NextSequence() if err3 != nil { return nil, err3 } objID = intObjectID(id) default: return nil, fmt.Errorf("unsupported ID type") } } else { id, err2 := b.NextSequence() if err2 != nil { return nil, err2 } if props.SortInverted { id = MaxID - id } objID = intObjectID(id) } if objID == nil { return nil, fmt.Errorf("object ID can not be nil") } objPtr.Set(tmpObj) str, err := marshalObject(object) if err != nil { return nil, err } return object, b.Put(objID.ToBytes(), str) } func (d *BoltDb) createObject(bucketID int, props db.ObjectProps, object any) (res any, err error) { _ = d.db.Update(func(tx *bbolt.Tx) error { res, err = d.createObjectTx(tx, bucketID, props, object) return err }) return } func (d *BoltDb) getIntegrationRefs(projectID int, objectProps db.ObjectProps, objectID int) (refs db.IntegrationReferrers, err error) { //refs.IntegrationExtractors, err = d.getReferringObjectByParentID(projectID, objectProps, objectID, db.IntegrationExtractorProps) return } func (d *BoltDb) getIntegrationExtractorChildrenRefs(integrationID int, objectProps db.ObjectProps, objectID int) (refs db.IntegrationExtractorChildReferrers, err error) { //refs.IntegrationExtractors, err = d.getReferringObjectByParentID(objectID, objectProps, integrationID, db.IntegrationExtractorProps) //if err != nil { // return //} return } func (d *BoltDb) getReferringObjectByParentID(parentID int, objProps db.ObjectProps, objID int, referringObjectProps db.ObjectProps) (referringObjs []db.ObjectReferrer, err error) { referringObjs = make([]db.ObjectReferrer, 0) var referringObjectOfType = reflect.New(reflect.SliceOf(referringObjectProps.Type)) err = d.getObjects(parentID, referringObjectProps, db.RetrieveQueryParams{}, func(referringObj any) bool { return isObjectReferredBy(objProps, intObjectID(objID), referringObj) }, referringObjectOfType.Interface()) if err != nil { return } for i := 0; i < referringObjectOfType.Elem().Len(); i++ { referringObjs = append(referringObjs, db.ObjectReferrer{ ID: int(referringObjectOfType.Elem().Index(i).FieldByName("ID").Int()), Name: referringObjectOfType.Elem().Index(i).FieldByName("Name").String(), }) } return } func (d *BoltDb) getObjectRefs(projectID int, objectProps db.ObjectProps, objectID int) (refs db.ObjectReferrers, err error) { refs.Templates, err = d.getObjectRefsFrom(projectID, objectProps, intObjectID(objectID), db.TemplateProps) if err != nil { return } refs.Repositories, err = d.getObjectRefsFrom(projectID, objectProps, intObjectID(objectID), db.RepositoryProps) if err != nil { return } refs.Inventories, err = d.getObjectRefsFrom(projectID, objectProps, intObjectID(objectID), db.InventoryProps) if err != nil { return } refs.Schedules, err = d.getObjectRefsFrom(projectID, objectProps, intObjectID(objectID), db.ScheduleProps) if err != nil { return } refs.Integrations, err = d.getObjectRefsFrom(projectID, objectProps, intObjectID(objectID), db.IntegrationProps) if err != nil { return } return } func (d *BoltDb) getObjectRefsFrom(projectID int, objProps db.ObjectProps, objID objectID, referringObjectProps db.ObjectProps) (referringObjs []db.ObjectReferrer, err error) { referringObjs = make([]db.ObjectReferrer, 0) _, err = objProps.GetReferringFieldsFrom(referringObjectProps.Type) if err != nil { return } var referringObjects reflect.Value if referringObjectProps.Type == db.ScheduleProps.Type { schedules := make([]db.Schedule, 0) err = d.getObjects(projectID, db.ScheduleProps, db.RetrieveQueryParams{}, func(referringObj any) bool { return isObjectReferredBy(objProps, objID, referringObj) }, &schedules) if err != nil { return } for _, schedule := range schedules { var template db.Template template, err = d.GetTemplate(projectID, schedule.TemplateID) if err != nil { return } referringObjs = append(referringObjs, db.ObjectReferrer{ ID: template.ID, Name: template.Name, }) } } else { referringObjects = reflect.New(reflect.SliceOf(referringObjectProps.Type)) err = d.getObjects(projectID, referringObjectProps, db.RetrieveQueryParams{}, func(referringObj any) bool { return isObjectReferredBy(objProps, objID, referringObj) }, referringObjects.Interface()) if err != nil { return } for i := 0; i < referringObjects.Elem().Len(); i++ { referringObjs = append(referringObjs, db.ObjectReferrer{ ID: int(referringObjects.Elem().Index(i).FieldByName("ID").Int()), Name: referringObjects.Elem().Index(i).FieldByName("Name").String(), }) } } return } func getReferredValue(props db.ObjectProps, referringObj any) (f reflect.Value, ok bool) { if props.ReferringColumnSuffix == "" { ok = false return } fieldName, err := getFieldNameByTagSuffix(reflect.TypeOf(referringObj), "db", props.ReferringColumnSuffix) if err != nil { ok = false return } f = reflect.ValueOf(referringObj).FieldByName(fieldName) ok = true return } func isObjectReferredBy(props db.ObjectProps, objID objectID, referringObj any) bool { f, ok := getReferredValue(props, referringObj) if !ok { return false } //if props.ReferringColumnSuffix == "" { // return false //} // //fieldName, err := getFieldNameByTagSuffix(reflect.TypeOf(referringObj), "db", props.ReferringColumnSuffix) // //if err != nil { // return false //} // //f := reflect.ValueOf(referringObj).FieldByName(fieldName) if f.IsZero() { return false } if f.Kind() == reflect.Ptr { if f.IsNil() { return false } f = f.Elem() } var fVal objectID switch f.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: fVal = intObjectID(f.Int()) case reflect.String: fVal = strObjectID(f.String()) } if fVal == nil { return false } return bytes.Equal(fVal.ToBytes(), objID.ToBytes()) } // isObjectInUse checks if objID associated with any object in foreignTableProps. func (d *BoltDb) isObjectInUse(bucketID int, objProps db.ObjectProps, objID objectID, referringObjectProps db.ObjectProps) (inUse bool, err error) { referringObjects := reflect.New(reflect.SliceOf(referringObjectProps.Type)) err = d.getObjects(bucketID, referringObjectProps, db.RetrieveQueryParams{}, func(referringObj any) bool { return isObjectReferredBy(objProps, objID, referringObj) }, referringObjects.Interface()) if err != nil { return } inUse = referringObjects.Elem().Len() > 0 return } var ErrEndOfRange = errors.New("end of range") func (d *BoltDb) GetTaskStats(projectID int, templateID *int, unit db.TaskStatUnit, filter db.TaskFilter) (stats []db.TaskStat, err error) { if unit != db.TaskStatUnitDay { err = fmt.Errorf("only day unit is supported") return } stats = make([]db.TaskStat, 0) err = d.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket(makeBucketId(db.TaskProps, 0)) var c enumerable if b == nil { c = emptyEnumerable{} } else { c = b.Cursor() } var date string var stat *db.TaskStat err2 := apply(c, db.TaskProps, db.RetrieveQueryParams{}, func(i any) bool { task := i.(db.Task) if task.ProjectID != projectID { return false } if templateID != nil && task.TemplateID != *templateID { return false } if filter.End != nil && task.Created.After(*filter.End) { return false } if filter.UserID != nil && (task.UserID == nil || *task.UserID != *filter.UserID) { return false } return true }, func(i any) error { task := i.(db.Task) created := task.Created.Format("2006-01-02") if created < filter.Start.Format("2006-01-02") { return ErrEndOfRange } if date != created { date = created stat = &db.TaskStat{ Date: date, CountByStatus: make(map[task_logger.TaskStatus]int), } stats = append(stats, *stat) } if _, ok := stat.CountByStatus[task.Status]; !ok { stat.CountByStatus[task.Status] = 0 } stat.CountByStatus[task.Status]++ return nil }) if errors.Is(err2, ErrEndOfRange) { return nil } return err2 }) return } func CreateTestStore() *BoltDb { util.Config = &util.ConfigType{ BoltDb: &util.DbConfig{}, Dialect: "bolt", Log: &util.ConfigLog{ Events: &util.EventLogType{}, Tasks: &util.TaskLogType{}, }, } fn := "/tmp/test_semaphore_db_" + util.RandString(5) store := CreateBoltDB() store.Filename = fn store.Connect("test") return store } ================================================ FILE: db/bolt/BoltDb_test.go ================================================ package bolt import ( "fmt" "reflect" "testing" "github.com/semaphoreui/semaphore/db" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type test1 struct { ID int `db:"ID"` FirstName string `db:"first_name" json:"firstName"` LastName string `db:"last_name" json:"lastName"` Password string `db:"-" json:"password"` PasswordRepeat string `db:"-" json:"passwordRepeat"` PasswordHash string `db:"password" json:"-"` Removed bool `db:"removed"` } var inventoryID = 10 var environmentID = 10 func TestMarshalObject_UserWithPwd(t *testing.T) { user := db.UserWithPwd{ Pwd: "123456", User: db.User{ Username: "fiftin", Password: "345345234523452345234", }, } bytes, err := marshalObject(user) require.NoError(t, err) str := string(bytes) expected := `{"id":0,"created":"0001-01-01T00:00:00Z","username":"fiftin","name":"","email":"","password":"345345234523452345234","admin":false,"external":false,"alert":false,"pro":false}` assert.Equal(t, expected, str) fmt.Println(str) } func TestMarshalObject(t *testing.T) { test1 := test1{ FirstName: "Denis", LastName: "Gukov", Password: "1234556", PasswordRepeat: "123456", PasswordHash: "9347502348723", } bytes, err := marshalObject(test1) require.NoError(t, err) str := string(bytes) expected := `{"ID":0,"first_name":"Denis","last_name":"Gukov","password":"9347502348723","removed":false}` assert.Equal(t, expected, str) fmt.Println(str) } func TestUnmarshalObject(t *testing.T) { test1 := test1{} data := `{ "first_name": "Denis", "last_name": "Gukov", "password": "9347502348723" }` err := unmarshalObject([]byte(data), &test1, nil) require.NoError(t, err) assert.Equal(t, "Denis", test1.FirstName) assert.Equal(t, "Gukov", test1.LastName) assert.Equal(t, "", test1.Password) assert.Equal(t, "", test1.PasswordRepeat) assert.Equal(t, "9347502348723", test1.PasswordHash) } func TestSortObjects(t *testing.T) { objects := []db.Inventory{ {ID: 1, Name: "x"}, {ID: 2, Name: "a"}, {ID: 3, Name: "d"}, {ID: 4, Name: "b"}, {ID: 5, Name: "r"}, } err := sortObjects(&objects, "name", false) require.NoError(t, err) expected := []string{"a", "b", "d", "r", "x"} for i, obj := range objects { assert.Equal(t, expected[i], obj.Name) } } func TestGetFieldNameByTag(t *testing.T) { f, err := getFieldNameByTagSuffix(reflect.TypeOf(test1{}), "db", "first_name") require.NoError(t, err) assert.Equal(t, "FirstName", f) } func TestGetFieldNameByTag2(t *testing.T) { f, err := getFieldNameByTagSuffix(reflect.TypeOf(db.UserWithPwd{}), "db", "id") require.NoError(t, err) assert.Equal(t, "ID", f) } func TestIsObjectInUse(t *testing.T) { store := CreateTestStore() proj, err := store.CreateProject(db.Project{Name: "test"}) require.NoError(t, err) _, err = store.CreateTemplate(db.Template{ Name: "Test", Playbook: "test.yml", ProjectID: proj.ID, InventoryID: &inventoryID, EnvironmentID: &environmentID, }) require.NoError(t, err) isUse, err := store.isObjectInUse(proj.ID, db.InventoryProps, intObjectID(10), db.TemplateProps) require.NoError(t, err) assert.True(t, isUse) } func TestIsObjectInUse_Environment(t *testing.T) { store := CreateTestStore() proj, err := store.CreateProject(db.Project{Name: "test"}) require.NoError(t, err) _, err = store.CreateTemplate(db.Template{ Name: "Test", Playbook: "test.yml", ProjectID: proj.ID, InventoryID: &inventoryID, EnvironmentID: &environmentID, }) require.NoError(t, err) isUse, err := store.isObjectInUse(proj.ID, db.EnvironmentProps, intObjectID(10), db.TemplateProps) require.NoError(t, err) assert.True(t, isUse) } func TestIsObjectInUse_EnvironmentNil(t *testing.T) { store := CreateTestStore() proj, err := store.CreateProject(db.Project{Name: "test"}) require.NoError(t, err) _, err = store.CreateTemplate(db.Template{ Name: "Test", Playbook: "test.yml", ProjectID: proj.ID, InventoryID: &inventoryID, EnvironmentID: nil, }) require.NoError(t, err) isUse, err := store.isObjectInUse(proj.ID, db.EnvironmentProps, intObjectID(10), db.TemplateProps) require.NoError(t, err) assert.False(t, isUse) } func TestBoltDb_CreateAPIToken(t *testing.T) { store := CreateTestStore() user, err := store.CreateUser(db.UserWithPwd{ Pwd: "3412341234123", User: db.User{ Username: "test", Name: "Test", Email: "test@example.com", Admin: true, }, }) require.NoError(t, err) token, err := store.CreateAPIToken(db.APIToken{ ID: "f349gyhgqirgysfgsfg34973dsfad", UserID: user.ID, }) require.NoError(t, err) token2, err := store.GetAPIToken(token.ID) require.NoError(t, err) assert.Equal(t, token.ID, token2.ID) tokens, err := store.GetAPITokens(user.ID) require.NoError(t, err) assert.Len(t, tokens, 1) assert.Equal(t, token.ID, tokens[0].ID) err = store.ExpireAPIToken(user.ID, token.ID) require.NoError(t, err) token2, err = store.GetAPIToken(token.ID) require.NoError(t, err) assert.True(t, token2.Expired) err = store.DeleteAPIToken(user.ID, token.ID) require.NoError(t, err) _, err = store.GetAPIToken(token.ID) assert.Error(t, err) } func TestBoltDb_GetRepositoryRefs(t *testing.T) { store := CreateTestStore() repo1, err := store.CreateRepository(db.Repository{ Name: "repo1", GitURL: "git@example.com/repo1", GitBranch: "master", ProjectID: 1, }) require.NoError(t, err) _, err = store.CreateTemplate(db.Template{ Type: db.TemplateBuild, Name: "tpl1", Playbook: "build.yml", RepositoryID: repo1.ID, ProjectID: 1, InventoryID: &inventoryID, EnvironmentID: &environmentID, }) require.NoError(t, err) tpl2, err := store.CreateTemplate(db.Template{ Type: db.TemplateBuild, Name: "tpl12", Playbook: "build.yml", ProjectID: 1, InventoryID: &inventoryID, EnvironmentID: &environmentID, }) require.NoError(t, err) _, err = store.CreateSchedule(db.Schedule{ CronFormat: "* * * * *", TemplateID: tpl2.ID, ProjectID: 1, RepositoryID: &repo1.ID, }) require.NoError(t, err) refs, err := store.GetRepositoryRefs(1, repo1.ID) require.NoError(t, err) assert.Len(t, refs.Templates, 1) assert.Len(t, refs.Schedules, 1) } ================================================ FILE: db/bolt/Task_test.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" "testing" ) func TestTask_GetVersion(t *testing.T) { VERSION := "1.54.48" invID := 0 store := CreateTestStore() build, err := store.CreateTemplate(db.Template{ ProjectID: 0, Type: db.TemplateBuild, Name: "Build", Playbook: "build.yml", InventoryID: &invID, }) if err != nil { t.Fatal(err) } deploy, err := store.CreateTemplate(db.Template{ ProjectID: 0, Type: db.TemplateDeploy, BuildTemplateID: &build.ID, Name: "Deploy", Playbook: "deploy.yml", InventoryID: &invID, }) if err != nil { t.Fatal(err) } deploy2, err := store.CreateTemplate(db.Template{ ProjectID: 0, Type: db.TemplateDeploy, BuildTemplateID: &deploy.ID, Name: "Deploy2", Playbook: "deploy2.yml", InventoryID: &invID, }) if err != nil { t.Fatal(err) } buildTask, err := store.CreateTask(db.Task{ ProjectID: 0, TemplateID: build.ID, Version: &VERSION, }, 0) if err != nil { t.Fatal(err) } deployTask, err := store.CreateTask(db.Task{ ProjectID: 0, TemplateID: deploy.ID, BuildTaskID: &buildTask.ID, }, 0) if err != nil { t.Fatal(err) } deploy2Task, err := store.CreateTask(db.Task{ ProjectID: 0, TemplateID: deploy2.ID, BuildTaskID: &deployTask.ID, }, 0) if err != nil { t.Fatal(err) } version := deployTask.GetIncomingVersion(store) if version == nil { t.Fatal() return } if *version != VERSION { t.Fatal() return } version = deploy2Task.GetIncomingVersion(store) if version == nil { t.Fatal() return } if *version != VERSION { t.Fatal() return } } ================================================ FILE: db/bolt/access_key.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" ) func (d *BoltDb) GetAccessKey(projectID int, accessKeyID int) (key db.AccessKey, err error) { err = d.getObject(projectID, db.AccessKeyProps, intObjectID(accessKeyID), &key) if err != nil { return } return } func (d *BoltDb) GetAccessKeyRefs(projectID int, accessKeyID int) (db.ObjectReferrers, error) { return d.getObjectRefs(projectID, db.AccessKeyProps, accessKeyID) } func (d *BoltDb) GetAccessKeys(projectID int, options db.GetAccessKeyOptions, params db.RetrieveQueryParams) ([]db.AccessKey, error) { var keys []db.AccessKey err := d.getObjects(projectID, db.AccessKeyProps, params, func(i any) bool { k := i.(db.AccessKey) return k.Owner == db.AccessKeyShared }, &keys) return keys, err } func (d *BoltDb) UpdateAccessKey(key db.AccessKey) error { err := key.Validate(key.OverrideSecret) if err != nil { return err } if key.OverrideSecret { //err = key.SerializeSecret() //if err != nil { // return err //} } else { // accept only new name, ignore other changes oldKey, err2 := d.GetAccessKey(*key.ProjectID, key.ID) if err2 != nil { return err2 } oldKey.Name = key.Name key = oldKey } return d.updateObject(*key.ProjectID, db.AccessKeyProps, key) } func (d *BoltDb) CreateAccessKey(key db.AccessKey) (db.AccessKey, error) { newKey, err := d.createObject(*key.ProjectID, db.AccessKeyProps, key) return newKey.(db.AccessKey), err } func (d *BoltDb) DeleteAccessKey(projectID int, accessKeyID int) error { return d.deleteObject(projectID, db.AccessKeyProps, intObjectID(accessKeyID), nil) } func (d *BoltDb) RekeyAccessKeys(oldKey string) error { return nil //return d.db.Update(func(tx *bbolt.Tx) error { // var allProjects []db.Project // // err := d.getObjectsTx(tx, 0, db.ProjectProps, db.RetrieveQueryParams{}, nil, &allProjects) // // if err != nil { // return err // } // // for _, project := range allProjects { // var keys []db.AccessKey // err = d.getObjectsTx(tx, project.ID, db.AccessKeyProps, db.RetrieveQueryParams{}, nil, &keys) // if err != nil { // return err // } // // for _, key := range keys { // err = key.DeserializeSecret2(oldKey) // // if err != nil { // return err // } // // err = key.SerializeSecret() // if err != nil { // return err // } // // err = d.updateObjectTx(tx, *key.ProjectID, db.AccessKeyProps, key) // if err != nil { // return err // } // } // } // // return nil //}) } ================================================ FILE: db/bolt/environment.go ================================================ package bolt import "github.com/semaphoreui/semaphore/db" func (d *BoltDb) GetEnvironment(projectID int, environmentID int) (environment db.Environment, err error) { err = d.getObject(projectID, db.EnvironmentProps, intObjectID(environmentID), &environment) return } func (d *BoltDb) GetEnvironmentRefs(projectID int, environmentID int) (db.ObjectReferrers, error) { return d.getObjectRefs(projectID, db.EnvironmentProps, environmentID) } func (d *BoltDb) GetEnvironments(projectID int, params db.RetrieveQueryParams) (environment []db.Environment, err error) { err = d.getObjects(projectID, db.EnvironmentProps, params, nil, &environment) return } func (d *BoltDb) UpdateEnvironment(env db.Environment) error { err := env.Validate() if err != nil { return err } return d.updateObject(env.ProjectID, db.EnvironmentProps, env) } func (d *BoltDb) CreateEnvironment(env db.Environment) (db.Environment, error) { err := env.Validate() if err != nil { return db.Environment{}, err } newEnv, err := d.createObject(env.ProjectID, db.EnvironmentProps, env) return newEnv.(db.Environment), err } func (d *BoltDb) DeleteEnvironment(projectID int, environmentID int) error { return d.deleteObject(projectID, db.EnvironmentProps, intObjectID(environmentID), nil) } func (d *BoltDb) GetEnvironmentSecrets(projectID int, environmentID int) ([]db.AccessKey, error) { var keys []db.AccessKey err := d.getObjects(projectID, db.AccessKeyProps, db.RetrieveQueryParams{}, func(i any) bool { k := i.(db.AccessKey) return k.EnvironmentID != nil && *k.EnvironmentID == environmentID }, &keys) return keys, err } ================================================ FILE: db/bolt/event.go ================================================ package bolt import ( "encoding/json" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/tz" "go.etcd.io/bbolt" ) //func (d *BoltDb) getEventObjectName(evt db.Event) (string, error) { // if evt.ObjectID == nil || evt.ObjectType == nil { // return "", nil // } // switch *evt.ObjectType { // case "task": // task, err := d.GetTask(*evt.ProjectID, *evt.ObjectID) // if err != nil { // return "", err // } // return task.Playbook, nil // default: // return "", nil // } //} // getEvents filter and sort enumerable object passed via parameter. func (d *BoltDb) getEvents(c enumerable, params db.RetrieveQueryParams, filter func(db.Event) bool) (events []db.Event, err error) { i := 0 // offset counter n := 0 // number of added items events = []db.Event{} for k, v := c.First(); k != nil; k, v = c.Next() { if params.Offset > 0 && i < params.Offset { i++ continue } var evt db.Event err = json.Unmarshal(v, &evt) if err != nil { break } if !filter(evt) { continue } if evt.ProjectID != nil { var proj db.Project proj, err = d.GetProject(*evt.ProjectID) if err != nil { break } evt.ProjectName = &proj.Name } events = append(events, evt) n++ if n > params.Count { break } } err = db.FillEvents(d, events) return } func (d *BoltDb) CreateEvent(evt db.Event) (newEvent db.Event, err error) { newEvent = evt newEvent.Created = tz.Now() err = d.db.Update(func(tx *bbolt.Tx) error { b, err2 := tx.CreateBucketIfNotExists([]byte("events")) if err2 != nil { return err2 } str, err2 := json.Marshal(newEvent) if err2 != nil { return err2 } id, err2 := b.NextSequence() if err2 != nil { return err2 } id = MaxID - id return b.Put(intObjectID(id).ToBytes(), str) }) return } func (d *BoltDb) GetUserEvents(userID int, params db.RetrieveQueryParams) (events []db.Event, err error) { err = d.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("events")) if b == nil { return nil } c := b.Cursor() events, err = d.getEvents(c, params, func(evt db.Event) bool { if evt.ProjectID == nil { return false } _, err2 := d.GetProjectUser(*evt.ProjectID, userID) return err2 == nil }) return nil }) return } func (d *BoltDb) GetEvents(projectID int, params db.RetrieveQueryParams) (events []db.Event, err error) { err = d.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("events")) if b == nil { return nil } c := b.Cursor() events, err = d.getEvents(c, params, func(evt db.Event) bool { if evt.ProjectID == nil { return false } return *evt.ProjectID == projectID }) return nil }) return } func (d *BoltDb) GetAllEvents(params db.RetrieveQueryParams) (events []db.Event, err error) { err = d.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("events")) if b == nil { return nil } c := b.Cursor() events, err = d.getEvents(c, params, func(evt db.Event) bool { return true }) return nil }) return } ================================================ FILE: db/bolt/global_runner.go ================================================ package bolt import ( "encoding/base64" "github.com/gorilla/securecookie" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/tz" "go.etcd.io/bbolt" ) func (d *BoltDb) GetRunnerByToken(token string) (runner db.Runner, err error) { runners := make([]db.Runner, 0) err = d.getObjects(0, db.GlobalRunnerProps, db.RetrieveQueryParams{}, func(i any) bool { r := i.(db.Runner) return r.Token == token }, &runners) if err != nil { return } if len(runners) == 0 { err = db.ErrNotFound return } runner = runners[0] return } func (d *BoltDb) GetGlobalRunner(runnerID int) (runner db.Runner, err error) { err = d.getObject(0, db.GlobalRunnerProps, intObjectID(runnerID), &runner) if err != nil { return } if runner.ProjectID != nil { err = db.ErrNotFound } return } func (d *BoltDb) GetAllRunners(activeOnly bool, globalOnly bool) (runners []db.Runner, err error) { err = d.getObjects(0, db.GlobalRunnerProps, db.RetrieveQueryParams{}, func(i any) bool { runner := i.(db.Runner) if globalOnly && runner.ProjectID != nil { return false } if activeOnly { return runner.Active } return true }, &runners) return } func (d *BoltDb) DeleteGlobalRunner(runnerID int) error { return d.db.Update(func(tx *bbolt.Tx) error { var runner db.Runner err := d.getObject(0, db.GlobalRunnerProps, intObjectID(runnerID), &runner) if err != nil { return err } if runner.ProjectID != nil { return db.ErrNotFound } return d.deleteObject(0, db.GlobalRunnerProps, intObjectID(runnerID), tx) }) } func (d *BoltDb) updateRunner(runner db.Runner, updater func(targetRunner *db.Runner, foundRunner db.Runner)) (err error) { return d.db.Update(func(tx *bbolt.Tx) error { var origRunner db.Runner err = d.getObjectTx(tx, 0, db.GlobalRunnerProps, intObjectID(runner.ID), &origRunner) if err != nil { return err } if runner.ProjectID == nil { if origRunner.ProjectID != nil { return db.ErrNotFound } } else { if *origRunner.ProjectID != *runner.ProjectID { return db.ErrNotFound } } updater(&runner, origRunner) return d.updateObjectTx(tx, 0, db.GlobalRunnerProps, runner) }) } func (d *BoltDb) ClearRunnerCache(runner db.Runner) (err error) { return d.updateRunner(runner, func(targetRunner *db.Runner, foundRunner db.Runner) { now := tz.Now() targetRunner.CleaningRequested = &now }) } func (d *BoltDb) TouchRunner(runner db.Runner) (err error) { return d.updateRunner(runner, func(targetRunner *db.Runner, foundRunner db.Runner) { now := tz.Now() targetRunner.Touched = &now }) } func (d *BoltDb) UpdateRunner(runner db.Runner) (err error) { return d.updateRunner(runner, func(targetRunner *db.Runner, foundRunner db.Runner) { targetRunner.PublicKey = foundRunner.PublicKey targetRunner.Token = foundRunner.Token }) } func (d *BoltDb) CreateRunner(runner db.Runner) (newRunner db.Runner, err error) { runner.Token = base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(32)) res, err := d.createObject(0, db.GlobalRunnerProps, runner) if err != nil { return } newRunner = res.(db.Runner) return } ================================================ FILE: db/bolt/global_runner_test.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" "github.com/stretchr/testify/assert" "testing" ) func Test_GetRunnerByToken_ReturnsGlobalRunnerWhenTokenExists(t *testing.T) { store := CreateTestStore() testRunner, err := store.CreateRunner(db.Runner{}) assert.NoError(t, err) _, err = store.GetRunnerByToken(testRunner.Token) assert.NoError(t, err) } func Test_GetRunnerByToken_ReturnsRunnerWhenTokenExists(t *testing.T) { store := CreateTestStore() project, err := store.CreateProject(db.Project{}) assert.NoError(t, err) testRunner, err := store.CreateRunner(db.Runner{ProjectID: &project.ID}) assert.NoError(t, err) _, err = store.GetRunnerByToken(testRunner.Token) assert.NoError(t, err) } func Test_GetGlobalRunner_ReturnsErrorWhenTryingGetProjectRunner(t *testing.T) { store := CreateTestStore() project, err := store.CreateProject(db.Project{}) assert.NoError(t, err) testRunner, err := store.CreateRunner(db.Runner{ProjectID: &project.ID}) assert.NoError(t, err) _, err = store.GetGlobalRunner(testRunner.ID) assert.ErrorIs(t, err, db.ErrNotFound) } ================================================ FILE: db/bolt/integrations.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" "go.etcd.io/bbolt" ) /* Integrations */ func (d *BoltDb) CreateIntegration(integration db.Integration) (db.Integration, error) { err := integration.Validate() if err != nil { return db.Integration{}, err } newIntegration, err := d.createObject(integration.ProjectID, db.IntegrationProps, integration) return newIntegration.(db.Integration), err } func (d *BoltDb) GetIntegrations(projectID int, params db.RetrieveQueryParams, includeTaskParams bool) (integrations []db.Integration, err error) { err = d.getObjects(projectID, db.IntegrationProps, params, nil, &integrations) return integrations, err } func (d *BoltDb) GetIntegration(projectID int, integrationID int) (integration db.Integration, err error) { err = d.getObject(projectID, db.IntegrationProps, intObjectID(integrationID), &integration) if err != nil { return } return } func (d *BoltDb) UpdateIntegration(integration db.Integration) error { err := integration.Validate() if err != nil { return err } return d.updateObject(integration.ProjectID, db.IntegrationProps, integration) } func (d *BoltDb) GetIntegrationRefs(projectID int, integrationID int) (db.IntegrationReferrers, error) { //return d.getObjectRefs(projectID, db.IntegrationProps, integrationID) return db.IntegrationReferrers{}, nil } func (d *BoltDb) DeleteIntegrationExtractValue(projectID int, valueID int, integrationID int) error { return d.deleteObject(projectID, db.IntegrationExtractValueProps, intObjectID(valueID), nil) } func (d *BoltDb) CreateIntegrationExtractValue(projectId int, value db.IntegrationExtractValue) (db.IntegrationExtractValue, error) { err := value.Validate() if err != nil { return db.IntegrationExtractValue{}, err } newValue, err := d.createObject(projectId, db.IntegrationExtractValueProps, value) return newValue.(db.IntegrationExtractValue), err } func (d *BoltDb) GetIntegrationExtractValues(projectID int, params db.RetrieveQueryParams, integrationID int) (values []db.IntegrationExtractValue, err error) { values = make([]db.IntegrationExtractValue, 0) err = d.getObjects(projectID, db.IntegrationExtractValueProps, params, func(i any) bool { v := i.(db.IntegrationExtractValue) return v.IntegrationID == integrationID }, &values) return } func (d *BoltDb) GetIntegrationExtractValue(projectID int, valueID int, integrationID int) (value db.IntegrationExtractValue, err error) { err = d.getObject(projectID, db.IntegrationExtractValueProps, intObjectID(valueID), &value) return value, err } func (d *BoltDb) UpdateIntegrationExtractValue(projectID int, integrationExtractValue db.IntegrationExtractValue) error { err := integrationExtractValue.Validate() if err != nil { return err } return d.updateObject(projectID, db.IntegrationExtractValueProps, integrationExtractValue) } func (d *BoltDb) GetIntegrationExtractValueRefs(projectID int, valueID int, integrationID int) (db.IntegrationExtractorChildReferrers, error) { return d.getIntegrationExtractorChildrenRefs(projectID, db.IntegrationExtractValueProps, valueID) } /* Integration Matcher */ func (d *BoltDb) CreateIntegrationMatcher(projectID int, matcher db.IntegrationMatcher) (db.IntegrationMatcher, error) { err := matcher.Validate() if err != nil { return db.IntegrationMatcher{}, err } newMatcher, err := d.createObject(projectID, db.IntegrationMatcherProps, matcher) return newMatcher.(db.IntegrationMatcher), err } func (d *BoltDb) GetIntegrationMatchers(projectID int, params db.RetrieveQueryParams, integrationID int) (matchers []db.IntegrationMatcher, err error) { matchers = make([]db.IntegrationMatcher, 0) err = d.getObjects(projectID, db.IntegrationMatcherProps, db.RetrieveQueryParams{}, func(i any) bool { v := i.(db.IntegrationMatcher) return v.IntegrationID == integrationID }, &matchers) return } func (d *BoltDb) GetIntegrationMatcher(projectID int, matcherID int, integrationID int) (matcher db.IntegrationMatcher, err error) { var matchers []db.IntegrationMatcher matchers, err = d.GetIntegrationMatchers(projectID, db.RetrieveQueryParams{}, integrationID) for _, v := range matchers { if v.ID == matcherID { matcher = v } } return } func (d *BoltDb) UpdateIntegrationMatcher(projectID int, integrationMatcher db.IntegrationMatcher) error { err := integrationMatcher.Validate() if err != nil { return err } return d.updateObject(projectID, db.IntegrationMatcherProps, integrationMatcher) } func (d *BoltDb) deleteIntegrationMatcher(projectID int, matcherID int, integrationID int, tx *bbolt.Tx) error { return d.deleteObject(projectID, db.IntegrationMatcherProps, intObjectID(matcherID), tx) } func (d *BoltDb) DeleteIntegrationMatcher(projectID int, matcherID int, integrationID int) error { return d.deleteIntegrationMatcher(projectID, matcherID, integrationID, nil) } func (d *BoltDb) DeleteIntegration(projectID int, integrationID int) error { return d.deleteIntegration(projectID, integrationID, nil) } func (d *BoltDb) deleteIntegration(projectID int, integrationID int, tx *bbolt.Tx) error { matchers, err := d.GetIntegrationMatchers(projectID, db.RetrieveQueryParams{}, integrationID) if err != nil { return err } for m := range matchers { d.deleteIntegrationMatcher(projectID, matchers[m].ID, integrationID, tx) } return d.deleteObject(projectID, db.IntegrationProps, intObjectID(integrationID), tx) } func (d *BoltDb) GetIntegrationMatcherRefs(projectID int, matcherID int, integrationID int) (db.IntegrationExtractorChildReferrers, error) { return d.getIntegrationExtractorChildrenRefs(projectID, db.IntegrationMatcherProps, matcherID) } ================================================ FILE: db/bolt/integrations_alias.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" "reflect" ) var integrationAliasProps = db.ObjectProps{ TableName: "integration_alias", Type: reflect.TypeOf(db.IntegrationAlias{}), PrimaryColumnName: "alias", } func (d *BoltDb) GetIntegrationAliases(projectID int, integrationID *int) (res []db.IntegrationAlias, err error) { err = d.integrationAlias.getAliases(projectID, func(i any) bool { alias := i.(db.IntegrationAlias) if alias.IntegrationID == nil && integrationID == nil { return true } else if alias.IntegrationID != nil && integrationID != nil { return *alias.IntegrationID == *integrationID } return false }, &res) return } func (d *BoltDb) GetIntegrationsByAlias(alias string) (res []db.Integration, level db.IntegrationAliasLevel, err error) { var aliasObj db.IntegrationAlias err = d.integrationAlias.getPublicAlias(alias, &aliasObj) if err != nil { return } if aliasObj.IntegrationID == nil { level = db.IntegrationAliasProject err = d.getObjects(aliasObj.ProjectID, db.IntegrationProps, db.RetrieveQueryParams{}, func(i any) bool { integration := i.(db.Integration) return integration.Searchable }, &res) if err != nil { return } } else { level = db.IntegrationAliasSingle var integration db.Integration integration, err = d.GetIntegration(aliasObj.ProjectID, *aliasObj.IntegrationID) if err != nil { return } if integration.Searchable { err = db.ErrNotFound return } res = append(res, integration) } return } func (d *BoltDb) CreateIntegrationAlias(alias db.IntegrationAlias) (res db.IntegrationAlias, err error) { newAlias, err := d.integrationAlias.createAlias(alias) if err != nil { return } res = newAlias.(db.IntegrationAlias) return } func (d *BoltDb) DeleteIntegrationAlias(projectID int, aliasID int) (err error) { err = d.integrationAlias.deleteIntegrationAlias(projectID, aliasID) return } ================================================ FILE: db/bolt/inventory.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" ) func (d *BoltDb) GetInventory(projectID int, inventoryID int) (inventory db.Inventory, err error) { err = d.getObject(projectID, db.InventoryProps, intObjectID(inventoryID), &inventory) return } func (d *BoltDb) GetInventories(projectID int, params db.RetrieveQueryParams, types []db.InventoryType) (inventories []db.Inventory, err error) { err = d.getObjects(projectID, db.InventoryProps, params, func(i any) bool { inventory := i.(db.Inventory) if len(types) == 0 { return true } for _, t := range types { if inventory.Type == t { return true } } return false }, &inventories) return } func (d *BoltDb) GetInventoryRefs(projectID int, inventoryID int) (db.ObjectReferrers, error) { return d.getObjectRefs(projectID, db.InventoryProps, inventoryID) } func (d *BoltDb) DeleteInventory(projectID int, inventoryID int) error { return d.deleteObject(projectID, db.InventoryProps, intObjectID(inventoryID), nil) } func (d *BoltDb) UpdateInventory(inventory db.Inventory) error { return d.updateObject(inventory.ProjectID, db.InventoryProps, inventory) } func (d *BoltDb) CreateInventory(inventory db.Inventory) (db.Inventory, error) { newInventory, err := d.createObject(inventory.ProjectID, db.InventoryProps, inventory) return newInventory.(db.Inventory), err } ================================================ FILE: db/bolt/migration.go ================================================ package bolt import ( "encoding/json" "fmt" "github.com/semaphoreui/semaphore/db" "go.etcd.io/bbolt" ) func (d *BoltDb) IsMigrationApplied(migration db.Migration) (bool, error) { err := d.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("migrations")) if b == nil { return db.ErrNotFound } d := b.Get([]byte(migration.Version)) if d == nil { return db.ErrNotFound } return nil }) if err == nil { return true, nil } if err == db.ErrNotFound { return false, nil } return false, err } func (d *BoltDb) ApplyMigration(m db.Migration) (err error) { switch m.Version { case "2.8.26": err = migration_2_8_28{migration{d.db}}.Apply() case "2.8.40": err = migration_2_8_40{migration{d.db}}.Apply() case "2.8.91": err = migration_2_8_91{migration{d.db}}.Apply() case "2.10.12": err = migration_2_10_12{migration{d.db}}.Apply() case "2.10.16": err = migration_2_10_16{migration{d.db}}.Apply() case "2.10.24": err = migration_2_10_24{migration{d.db}}.Apply() case "2.10.33": err = migration_2_10_33{migration{d.db}}.Apply() case "2.14.7": err = migration_2_14_7{migration{d.db}}.Apply() case "2.17.0": err = migration_2_17_0{migration{d.db}}.Apply() case "2.17.2": err = migration_2_17_2{migration{d.db}}.Apply() } if err != nil { return } return d.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("migrations")) if err != nil { return err } j, err := json.Marshal(m) if err != nil { return err } return b.Put([]byte(m.Version), j) }) } func (d *BoltDb) TryRollbackMigration(m db.Migration) { switch m.Version { case "2.8.26": } } type migration struct { db *bbolt.DB } func (d migration) createObjectTx(tx *bbolt.Tx, projectID string, objectPrefix string, object map[string]any) (newObjectID string, err error) { b, err := tx.CreateBucketIfNotExists([]byte("project__" + objectPrefix + "_" + projectID)) if err != nil { return } var objID objectID id, err := b.NextSequence() if err != nil { return } objID = intObjectID(id) if objID == nil { err = fmt.Errorf("object ID can not be nil") return } object["id"] = objID j, err := json.Marshal(object) if err != nil { return } objIDBytes := objID.ToBytes() newObjectID = string(objIDBytes) return newObjectID, b.Put(objIDBytes, j) } func (d migration) createObject(projectID string, objectPrefix string, object map[string]any) (newObjectID string, err error) { _ = d.db.Update(func(tx *bbolt.Tx) error { newObjectID, err = d.createObjectTx(tx, projectID, objectPrefix, object) return err }) return } func (d migration) getProjectIDs() (projectIDs []string, err error) { err = d.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("project")) if b == nil { return nil } return b.ForEach(func(id, _ []byte) error { projectIDs = append(projectIDs, string(id)) return nil }) }) return } // getObjects returns map of following format: map[OBJECT_ID]map[FIELD_NAME]interface{} func (d migration) getObjects(projectID string, objectPrefix string) (map[string]map[string]any, error) { repos := make(map[string]map[string]any) // ??? err := d.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("project__" + objectPrefix + "_" + projectID)) if b == nil { return nil } return b.ForEach(func(id, body []byte) error { r := make(map[string]any) repos[string(id)] = r return json.Unmarshal(body, &r) }) }) return repos, err } func (d migration) getObject(projectID string, objectPrefix string, objectID string) (r map[string]any, err error) { err = d.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("project__" + objectPrefix + "_" + projectID)) if b == nil { return nil } s := b.Get([]byte(objectID)) if s == nil { return nil } return json.Unmarshal(s, &r) }) return } func (d migration) setObject(projectID string, objectPrefix string, objectID string, object map[string]any) error { return d.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("project__" + objectPrefix + "_" + projectID)) if err != nil { return err } j, err := json.Marshal(object) if err != nil { return err } return b.Put([]byte(objectID), j) }) } func (d migration) deleteObject(projectID string, objectPrefix string, objectID string) error { return d.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("project__" + objectPrefix + "_" + projectID)) if b == nil { return nil } return b.Delete([]byte(objectID)) }) } ================================================ FILE: db/bolt/migration_2_10_12.go ================================================ package bolt type migration_2_10_12 struct { migration } func (d migration_2_10_12) Apply() error { projectIDs, err := d.getProjectIDs() if err != nil { return err } for _, projectID := range projectIDs { schedules, err := d.getObjects(projectID, "schedule") if err != nil { return err } for scheduleID, schedule := range schedules { schedule["active"] = true err = d.setObject(projectID, "schedule", scheduleID, schedule) if err != nil { return err } } } return nil } ================================================ FILE: db/bolt/migration_2_10_12_test.go ================================================ package bolt import ( "encoding/json" "go.etcd.io/bbolt" "testing" ) func TestMigration_2_10_12_Apply(t *testing.T) { store := CreateTestStore() err := store.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("project")) if err != nil { return err } err = b.Put([]byte("0000000001"), []byte("{}")) if err != nil { return err } r, err := tx.CreateBucketIfNotExists([]byte("project__schedule_0000000001")) if err != nil { return err } err = r.Put([]byte("0000000001"), []byte("{\"id\":\"1\",\"project_id\":\"1\"}")) return err }) if err != nil { t.Fatal(err) } err = migration_2_10_12{migration{store.db}}.Apply() if err != nil { t.Fatal(err) } var scheduleData map[string]any err = store.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("project__schedule_0000000001")) str := string(b.Get([]byte("0000000001"))) return json.Unmarshal([]byte(str), &scheduleData) }) if err != nil { t.Fatal(err) } if !scheduleData["active"].(bool) { t.Fatal("invalid role") } } ================================================ FILE: db/bolt/migration_2_10_16.go ================================================ package bolt type migration_2_10_16 struct { migration } func (d migration_2_10_16) Apply() (err error) { projectIDs, err := d.getProjectIDs() if err != nil { return } templates := make(map[string]map[string]map[string]any) for _, projectID := range projectIDs { var err2 error templates[projectID], err2 = d.getObjects(projectID, "template") if err2 != nil { return err2 } } for projectID, projectTemplates := range templates { for repoID, tpl := range projectTemplates { if tpl["app"] != nil && tpl["app"] != "" { continue } tpl["app"] = "ansible" err = d.setObject(projectID, "template", repoID, tpl) if err != nil { return err } } } return } ================================================ FILE: db/bolt/migration_2_10_16_test.go ================================================ package bolt import ( "encoding/json" "go.etcd.io/bbolt" "testing" ) func TestMigration_2_10_16_Apply(t *testing.T) { store := CreateTestStore() err := store.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("project")) if err != nil { return err } err = b.Put([]byte("0000000001"), []byte("{}")) if err != nil { return err } r, err := tx.CreateBucketIfNotExists([]byte("project__template_0000000001")) if err != nil { return err } err = r.Put([]byte("0000000001"), []byte("{\"id\":\"1\",\"project_id\":\"1\"}")) return err }) if err != nil { t.Fatal(err) } err = migration_2_10_16{migration{store.db}}.Apply() if err != nil { t.Fatal(err) } var repo map[string]any err = store.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("project__template_0000000001")) str := string(b.Get([]byte("0000000001"))) return json.Unmarshal([]byte(str), &repo) }) if err != nil { t.Fatal(err) } if repo["app"] == nil { t.Fatal("app must be set") } if repo["app"].(string) != "ansible" { t.Fatal("invalid app: " + repo["app"].(string)) } if repo["alias"] != nil { t.Fatal("alias must be deleted") } } func TestMigration_2_10_16_Apply2(t *testing.T) { store := CreateTestStore() err := store.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("project")) if err != nil { return err } err = b.Put([]byte("0000000001"), []byte("{}")) return err }) if err != nil { t.Fatal(err) } err = migration_2_10_16{migration{store.db}}.Apply() if err != nil { t.Fatal(err) } } ================================================ FILE: db/bolt/migration_2_10_24.go ================================================ package bolt import "fmt" type migration_2_10_24 struct { migration } func (d migration_2_10_24) Apply() (err error) { projectIDs, err := d.getProjectIDs() if err != nil { return err } for _, projectID := range projectIDs { templates, err := d.getObjects(projectID, "template") if err != nil { return err } var templateVaultID = 1 for templateID, template := range templates { if template["vault_key_id"] != nil { templateVault := map[string]any{ "id": templateVaultID, "project_id": template["project_id"], "template_id": template["id"], "vault_key_id": template["vault_key_id"], "name": nil, } err = d.setObject(projectID, "template_vault", fmt.Sprintf("%010d", templateVaultID), templateVault) if err != nil { return err } templateVaultID++ } delete(template, "vault_key_id") err = d.setObject(projectID, "template", templateID, template) if err != nil { return err } } } return } ================================================ FILE: db/bolt/migration_2_10_24_test.go ================================================ package bolt import ( "encoding/json" "go.etcd.io/bbolt" "testing" ) func TestMigration_2_10_24_Apply(t *testing.T) { store := CreateTestStore() err := store.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("project")) if err != nil { return err } err = b.Put([]byte("0000000001"), []byte("{}")) if err != nil { return err } r, err := tx.CreateBucketIfNotExists([]byte("project__template_0000000001")) if err != nil { return err } err = r.Put([]byte("0000000001"), []byte("{\"id\":\"1\",\"project_id\":\"1\",\"vault_key_id\":\"1\"}")) return err }) if err != nil { t.Fatal(err) } err = migration_2_10_24{migration{store.db}}.Apply() if err != nil { t.Fatal(err) } var template map[string]any err = store.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("project__template_0000000001")) str := string(b.Get([]byte("0000000001"))) return json.Unmarshal([]byte(str), &template) }) if err != nil { t.Fatal(err) } var templateVault map[string]any err = store.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("project__template_vault_0000000001")) str := string(b.Get([]byte("0000000001"))) return json.Unmarshal([]byte(str), &templateVault) }) if err != nil { t.Fatal(err) } if _, ok := template["vault_key_id"]; ok { t.Fatal("vault_key_id must be deleted") } if templateVault["vault_key_id"].(string) != "1" { t.Fatal("invalid vault_key_id: " + templateVault["vault_key_id"].(string)) } } func TestMigration_2_10_24_Apply2(t *testing.T) { store := CreateTestStore() err := store.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("project")) if err != nil { return err } err = b.Put([]byte("0000000001"), []byte("{}")) return err }) if err != nil { t.Fatal(err) } err = migration_2_10_24{migration{store.db}}.Apply() if err != nil { t.Fatal(err) } } ================================================ FILE: db/bolt/migration_2_10_33.go ================================================ package bolt type migration_2_10_33 struct { migration } func (d migration_2_10_33) Apply() (err error) { projectIDs, err := d.getProjectIDs() if err != nil { return } vaults := make(map[string]map[string]map[string]any) for _, projectID := range projectIDs { var err2 error vaults[projectID], err2 = d.getObjects(projectID, "template_vault") if err2 != nil { return err2 } } for projectID, projectVaults := range vaults { for repoID, vault := range projectVaults { if vault["type"] != nil && vault["type"] != "" { continue } vault["type"] = "password" err = d.setObject(projectID, "template_vault", repoID, vault) if err != nil { return err } } } return } ================================================ FILE: db/bolt/migration_2_10_33_test.go ================================================ package bolt import ( "encoding/json" "go.etcd.io/bbolt" "testing" ) func TestMigration_2_10_33_Apply(t *testing.T) { store := CreateTestStore() err := store.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("project")) if err != nil { return err } err = b.Put([]byte("0000000001"), []byte("{}")) if err != nil { return err } r, err := tx.CreateBucketIfNotExists([]byte("project__template_vault_0000000001")) if err != nil { return err } err = r.Put([]byte("0000000001"), []byte("{\"id\":\"1\",\"project_id\":\"1\"}")) return err }) if err != nil { t.Fatal(err) } err = migration_2_10_33{migration{store.db}}.Apply() if err != nil { t.Fatal(err) } var repo map[string]any err = store.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("project__template_vault_0000000001")) str := string(b.Get([]byte("0000000001"))) return json.Unmarshal([]byte(str), &repo) }) if err != nil { t.Fatal(err) } if repo["type"] == nil { t.Fatal("app must be set") } if repo["type"].(string) != "password" { t.Fatal("invalid app: " + repo["type"].(string)) } } func TestMigration_2_10_33_Apply2(t *testing.T) { store := CreateTestStore() err := store.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("project")) if err != nil { return err } err = b.Put([]byte("0000000001"), []byte("{}")) return err }) if err != nil { t.Fatal(err) } err = migration_2_10_33{migration{store.db}}.Apply() if err != nil { t.Fatal(err) } } ================================================ FILE: db/bolt/migration_2_14_7.go ================================================ package bolt import ( "fmt" "github.com/semaphoreui/semaphore/pkg/conv" ) type migration_2_14_7 struct { migration } func (d migration_2_14_7) Apply() (err error) { projectIDs, err := d.getProjectIDs() if err != nil { return } for _, projectID := range projectIDs { projectSchedules, err2 := d.getObjects(projectID, "schedule") if err2 != nil { return err2 } for scheduleID, schedule := range projectSchedules { tplID, ok := conv.ConvertFloatToIntIfPossible(schedule["template_id"]) if !ok { return fmt.Errorf("schedule template id %s is not a valid integer", schedule["template_id"]) } tpl, err3 := d.getObject(projectID, "template", string(intObjectID(int(tplID)).ToBytes())) if err3 != nil { return err3 } if tpl == nil { err3 = d.deleteObject(projectID, "schedule", scheduleID) } if err3 != nil { return err3 } } } return } ================================================ FILE: db/bolt/migration_2_14_7_test.go ================================================ package bolt import ( "go.etcd.io/bbolt" "testing" ) func TestMigration_2_14_7_Apply(t *testing.T) { store := CreateTestStore() err := store.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("project")) if err != nil { return err } err = b.Put([]byte("0000000001"), []byte("{}")) if err != nil { return err } // Create templates r, err := tx.CreateBucketIfNotExists([]byte("project__template_0000000001")) if err != nil { return err } err = r.Put([]byte("0000000001"), []byte("{\"id\":\"1\",\"project_id\":\"1\"}")) if err != nil { return err } // Create schedules r, err = tx.CreateBucketIfNotExists([]byte("project__schedule_0000000001")) if err != nil { return err } err = r.Put([]byte("0000000001"), []byte("{\"id\":\"1\",\"project_id\":\"1\",\"template_id\":1}")) // correct err = r.Put([]byte("0000000002"), []byte("{\"id\":\"1\",\"project_id\":\"1\",\"template_id\":100}")) // incorrect return err }) if err != nil { t.Fatal(err) } err = migration_2_14_7{migration{store.db}}.Apply() if err != nil { t.Fatal(err) } var s1, s2 []byte err = store.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("project__schedule_0000000001")) s1 = b.Get([]byte("0000000001")) s2 = b.Get([]byte("0000000002")) return nil }) if err != nil { t.Fatal(err) } if s1 == nil { t.Fatal("Correct schedule should not be deleted") } if s2 != nil { t.Fatal("Incorrect schedule should be deleted") } } ================================================ FILE: db/bolt/migration_2_17_0.go ================================================ package bolt import "strconv" type migration_2_17_0 struct { migration } func (d migration_2_17_0) Apply() (err error) { projectIDs, err := d.getProjectIDs() if err != nil { return } for _, projectID := range projectIDs { id, err2 := strconv.Atoi(projectID) _, err2 = d.createObject(projectID, "view", map[string]any{ "project_id": id, "type": "all", "position": -1, "title": "All", "sort_column": "name", }) if err2 != nil { return err2 } } return } ================================================ FILE: db/bolt/migration_2_17_0_test.go ================================================ package bolt import ( "encoding/json" "testing" "github.com/stretchr/testify/assert" "go.etcd.io/bbolt" ) func TestMigration_2_17_0_Apply(t *testing.T) { store := CreateTestStore() err := store.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("project")) if err != nil { return err } return b.Put([]byte("0000000001"), []byte("{}")) }) assert.NoError(t, err) err = migration_2_17_0{migration{store.db}}.Apply() if err != nil { t.Fatal(err) } var s1 []byte err = store.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("project__view_0000000001")) s1 = b.Get([]byte("0000000001")) return nil }) assert.NoError(t, err) assert.NotNil(t, s1) var res map[string]any err = json.Unmarshal(s1, &res) assert.NoError(t, err) assert.Equal(t, 1.0, res["id"]) assert.Equal(t, "all", res["type"]) } ================================================ FILE: db/bolt/migration_2_17_2.go ================================================ package bolt type migration_2_17_2 struct { migration } func (d migration_2_17_2) Apply() error { // No-op migration for BoltDB. // The project_id field is added to the Role struct and will be handled automatically. return nil } ================================================ FILE: db/bolt/migration_2_8_28.go ================================================ package bolt import ( "strings" ) type migration_2_8_28 struct { migration } func (d migration_2_8_28) Apply() (err error) { projectIDs, err := d.getProjectIDs() if err != nil { return } repos := make(map[string]map[string]map[string]any) for _, projectID := range projectIDs { var err2 error repos[projectID], err2 = d.getObjects(projectID, "repository") if err2 != nil { return err2 } } for projectID, projectRepos := range repos { for repoID, repo := range projectRepos { branch := "master" url := repo["git_url"].(string) parts := strings.Split(url, "#") if len(parts) > 1 { url, branch = parts[0], parts[1] } repo["git_url"] = url repo["git_branch"] = branch err = d.setObject(projectID, "repository", repoID, repo) if err != nil { return err } } } return nil } ================================================ FILE: db/bolt/migration_2_8_28_test.go ================================================ package bolt import ( "encoding/json" "testing" "github.com/stretchr/testify/assert" "go.etcd.io/bbolt" ) func TestMigration_2_8_28_Apply(t *testing.T) { store := CreateTestStore() err := store.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("project")) if err != nil { return err } err = b.Put([]byte("0000000001"), []byte("{}")) if err != nil { return err } r, err := tx.CreateBucketIfNotExists([]byte("project__repository_0000000001")) if err != nil { return err } err = r.Put([]byte("0000000001"), []byte("{\"id\":\"1\",\"project_id\":\"1\",\"git_url\": \"git@github.com/test/test#main\"}")) return err }) assert.NoError(t, err) err = migration_2_8_28{migration{store.db}}.Apply() assert.NoError(t, err) var repo map[string]any err = store.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("project__repository_0000000001")) str := string(b.Get([]byte("0000000001"))) return json.Unmarshal([]byte(str), &repo) }) assert.NoError(t, err) assert.Equal(t, "git@github.com/test/test", repo["git_url"].(string), "invalid url") assert.Equal(t, "main", repo["git_branch"].(string), "invalid branch") } func TestMigration_2_8_28_Apply2(t *testing.T) { store := CreateTestStore() err := store.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("project")) if err != nil { return err } err = b.Put([]byte("0000000001"), []byte("{}")) return err }) assert.NoError(t, err) err = migration_2_8_28{migration{store.db}}.Apply() assert.NoError(t, err) } ================================================ FILE: db/bolt/migration_2_8_40.go ================================================ package bolt type migration_2_8_40 struct { migration } func (d migration_2_8_40) Apply() (err error) { projectIDs, err := d.getProjectIDs() if err != nil { return } templates := make(map[string]map[string]map[string]any) for _, projectID := range projectIDs { var err2 error templates[projectID], err2 = d.getObjects(projectID, "template") if err2 != nil { return err2 } } for projectID, projectTemplates := range templates { for repoID, tpl := range projectTemplates { tpl["name"] = tpl["alias"] delete(tpl, "alias") err = d.setObject(projectID, "template", repoID, tpl) if err != nil { return err } } } return } ================================================ FILE: db/bolt/migration_2_8_40_test.go ================================================ package bolt import ( "encoding/json" "go.etcd.io/bbolt" "testing" ) func TestMigration_2_8_40_Apply(t *testing.T) { store := CreateTestStore() err := store.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("project")) if err != nil { return err } err = b.Put([]byte("0000000001"), []byte("{}")) if err != nil { return err } r, err := tx.CreateBucketIfNotExists([]byte("project__template_0000000001")) if err != nil { return err } err = r.Put([]byte("0000000001"), []byte("{\"id\":\"1\",\"project_id\":\"1\",\"alias\": \"test123\"}")) return err }) if err != nil { t.Fatal(err) } err = migration_2_8_40{migration{store.db}}.Apply() if err != nil { t.Fatal(err) } var repo map[string]any err = store.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("project__template_0000000001")) str := string(b.Get([]byte("0000000001"))) return json.Unmarshal([]byte(str), &repo) }) if err != nil { t.Fatal(err) } if repo["name"].(string) != "test123" { t.Fatal("invalid name") } if repo["alias"] != nil { t.Fatal("alias must be deleted") } } func TestMigration_2_8_40_Apply2(t *testing.T) { store := CreateTestStore() err := store.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("project")) if err != nil { return err } err = b.Put([]byte("0000000001"), []byte("{}")) return err }) if err != nil { t.Fatal(err) } err = migration_2_8_28{migration{store.db}}.Apply() if err != nil { t.Fatal(err) } } ================================================ FILE: db/bolt/migration_2_8_91.go ================================================ package bolt type migration_2_8_91 struct { migration } func (d migration_2_8_91) Apply() (err error) { projectIDs, err := d.getProjectIDs() if err != nil { return } usersByProjectMap := make(map[string]map[string]map[string]any) for _, projectID := range projectIDs { usersByProjectMap[projectID], err = d.getObjects(projectID, "user") if err != nil { return } } for projectID, projectUsers := range usersByProjectMap { for userId, userData := range projectUsers { if userData["admin"] == true { userData["role"] = "owner" } else { userData["role"] = "manager" } delete(userData, "admin") err = d.setObject(projectID, "user", userId, userData) if err != nil { return } } } return } ================================================ FILE: db/bolt/migration_2_8_91_test.go ================================================ package bolt import ( "encoding/json" "go.etcd.io/bbolt" "testing" ) func TestMigration_2_8_91_Apply(t *testing.T) { store := CreateTestStore() err := store.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("project")) if err != nil { return err } err = b.Put([]byte("0000000001"), []byte("{}")) if err != nil { return err } r, err := tx.CreateBucketIfNotExists([]byte("project__user_0000000001")) if err != nil { return err } err = r.Put([]byte("0000000001"), []byte("{\"id\":\"1\",\"project_id\":\"1\",\"admin\": true}")) return err }) if err != nil { t.Fatal(err) } err = migration_2_8_91{migration{store.db}}.Apply() if err != nil { t.Fatal(err) } var userData map[string]any err = store.db.View(func(tx *bbolt.Tx) error { b := tx.Bucket([]byte("project__user_0000000001")) str := string(b.Get([]byte("0000000001"))) return json.Unmarshal([]byte(str), &userData) }) if err != nil { t.Fatal(err) } if userData["role"].(string) != "owner" { t.Fatal("invalid role") } if userData["admin"] != nil { t.Fatal("admin field must be deleted") } } func TestMigration_2_8_91_Apply2(t *testing.T) { store := CreateTestStore() err := store.db.Update(func(tx *bbolt.Tx) error { b, err := tx.CreateBucketIfNotExists([]byte("project")) if err != nil { return err } err = b.Put([]byte("0000000001"), []byte("{}")) return err }) if err != nil { t.Fatal(err) } err = migration_2_8_28{migration{store.db}}.Apply() if err != nil { t.Fatal(err) } } ================================================ FILE: db/bolt/option.go ================================================ package bolt import ( "errors" "github.com/semaphoreui/semaphore/db" "go.etcd.io/bbolt" "strings" ) func (d *BoltDb) GetOptions(params db.RetrieveQueryParams) (res map[string]string, err error) { res = make(map[string]string) var options []db.Option err = d.getObjects(0, db.OptionProps, db.RetrieveQueryParams{}, func(i any) bool { option := i.(db.Option) if params.Filter == "" { return true } return option.Key == params.Filter || strings.HasPrefix(option.Key, params.Filter+".") }, &options) for _, opt := range options { res[opt.Key] = opt.Value } return } func (d *BoltDb) SetOption(key string, value string) error { opt := db.Option{ Key: key, Value: value, } _, err := d.getOption(key) if errors.Is(err, db.ErrNotFound) { _, err = d.createObject(-1, db.OptionProps, opt) return err } else { err = d.updateObject(-1, db.OptionProps, opt) } return err } func (d *BoltDb) getOption(key string) (value string, err error) { var option db.Option err = d.getObject(-1, db.OptionProps, strObjectID(key), &option) value = option.Value return } func (d *BoltDb) GetOption(key string) (value string, err error) { var option db.Option err = d.getObject(-1, db.OptionProps, strObjectID(key), &option) value = option.Value if errors.Is(err, db.ErrNotFound) { err = nil } return } func (d *BoltDb) DeleteOption(key string) (err error) { err = db.ValidateOptionKey(key) if err != nil { return } return d.db.Update(func(tx *bbolt.Tx) error { return d.deleteObject(-1, db.OptionProps, strObjectID(key), tx) }) } func (d *BoltDb) DeleteOptions(filter string) (err error) { err = db.ValidateOptionKey(filter) if err != nil { return } var options []db.Option err = d.getObjects(0, db.OptionProps, db.RetrieveQueryParams{}, func(i any) bool { opt := i.(db.Option) return opt.Key == filter || strings.HasPrefix(opt.Key, filter+".") }, &options) for _, opt := range options { err = d.DeleteOption(opt.Key) if err != nil { return } } return } ================================================ FILE: db/bolt/option_test.go ================================================ package bolt import ( "testing" ) func TestGetOption(t *testing.T) { store := CreateTestStore() val, err := store.GetOption("unknown_option") if err != nil && val != "" { t.Fatal("Result must be empty string for non-existent option") } } func TestGetSetOption(t *testing.T) { store := CreateTestStore() err := store.SetOption("age", "33") if err != nil { t.Fatal("Can not save option") } val, err := store.GetOption("age") if err != nil { t.Fatal("Can not get option") } if val != "33" { t.Fatal("Invalid option value") } err = store.SetOption("age", "22") if err != nil { t.Fatal("Can not save option") } val, err = store.GetOption("age") if err != nil { t.Fatal("Can not get option") } if val != "22" { t.Fatal("Invalid option value") } } ================================================ FILE: db/bolt/project.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/tz" ) func (d *BoltDb) CreateProject(project db.Project) (db.Project, error) { project.Created = tz.Now() newProject, err := d.createObject(0, db.ProjectProps, project) if err != nil { return db.Project{}, err } return newProject.(db.Project), nil } func (d *BoltDb) GetAllProjects() (projects []db.Project, err error) { err = d.getObjects(0, db.ProjectProps, db.RetrieveQueryParams{}, nil, &projects) return } func (d *BoltDb) GetProjects(userID int) (projects []db.Project, err error) { projects = make([]db.Project, 0) var allProjects []db.Project err = d.getObjects(0, db.ProjectProps, db.RetrieveQueryParams{}, nil, &allProjects) if err != nil { return } for _, v := range allProjects { _, err2 := d.GetProjectUser(v.ID, userID) if err2 == nil { projects = append(projects, v) } else if err2 != db.ErrNotFound { err = err2 return } } return } func (d *BoltDb) GetProject(projectID int) (project db.Project, err error) { err = d.getObject(0, db.ProjectProps, intObjectID(projectID), &project) return } func (d *BoltDb) DeleteProject(projectID int) error { return d.deleteObject(0, db.ProjectProps, intObjectID(projectID), nil) } func (d *BoltDb) UpdateProject(project db.Project) error { return d.updateObject(0, db.ProjectProps, project) } ================================================ FILE: db/bolt/project_invite.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" ) func (d *BoltDb) GetProjectInvites(projectID int, params db.RetrieveQueryParams) (invites []db.ProjectInviteWithUser, err error) { invites = make([]db.ProjectInviteWithUser, 0) var projectInvites []db.ProjectInvite err = d.getObjects(projectID, db.ProjectInviteProps, params, nil, &projectInvites) if err != nil { return } for _, invite := range projectInvites { var inviteWithUser = db.ProjectInviteWithUser{ ProjectInvite: invite, } // Get invited by user info invitedByUser, err := d.GetUser(invite.InviterUserID) if err == nil { inviteWithUser.InvitedByUser = &invitedByUser } // Get user info if user exists if invite.UserID != nil { user, err := d.GetUser(*invite.UserID) if err == nil { inviteWithUser.User = &user } } invites = append(invites, inviteWithUser) } return } func (d *BoltDb) CreateProjectInvite(invite db.ProjectInvite) (db.ProjectInvite, error) { newInvite, err := d.createObject(invite.ProjectID, db.ProjectInviteProps, invite) if err != nil { return db.ProjectInvite{}, err } return newInvite.(db.ProjectInvite), nil } func (d *BoltDb) GetProjectInvite(projectID int, inviteID int) (invite db.ProjectInvite, err error) { err = d.getObject(projectID, db.ProjectInviteProps, intObjectID(inviteID), &invite) return } func (d *BoltDb) GetProjectInviteByToken(token string) (invite db.ProjectInvite, err error) { var allInvites []db.ProjectInvite // Get all projects to search across all invites projects, err := d.GetAllProjects() if err != nil { return } for _, project := range projects { var projectInvites []db.ProjectInvite err = d.getObjects(project.ID, db.ProjectInviteProps, db.RetrieveQueryParams{}, nil, &projectInvites) if err != nil { continue } allInvites = append(allInvites, projectInvites...) } for _, inv := range allInvites { if inv.Token == token { invite = inv return } } err = db.ErrNotFound return } func (d *BoltDb) UpdateProjectInvite(invite db.ProjectInvite) error { return d.updateObject(invite.ProjectID, db.ProjectInviteProps, invite) } func (d *BoltDb) DeleteProjectInvite(projectID int, inviteID int) error { return d.deleteObject(projectID, db.ProjectInviteProps, intObjectID(inviteID), nil) } ================================================ FILE: db/bolt/project_test.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/tz" "testing" ) func TestGetProjects(t *testing.T) { store := CreateTestStore() usr, err := store.CreateUser(db.UserWithPwd{ Pwd: "123456", User: db.User{ Email: "denguk@example.com", Name: "Denis Gukov", Username: "fiftin", }, }) if err != nil { t.Fatal(err.Error()) } proj1, err := store.CreateProject(db.Project{ Created: tz.Now(), Name: "Test1", }) if err != nil { t.Fatal(err.Error()) } _, err = store.CreateProjectUser(db.ProjectUser{ ProjectID: proj1.ID, UserID: usr.ID, Role: db.ProjectOwner, }) if err != nil { t.Fatal(err.Error()) } found, err := store.GetProjects(usr.ID) if err != nil { t.Fatal(err.Error()) } if len(found) != 1 { t.Fatal(err.Error()) } } func TestGetProject(t *testing.T) { store := CreateTestStore() proj, err := store.CreateProject(db.Project{ Created: tz.Now(), Name: "Test1", }) if err != nil { t.Fatal(err.Error()) } found, err := store.GetProject(proj.ID) if err != nil { t.Fatal(err.Error()) } if found.Name != "Test1" { t.Fatal(err.Error()) } err = store.DeleteProject(proj.ID) if err != nil { t.Fatal(err.Error()) } } ================================================ FILE: db/bolt/public_alias.go ================================================ package bolt import ( "errors" "fmt" "github.com/semaphoreui/semaphore/db" "reflect" ) type publicAlias struct { aliasProps db.ObjectProps publicAliasProps db.ObjectProps db *BoltDb } func (d *publicAlias) getAliases(projectID int, filter func(i any) bool, res any) (err error) { err = d.db.getObjects(projectID, d.aliasProps, db.RetrieveQueryParams{}, filter, res) return } func (d *publicAlias) getAlias(projectID int, aliasID int, res any) (err error) { err = d.db.getObject(projectID, d.aliasProps, intObjectID(aliasID), res) return } func (d *publicAlias) getPublicAlias(alias string, aliasObj any) (err error) { err = d.db.getObject(-1, d.publicAliasProps, strObjectID(alias), aliasObj) return } func (d *publicAlias) createAlias(aliasObj any) (newAlias any, err error) { alias := aliasObj.(db.Aliasable).ToAlias() err = d.getPublicAlias(alias.Alias, newAlias) if err == nil { err = fmt.Errorf("alias already exists") } if !errors.Is(err, db.ErrNotFound) { return } newAlias, err = d.db.createObject(alias.ProjectID, d.aliasProps, aliasObj) if err != nil { return } _, err = d.db.createObject(-1, d.publicAliasProps, aliasObj) if err != nil { _ = d.deleteIntegrationAlias(alias.ProjectID, alias.ID) return } return } func (d *publicAlias) deleteIntegrationAlias(projectID int, aliasID int) (err error) { aliasPtr := reflect.New(d.aliasProps.Type) aliasObj := aliasPtr.Elem().Interface() alias := aliasObj.(db.Aliasable).ToAlias() err = d.db.getObject(projectID, d.aliasProps, intObjectID(aliasID), aliasPtr.Interface()) if err != nil { return } err = d.db.deleteObject(projectID, d.aliasProps, intObjectID(aliasID), nil) if err != nil { return } err = d.db.deleteObject(-1, d.publicAliasProps, strObjectID(alias.Alias), nil) if err != nil { return } return } ================================================ FILE: db/bolt/repository.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" ) func (d *BoltDb) GetRepository(projectID int, repositoryID int) (repository db.Repository, err error) { err = d.getObject(projectID, db.RepositoryProps, intObjectID(repositoryID), &repository) if err != nil { return } repository.SSHKey, err = d.GetAccessKey(projectID, repository.SSHKeyID) return } func (d *BoltDb) GetRepositoryRefs(projectID int, repositoryID int) (db.ObjectReferrers, error) { return d.getObjectRefs(projectID, db.RepositoryProps, repositoryID) } func (d *BoltDb) GetRepositories(projectID int, params db.RetrieveQueryParams) (repositories []db.Repository, err error) { err = d.getObjects(projectID, db.RepositoryProps, params, nil, &repositories) return } func (d *BoltDb) UpdateRepository(repository db.Repository) error { err := repository.Validate() if err != nil { return err } return d.updateObject(repository.ProjectID, db.RepositoryProps, repository) } func (d *BoltDb) CreateRepository(repository db.Repository) (db.Repository, error) { err := repository.Validate() if err != nil { return db.Repository{}, err } newRepo, err := d.createObject(repository.ProjectID, db.RepositoryProps, repository) return newRepo.(db.Repository), err } func (d *BoltDb) DeleteRepository(projectID int, repositoryId int) error { return d.deleteObject(projectID, db.RepositoryProps, intObjectID(repositoryId), nil) } ================================================ FILE: db/bolt/role.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" ) func (d *BoltDb) GetGlobalRole(roleID int) (role db.Role, err error) { err = d.getObject(0, db.RoleProps, intObjectID(roleID), &role) return } func (d *BoltDb) GetGlobalRoleBySlug(slug string) (db.Role, error) { var roles []db.Role err := d.getObjects(0, db.RoleProps, db.RetrieveQueryParams{}, func(i any) bool { role := i.(db.Role) return role.Slug == slug && role.ProjectID == nil }, &roles) if err != nil { return db.Role{}, err } if len(roles) == 0 { return db.Role{}, db.ErrNotFound } return roles[0], nil } func (d *BoltDb) GetProjectRoles(projectID int) (roles []db.Role, err error) { err = d.getObjects(0, db.RoleProps, db.RetrieveQueryParams{}, func(i any) bool { role := i.(db.Role) return role.ProjectID != nil && *role.ProjectID == projectID }, &roles) return } func (d *BoltDb) GetGlobalRoles() (roles []db.Role, err error) { err = d.getObjects(0, db.RoleProps, db.RetrieveQueryParams{}, func(i any) bool { role := i.(db.Role) return role.ProjectID == nil }, &roles) return } func (d *BoltDb) UpdateRole(role db.Role) error { return d.updateObject(0, db.RoleProps, role) } func (d *BoltDb) CreateRole(role db.Role) (newRole db.Role, err error) { newRoleInterface, err := d.createObject(0, db.RoleProps, role) if err != nil { return } newRole = newRoleInterface.(db.Role) return } func (d *BoltDb) DeleteRole(slug string) error { return d.deleteObject(0, db.RoleProps, strObjectID(slug), nil) } func (d *BoltDb) GetProjectRole(projectID int, slug string) (db.Role, error) { var role db.Role err := d.getObject(0, db.RoleProps, strObjectID(slug), &role) if err != nil { return db.Role{}, err } // Verify the role belongs to the specified project if role.ProjectID == nil || *role.ProjectID != projectID { return db.Role{}, db.ErrNotFound } return role, nil } func (d *BoltDb) GetProjectOrGlobalRoleBySlug(projectID int, slug string) (db.Role, error) { var roles []db.Role err := d.getObjects(0, db.RoleProps, db.RetrieveQueryParams{}, func(i any) bool { role := i.(db.Role) return role.Slug == slug }, &roles) if err != nil { return db.Role{}, err } if len(roles) == 0 { return db.Role{}, db.ErrNotFound } return roles[0], nil } ================================================ FILE: db/bolt/runner_pro.go ================================================ package bolt import ( "fmt" "github.com/semaphoreui/semaphore/db" "go.etcd.io/bbolt" ) func (d *BoltDb) GetRunner(projectID int, runnerID int) (runner db.Runner, err error) { err = d.getObject(0, db.GlobalRunnerProps, intObjectID(runnerID), &runner) if err != nil { return } if runner.ProjectID == nil || *runner.ProjectID != projectID { err = db.ErrNotFound } return } func validateTag(tag string) error { if tag == "" { return fmt.Errorf("tag cannot be empty") } return nil } func (d *BoltDb) GetRunners(projectID int, activeOnly bool, tag *string) (runners []db.Runner, err error) { if tag != nil { err = validateTag(*tag) if err != nil { return } } runners = make([]db.Runner, 0) err = d.getObjects(0, db.GlobalRunnerProps, db.RetrieveQueryParams{}, func(i interface{}) bool { runner := i.(db.Runner) if runner.ProjectID == nil || *runner.ProjectID != projectID { return false } if tag != nil && runner.Tag != *tag { return false } if activeOnly { return runner.Active } return true }, &runners) return } func (d *BoltDb) DeleteRunner(projectID int, runnerID int) error { return d.db.Update(func(tx *bbolt.Tx) error { runner, err := d.GetRunner(projectID, runnerID) if err != nil { return err } if runner.ProjectID == nil || *runner.ProjectID != projectID { return db.ErrNotFound } return d.deleteObject(0, db.GlobalRunnerProps, intObjectID(runnerID), tx) }) } func (d *BoltDb) GetRunnerTags(projectID int) ([]db.RunnerTag, error) { runners, err := d.GetRunners(projectID, false, nil) if err != nil { return nil, err } tagMap := make(map[string]int) for _, runner := range runners { if runner.Tag != "" { tagMap[runner.Tag]++ } } res := make([]db.RunnerTag, 0, len(tagMap)) for tag, count := range tagMap { res = append(res, db.RunnerTag{ Tag: tag, NumberOfRunners: count, }) } return res, nil } func (d *BoltDb) GetRunnerCount() (res int, err error) { runners := make([]db.Runner, 0) err = d.getObjects(0, db.GlobalRunnerProps, db.RetrieveQueryParams{}, func(i interface{}) bool { runner := i.(db.Runner) return runner.ProjectID != nil }, &runners) if err != nil { return 0, err } return len(runners), nil } ================================================ FILE: db/bolt/runner_pro_test.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" "github.com/stretchr/testify/assert" "testing" ) func Test_DeleteRunner_DeletesProjectRunner(t *testing.T) { store := CreateTestStore() project, err := store.CreateProject(db.Project{}) assert.NoError(t, err) testRunner, err := store.CreateRunner(db.Runner{ProjectID: &project.ID}) assert.NoError(t, err) err = store.DeleteRunner(project.ID, testRunner.ID) assert.NoError(t, err) _, err = store.GetRunner(project.ID, testRunner.ID) assert.ErrorIs(t, err, db.ErrNotFound) } ================================================ FILE: db/bolt/schedule.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" "go.etcd.io/bbolt" ) func (d *BoltDb) GetSchedules() (schedules []db.Schedule, err error) { var allProjects []db.Project err = d.getObjects(0, db.ProjectProps, db.RetrieveQueryParams{}, nil, &allProjects) if err != nil { return } for _, proj := range allProjects { var projSchedules []db.Schedule projSchedules, err = d.getProjectSchedules(proj.ID, nil) if err != nil { return } schedules = append(schedules, projSchedules...) } return } func (d *BoltDb) getProjectSchedules(projectID int, filter func(referringObj db.Schedule) bool) (schedules []db.Schedule, err error) { schedules = []db.Schedule{} err = d.getObjects(projectID, db.ScheduleProps, db.RetrieveQueryParams{}, func(referringObj any) bool { return filter == nil || filter(referringObj.(db.Schedule)) }, &schedules) return } func (d *BoltDb) GetProjectSchedules(projectID int, includeTaskParams bool, includeCommitCheckers bool) (schedules []db.ScheduleWithTpl, err error) { schedules = []db.ScheduleWithTpl{} orig, err := d.getProjectSchedules(projectID, func(s db.Schedule) bool { if includeCommitCheckers { return true } return s.RepositoryID == nil }) if err != nil { return } for _, s := range orig { var tpl db.Template tpl, err = d.GetTemplate(projectID, s.TemplateID) if err != nil { return } schedules = append(schedules, db.ScheduleWithTpl{ Schedule: s, TemplateName: tpl.Name, }) } return } func (d *BoltDb) GetTemplateSchedules(projectID int, templateID int, onlyCommitCheckers bool) (schedules []db.Schedule, err error) { schedules, err = d.getProjectSchedules(projectID, func(s db.Schedule) bool { return s.TemplateID == templateID && (!onlyCommitCheckers || s.RepositoryID != nil) }) return } func (d *BoltDb) CreateSchedule(schedule db.Schedule) (newSchedule db.Schedule, err error) { newTpl, err := d.createObject(schedule.ProjectID, db.ScheduleProps, schedule) if err != nil { return } newSchedule = newTpl.(db.Schedule) return } func (d *BoltDb) UpdateSchedule(schedule db.Schedule) error { return d.updateObject(schedule.ProjectID, db.ScheduleProps, schedule) } func (d *BoltDb) GetSchedule(projectID int, scheduleID int) (schedule db.Schedule, err error) { err = d.getObject(projectID, db.ScheduleProps, intObjectID(scheduleID), &schedule) return } func (d *BoltDb) deleteSchedule(projectID int, scheduleID int, tx *bbolt.Tx) error { return d.deleteObject(projectID, db.ScheduleProps, intObjectID(scheduleID), tx) } func (d *BoltDb) DeleteSchedule(projectID int, scheduleID int) error { return d.db.Update(func(tx *bbolt.Tx) error { return d.deleteSchedule(projectID, scheduleID, tx) }) } func (d *BoltDb) SetScheduleActive(projectID int, scheduleID int, active bool) error { schedule, err := d.GetSchedule(projectID, scheduleID) if err != nil { return err } schedule.Active = active return d.updateObject(projectID, db.ScheduleProps, schedule) } func (d *BoltDb) SetScheduleCommitHash(projectID int, scheduleID int, hash string) error { schedule, err := d.GetSchedule(projectID, scheduleID) if err != nil { return err } schedule.LastCommitHash = &hash return d.updateObject(projectID, db.ScheduleProps, schedule) } ================================================ FILE: db/bolt/secret_storage.go ================================================ package bolt import "github.com/semaphoreui/semaphore/db" func (d *BoltDb) GetSecretStorages(projectID int) ([]db.SecretStorage, error) { return []db.SecretStorage{}, nil } func (d *BoltDb) CreateSecretStorage(storage db.SecretStorage) (db.SecretStorage, error) { //TODO implement me panic("implement me") } func (d *BoltDb) GetSecretStorage(projectID int, storageID int) (db.SecretStorage, error) { //TODO implement me panic("implement me") } func (d *BoltDb) DeleteSecretStorage(projectID int, storageID int) error { panic("implement me") } func (d *BoltDb) UpdateSecretStorage(storage db.SecretStorage) error { //TODO implement me panic("implement me") } func (d *BoltDb) GetSecretStorageRefs(projectID int, storageID int) (db.ObjectReferrers, error) { return d.getObjectRefs(projectID, db.SecretStorageProps, storageID) } ================================================ FILE: db/bolt/session.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/tz" "reflect" "slices" "strings" ) type globalToken struct { ID string `db:"id" json:"id"` UserID int `db:"user_id" json:"user_id"` } var globalTokenObject = db.ObjectProps{ TableName: "token", PrimaryColumnName: "id", Type: reflect.TypeOf(globalToken{}), IsGlobal: true, } func (d *BoltDb) CreateSession(session db.Session) (db.Session, error) { newSession, err := d.createObject(session.UserID, db.SessionProps, session) if err != nil { return db.Session{}, err } return newSession.(db.Session), nil } func (d *BoltDb) CreateAPIToken(token db.APIToken) (db.APIToken, error) { token.Created = db.GetParsedTime(tz.Now()) // create token in bucket "token_" newToken, err := d.createObject(token.UserID, db.TokenProps, token) if err != nil { return db.APIToken{}, err } // create token in bucket "token" _, err = d.createObject(0, globalTokenObject, globalToken{ID: token.ID, UserID: token.UserID}) if err != nil { return db.APIToken{}, err } return newToken.(db.APIToken), nil } func (d *BoltDb) GetAPIToken(tokenID string) (token db.APIToken, err error) { var t globalToken err = d.getObject(0, globalTokenObject, strObjectID(tokenID), &t) if err != nil { return } err = d.getObject(t.UserID, db.TokenProps, strObjectID(tokenID), &token) return } func (d *BoltDb) ExpireAPIToken(userID int, tokenID string) (err error) { var token db.APIToken err = d.getObject(userID, db.TokenProps, strObjectID(tokenID), &token) if err != nil { return } token.Expired = true err = d.updateObject(userID, db.TokenProps, token) return } func (d *BoltDb) DeleteAPIToken(userID int, tokenID string) (err error) { var tokens []db.APIToken err = d.getObjects(userID, db.TokenProps, db.RetrieveQueryParams{}, func(i any) bool { token := i.(db.APIToken) return strings.HasPrefix(token.ID, tokenID) }, &tokens) if err != nil { return } if len(tokens) == 0 { return db.ErrNotFound } err = d.deleteObject(userID, db.TokenProps, strObjectID(tokens[0].ID), nil) return } func (d *BoltDb) GetSession(userID int, sessionID int) (session db.Session, err error) { err = d.getObject(userID, db.SessionProps, intObjectID(sessionID), &session) return } func (d *BoltDb) ExpireSession(userID int, sessionID int) (err error) { var session db.Session err = d.getObject(userID, db.SessionProps, intObjectID(sessionID), &session) if err != nil { return } session.Expired = true err = d.updateObject(userID, db.SessionProps, session) return } func (d *BoltDb) SetSessionVerificationMethod(userID int, sessionID int, verificationMethod db.SessionVerificationMethod) error { return nil } func (d *BoltDb) VerifySession(userID int, sessionID int) (err error) { var session db.Session err = d.getObject(userID, db.SessionProps, intObjectID(sessionID), &session) if err != nil { return } session.Verified = true err = d.updateObject(userID, db.SessionProps, session) return } func (d *BoltDb) TouchSession(userID int, sessionID int) (err error) { var session db.Session err = d.getObject(userID, db.SessionProps, intObjectID(sessionID), &session) if err != nil { return } session.LastActive = tz.Now() err = d.updateObject(userID, db.SessionProps, session) return } func (d *BoltDb) GetAPITokens(userID int) (tokens []db.APIToken, err error) { err = d.getObjects(userID, db.TokenProps, db.RetrieveQueryParams{}, nil, &tokens) slices.SortFunc(tokens, func(a, b db.APIToken) int { if a.Created.Before(b.Created) { return 1 } if a.Created.After(b.Created) { return -1 } return 0 }) return } ================================================ FILE: db/bolt/task.go ================================================ package bolt import ( "time" "github.com/semaphoreui/semaphore/db" "go.etcd.io/bbolt" ) func (d *BoltDb) CreateTaskStage(stage db.TaskStage) (db.TaskStage, error) { newOutput, err := d.createObject(stage.TaskID, db.TaskStageProps, stage) if err != nil { return db.TaskStage{}, err } return newOutput.(db.TaskStage), nil } func (d *BoltDb) GetTaskStages(projectID int, taskID int) (res []db.TaskStageWithResult, err error) { // check if task exists in the project _, err = d.GetTask(projectID, taskID) if err != nil { return } var stages []db.TaskStage err = d.getObjects(taskID, db.TaskStageProps, db.RetrieveQueryParams{}, nil, &stages) if err != nil { return } // Convert TaskStage to TaskStageWithResult res = make([]db.TaskStageWithResult, len(stages)) for i, stage := range stages { res[i] = db.TaskStageWithResult{ ID: stage.ID, TaskID: stage.TaskID, Start: stage.Start, End: stage.End, Type: stage.Type, } } return } func (d *BoltDb) clearTasks(projectID int, templateID int, maxTasks int) { tpl, err := d.GetTemplate(projectID, templateID) if err != nil { return } nTasks := tpl.Tasks if nTasks == 0 { // recalculate number of tasks for the template n, err := d.count(projectID, db.TaskProps, db.RetrieveQueryParams{}, func(item any) bool { task := item.(db.Task) return task.TemplateID == templateID }) if err != nil { return } if n != nTasks { tpl.Tasks = n err = d.UpdateTemplate(tpl) if err != nil { return } } nTasks = n } if nTasks < maxTasks { return } i := 0 _ = d.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket(makeBucketId(db.TaskProps, projectID)) if b == nil { return db.ErrNotFound } c := b.Cursor() return apply(c, db.TaskProps, db.RetrieveQueryParams{}, func(item any) bool { task := item.(db.Task) if task.TemplateID != templateID { return false } i++ return i > maxTasks }, func(i any) error { task := i.(db.Task) return d.deleteTaskWithOutputs(projectID, task.ID, false, tx) }) }) } func (d *BoltDb) CreateTask(task db.Task, maxTasks int) (newTask db.Task, err error) { err = task.PreInsert(nil) if err != nil { return } task.ID = 0 res, err := d.createObject(0, db.TaskProps, task) if err != nil { return } newTask = res.(db.Task) if maxTasks > 0 { d.clearTasks(task.ProjectID, task.TemplateID, maxTasks) } return } func (d *BoltDb) UpdateTask(task db.Task) error { return d.updateObject(0, db.TaskProps, task) } func (d *BoltDb) CreateTaskOutput(output db.TaskOutput) (db.TaskOutput, error) { newOutput, err := d.createObject(output.TaskID, db.TaskOutputProps, output) if err != nil { return db.TaskOutput{}, err } return newOutput.(db.TaskOutput), nil } func (d *BoltDb) InsertTaskOutputBatch(output []db.TaskOutput) error { if len(output) == 0 { return nil } return d.db.Update(func(tx *bbolt.Tx) error { for _, out := range output { _, err := d.createObjectTx(tx, out.TaskID, db.TaskOutputProps, out) if err != nil { return err } } return nil }) } func (d *BoltDb) getTasks(projectID int, templateID *int, params db.RetrieveQueryParams) (tasksWithTpl []db.TaskWithTpl, err error) { var tasks []db.Task err = d.getObjects(0, db.TaskProps, params, func(tsk any) bool { task := tsk.(db.Task) if task.ProjectID != projectID { return false } if templateID != nil && task.TemplateID != *templateID { return false } return true }, &tasks) if err != nil { return } var templates = make(map[int]db.Template) var users = make(map[int]db.User) tasksWithTpl = make([]db.TaskWithTpl, len(tasks)) for i, task := range tasks { tpl, ok := templates[task.TemplateID] if !ok { if templateID == nil { tpl, _ = d.getRawTemplate(task.ProjectID, task.TemplateID) } else { tpl, _ = d.getRawTemplate(task.ProjectID, *templateID) } templates[task.TemplateID] = tpl } tasksWithTpl[i] = db.TaskWithTpl{Task: task} tasksWithTpl[i].TemplatePlaybook = tpl.Playbook tasksWithTpl[i].TemplateAlias = tpl.Name tasksWithTpl[i].TemplateType = tpl.Type tasksWithTpl[i].TemplateApp = tpl.App if task.UserID != nil { usr, ok := users[*task.UserID] if !ok { // trying to get user , but ignore error, because // user can be deleted, and it is ok usr, _ = d.GetUser(*task.UserID) users[*task.UserID] = usr } tasksWithTpl[i].UserName = &usr.Name } err = tasksWithTpl[i].Fill(d) if err != nil { return } } return } func (d *BoltDb) GetTask(projectID int, taskID int) (task db.Task, err error) { err = d.getObject(0, db.TaskProps, intObjectID(taskID), &task) if err != nil { return } if task.ProjectID != projectID { task = db.Task{} err = db.ErrNotFound return } return } func (d *BoltDb) GetTemplateTasks(projectID int, templateID int, params db.RetrieveQueryParams) ([]db.TaskWithTpl, error) { return d.getTasks(projectID, &templateID, params) } func (d *BoltDb) GetProjectTasks(projectID int, params db.RetrieveQueryParams) ([]db.TaskWithTpl, error) { return d.getTasks(projectID, nil, params) } func (d *BoltDb) deleteTaskWithOutputs(projectID int, taskID int, checkTaskExisting bool, tx *bbolt.Tx) (err error) { if checkTaskExisting { _, err = d.GetTask(projectID, taskID) if err != nil { return } } err = d.deleteObject(0, db.TaskProps, intObjectID(taskID), tx) if err != nil { return } err = tx.DeleteBucket(makeBucketId(db.TaskOutputProps, taskID)) if err == bbolt.ErrBucketNotFound { err = nil } return } func (d *BoltDb) DeleteTaskWithOutputs(projectID int, taskID int) error { return d.db.Update(func(tx *bbolt.Tx) error { return d.deleteTaskWithOutputs(projectID, taskID, true, tx) }) } func (d *BoltDb) GetTaskOutputs(projectID int, taskID int, params db.RetrieveQueryParams) (outputs []db.TaskOutput, err error) { // check if task exists in the project _, err = d.GetTask(projectID, taskID) if err != nil { return } err = d.getObjects(taskID, db.TaskOutputProps, params, nil, &outputs) return } func (d *BoltDb) EndTaskStage(taskID int, stageID int, end time.Time) error { return nil } func (d *BoltDb) CreateTaskStageResult(taskID int, stageID int, result map[string]any) error { return nil } func (d *BoltDb) GetTaskStageResult(projectID int, taskID int, stageID int) (res db.TaskStageResult, err error) { return } func (d *BoltDb) GetTaskStageOutputs(projectID int, taskID int, stageID int) (res []db.TaskOutput, err error) { return } func (d *BoltDb) GetNodeCount() (int, error) { return 0, nil } func (d *BoltDb) GetUiCount() (int, error) { return 1, nil } ================================================ FILE: db/bolt/template.go ================================================ package bolt import ( "encoding/json" "errors" "sort" "github.com/semaphoreui/semaphore/db" "go.etcd.io/bbolt" ) func (d *BoltDb) CreateTemplate(template db.Template) (newTemplate db.Template, err error) { err = template.Validate() if err != nil { return } template.SurveyVarsJSON = db.ObjectToJSON(template.SurveyVars) newTpl, err := d.createObject(template.ProjectID, db.TemplateProps, template) if err != nil { return } newTemplate = newTpl.(db.Template) err = d.UpdateTemplateVaults(template.ProjectID, newTemplate.ID, template.Vaults) if err != nil { return } err = db.FillTemplate(d, &newTemplate) return } func (d *BoltDb) UpdateTemplate(template db.Template) error { err := template.Validate() if err != nil { return err } template.SurveyVarsJSON = db.ObjectToJSON(template.SurveyVars) err = d.updateObject(template.ProjectID, db.TemplateProps, template) if err != nil { return err } return d.UpdateTemplateVaults(template.ProjectID, template.ID, template.Vaults) } func (d *BoltDb) setTemplateDescriptionTx(projectID int, templateID int, description string, tx *bbolt.Tx) error { template, err := d.getRawTemplateTx(projectID, templateID, tx) if err != nil { return err } if description == "" { template.Description = nil } else { template.Description = &description } err = d.updateObjectTx(tx, projectID, db.TemplateProps, template) return err } func (d *BoltDb) SetTemplateDescription(projectID int, templateID int, description string) error { err := d.db.Update(func(tx *bbolt.Tx) error { return d.setTemplateDescriptionTx(projectID, templateID, description, tx) }) return err } func (d *BoltDb) GetTemplatesWithPermissions(projectID int, userID int, filter db.TemplateFilter, params db.RetrieveQueryParams) (templates []db.TemplateWithPerms, err error) { res, err := d.GetTemplates(projectID, filter, params) if err != nil { return } for _, tpl := range res { var tplWithPerms db.TemplateWithPerms tplWithPerms.Template = tpl templates = append(templates, tplWithPerms) } return } func (d *BoltDb) GetTemplates(projectID int, filter db.TemplateFilter, params db.RetrieveQueryParams) (templates []db.Template, err error) { var view db.View if filter.ViewID != nil { view, err = d.GetView(projectID, *filter.ViewID) if err != nil { return } } var ftr = func(tpl any) bool { template := tpl.(db.Template) var res = true if filter.App != nil { res = res && template.App == *filter.App } if filter.ViewID != nil { switch view.Type { case db.ViewTypeAll: case db.ViewTypeCustom: res = res && template.ViewID != nil && *template.ViewID == *filter.ViewID } } if filter.BuildTemplateID != nil { res = res && template.BuildTemplateID != nil && *template.BuildTemplateID == *filter.BuildTemplateID if filter.AutorunOnly { res = res && template.Autorun } } return res } err = d.getObjects(projectID, db.TemplateProps, params, ftr, &templates) var sortColumn string var sortReverse bool if params.SortBy != "" { sortColumn = params.SortBy sortReverse = params.SortInverted } else if filter.ViewID != nil && view.SortColumn != nil { sortColumn = *view.SortColumn sortReverse = view.SortReverse } switch sortColumn { case "name": sort.Slice(templates, func(i, j int) bool { if sortReverse { return templates[i].Name > templates[j].Name } else { return templates[i].Name < templates[j].Name } }) } if err != nil { return } templatesMap := make(map[int]*db.Template) for i := 0; i < len(templates); i++ { if templates[i].SurveyVarsJSON != nil { err = json.Unmarshal([]byte(*templates[i].SurveyVarsJSON), &templates[i].SurveyVars) } if err != nil { return } templatesMap[templates[i].ID] = &templates[i] } unfilledTemplateCount := len(templates) var errEndOfTemplates = errors.New("no more templates to filling") err = d.apply(projectID, db.TaskProps, db.RetrieveQueryParams{}, func(i any) error { task := i.(db.Task) if task.ProjectID != projectID { return nil } tpl, ok := templatesMap[task.TemplateID] if !ok { return nil } if tpl.LastTask != nil { return nil } tpl.LastTask = &db.TaskWithTpl{ Task: task, TemplatePlaybook: tpl.Playbook, TemplateAlias: tpl.Name, TemplateType: tpl.Type, TemplateApp: tpl.App, } unfilledTemplateCount-- if unfilledTemplateCount <= 0 { return errEndOfTemplates } return nil }) if errors.Is(err, errEndOfTemplates) { err = nil } return } func (d *BoltDb) getRawTemplateTx(projectID int, templateID int, tx *bbolt.Tx) (template db.Template, err error) { err = d.getObjectTx(tx, projectID, db.TemplateProps, intObjectID(templateID), &template) return } func (d *BoltDb) getRawTemplate(projectID int, templateID int) (template db.Template, err error) { err = d.getObject(projectID, db.TemplateProps, intObjectID(templateID), &template) return } func (d *BoltDb) GetTemplate(projectID int, templateID int) (template db.Template, err error) { template, err = d.getRawTemplate(projectID, templateID) if err != nil { return } err = db.FillTemplate(d, &template) return } func (d *BoltDb) deleteTemplate(projectID int, templateID int, tx *bbolt.Tx) (err error) { inUse, err := d.isObjectInUse(projectID, db.TemplateProps, intObjectID(templateID), db.TemplateProps) if err != nil { return err } if inUse { return db.ErrInvalidOperation } tasks, err := d.GetTemplateTasks(projectID, templateID, db.RetrieveQueryParams{}) if err != nil { return } for _, task := range tasks { err = d.deleteTaskWithOutputs(projectID, task.ID, true, tx) if err != nil { return } } schedules, err := d.GetTemplateSchedules(projectID, templateID, false) if err != nil { return } for _, sch := range schedules { err = d.deleteSchedule(projectID, sch.ID, tx) if err != nil { return } } // Delete template vaults vaults, err := d.GetTemplateVaults(projectID, templateID) if err != nil { return } for _, sch := range vaults { err = d.deleteTemplateVault(projectID, sch.ID, tx) if err != nil { return } } integrations, err := d.GetIntegrations(projectID, db.RetrieveQueryParams{}, false) if err != nil { return } for _, integration := range integrations { if integration.TemplateID != templateID { continue } d.deleteIntegration(projectID, integration.ID, tx) } return d.deleteObject(projectID, db.TemplateProps, intObjectID(templateID), tx) } func (d *BoltDb) DeleteTemplate(projectID int, templateID int) error { return d.db.Update(func(tx *bbolt.Tx) error { return d.deleteTemplate(projectID, templateID, tx) }) } func (d *BoltDb) GetTemplateRefs(projectID int, templateID int) (db.ObjectReferrers, error) { return d.getObjectRefs(projectID, db.TemplateProps, templateID) } func (d *BoltDb) GetTemplatePermission(projectID int, templateID int, userID int) (perm db.ProjectUserPermission, err error) { return } func (d *BoltDb) GetTemplateRoles(projectID int, templateID int) (roles []db.TemplateRolePerm, err error) { roles = []db.TemplateRolePerm{} return } func (d *BoltDb) CreateTemplateRole(role db.TemplateRolePerm) (newRole db.TemplateRolePerm, err error) { return } func (d *BoltDb) DeleteTemplateRole(projectID int, templateID int, roleID int) error { return nil } func (d *BoltDb) UpdateTemplateRole(role db.TemplateRolePerm) error { return nil } func (d *BoltDb) GetTemplateRole(projectID int, templateID int, roleID int) (role db.TemplateRolePerm, err error) { return } ================================================ FILE: db/bolt/template_test.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/tz" "testing" ) func Test_SetTemplateDescription(t *testing.T) { store := CreateTestStore() proj, err := store.CreateProject(db.Project{ Created: tz.Now(), Name: "TestProject", }) if err != nil { t.Fatal(err.Error()) } template, err := store.CreateTemplate(db.Template{ ProjectID: proj.ID, Name: "TestTemplate", Playbook: "test.yml", }) if err != nil { t.Fatal(err.Error()) } err = store.SetTemplateDescription(proj.ID, template.ID, "New description") if err != nil { t.Fatal(err.Error()) } tpl, err := store.GetTemplate(proj.ID, template.ID) if err != nil { t.Fatal(err.Error()) } if *tpl.Description != "New description" { t.Fatalf("expected description to be 'New description', got '%s'", *tpl.Description) } } ================================================ FILE: db/bolt/template_vault.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" "go.etcd.io/bbolt" ) func (d *BoltDb) GetTemplateVaults(projectID int, templateID int) (vaults []db.TemplateVault, err error) { err = d.getObjects(projectID, db.TemplateVaultProps, db.RetrieveQueryParams{}, func(referringObj any) bool { return referringObj.(db.TemplateVault).TemplateID == templateID }, &vaults) if err != nil { return } for i := range vaults { err = db.FillTemplateVault(d, projectID, &vaults[i]) if err != nil { return } } return } func (d *BoltDb) CreateTemplateVault(vault db.TemplateVault) (newVault db.TemplateVault, err error) { var newTpl any newTpl, err = d.createObject(vault.ProjectID, db.TemplateVaultProps, vault) if err != nil { return } newVault = newTpl.(db.TemplateVault) return } func (d *BoltDb) UpdateTemplateVaults(projectID int, templateID int, vaults []db.TemplateVault) (err error) { if vaults == nil { vaults = []db.TemplateVault{} } var oldVaults []db.TemplateVault oldVaults, err = d.GetTemplateVaults(projectID, templateID) err = d.db.Update(func(tx *bbolt.Tx) error { for _, vault := range oldVaults { err = d.deleteObject(projectID, db.TemplateVaultProps, intObjectID(vault.ID), tx) if err != nil { return err } } for _, vault := range vaults { vault.ProjectID = projectID vault.TemplateID = templateID switch vault.Type { case "password": vault.Script = nil case "script": vault.VaultKeyID = nil } _, err = d.createObjectTx(tx, projectID, db.TemplateVaultProps, vault) if err != nil { return err } } return nil }) return } func (d *BoltDb) deleteTemplateVault(projectID int, vaultID int, tx *bbolt.Tx) error { return d.deleteObject(projectID, db.TemplateVaultProps, intObjectID(vaultID), tx) } ================================================ FILE: db/bolt/template_vault_test.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/tz" "testing" ) func TestGetTemplateVaults(t *testing.T) { store := CreateTestStore() proj, err := store.CreateProject(db.Project{ Created: tz.Now(), Name: "TestProject", }) if err != nil { t.Fatal(err.Error()) } template, err := store.CreateTemplate(db.Template{ ProjectID: proj.ID, Name: "TestTemplate", Playbook: "test.yml", }) if err != nil { t.Fatal(err.Error()) } vault, err := store.CreateTemplateVault(db.TemplateVault{ ProjectID: proj.ID, TemplateID: template.ID, Type: "password", }) if err != nil { t.Fatal(err.Error()) } vaults, err := store.GetTemplateVaults(proj.ID, template.ID) if err != nil { t.Fatal(err.Error()) } if len(vaults) != 1 || vaults[0].ID != vault.ID { t.Fatalf("expected 1 vault, got %d", len(vaults)) } } func TestCreateTemplateVault(t *testing.T) { store := CreateTestStore() proj, err := store.CreateProject(db.Project{ Created: tz.Now(), Name: "TestProject", }) if err != nil { t.Fatal(err.Error()) } template, err := store.CreateTemplate(db.Template{ ProjectID: proj.ID, Name: "TestTemplate", Playbook: "test.yml", }) if err != nil { t.Fatal(err.Error()) } vault, err := store.CreateTemplateVault(db.TemplateVault{ ProjectID: proj.ID, TemplateID: template.ID, Type: "password", }) if err != nil { t.Fatal(err.Error()) } foundVaults, err := store.GetTemplateVaults(proj.ID, template.ID) if err != nil { t.Fatal(err.Error()) } if len(foundVaults) != 1 || foundVaults[0].ID != vault.ID { t.Fatalf("expected 1 vault, got %d", len(foundVaults)) } } func TestUpdateTemplateVaults(t *testing.T) { store := CreateTestStore() proj, err := store.CreateProject(db.Project{ Created: tz.Now(), Name: "TestProject", }) if err != nil { t.Fatal(err.Error()) } template, err := store.CreateTemplate(db.Template{ ProjectID: proj.ID, Name: "TestTemplate", Playbook: "test.yml", }) if err != nil { t.Fatal(err.Error()) } _, err = store.CreateTemplateVault(db.TemplateVault{ ProjectID: proj.ID, TemplateID: template.ID, Type: "password", }) if err != nil { t.Fatal(err.Error()) } vault2 := db.TemplateVault{ ProjectID: proj.ID, TemplateID: template.ID, Type: "script", } err = store.UpdateTemplateVaults(proj.ID, template.ID, []db.TemplateVault{vault2}) if err != nil { t.Fatal(err.Error()) } vaults, err := store.GetTemplateVaults(proj.ID, template.ID) if err != nil { t.Fatal(err.Error()) } if len(vaults) != 1 || vaults[0].Type != "script" { t.Fatalf("expected 1 vault with type 'script', got %d", len(vaults)) } } ================================================ FILE: db/bolt/user.go ================================================ package bolt import ( "errors" "fmt" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/tz" "golang.org/x/crypto/bcrypt" ) func (d *BoltDb) CreateUserWithoutPassword(user db.User) (newUser db.User, err error) { err = db.ValidateUser(user) if err != nil { return } _, err = d.GetUserByLoginOrEmail(user.Username, user.Email) if err == nil { err = fmt.Errorf("user already exists") return } if err != db.ErrNotFound { return } user.Password = "" user.Created = db.GetParsedTime(tz.Now()) usr, err := d.createObject(0, db.UserProps, user) if err != nil { return } newUser = usr.(db.User) return } func (d *BoltDb) ImportUser(user db.UserWithPwd) (newUser db.User, err error) { return db.User{}, errors.New("unsupported operation") } func (d *BoltDb) CreateUser(user db.UserWithPwd) (newUser db.User, err error) { err = db.ValidateUser(user.User) if err != nil { return } _, err = d.GetUserByLoginOrEmail(user.Username, user.Email) if err == nil { err = fmt.Errorf("user already exists") return } if err != db.ErrNotFound { return } pwdHash, err := bcrypt.GenerateFromPassword([]byte(user.Pwd), 11) if err != nil { return } user.Password = string(pwdHash) user.Created = db.GetParsedTime(tz.Now()) usr, err := d.createObject(0, db.UserProps, user) if err != nil { return } newUser = usr.(db.UserWithPwd).User return } func (d *BoltDb) DeleteUser(userID int) error { projects, err := d.GetProjects(userID) if err != nil { return err } // TODO: add transaction for _, p := range projects { _ = d.DeleteProjectUser(p.ID, userID) } return d.deleteObject(0, db.UserProps, intObjectID(userID), nil) } func (d *BoltDb) UpdateUser(user db.UserWithPwd) error { var password string if user.Pwd != "" { var pwdHash []byte pwdHash, err := bcrypt.GenerateFromPassword([]byte(user.Pwd), 11) if err != nil { return err } password = string(pwdHash) } else { oldUser, err := d.GetUser(user.ID) if err != nil { return err } password = oldUser.Password } user.Password = password return d.updateObject(0, db.UserProps, user) } func (d *BoltDb) SetUserPassword(userID int, password string) error { pwdHash, err := bcrypt.GenerateFromPassword([]byte(password), 11) if err != nil { return err } user, err := d.GetUser(userID) if err != nil { return err } user.Password = string(pwdHash) return d.updateObject(0, db.UserProps, user) } func (d *BoltDb) CreateProjectUser(projectUser db.ProjectUser) (db.ProjectUser, error) { newProjectUser, err := d.createObject(projectUser.ProjectID, db.ProjectUserProps, projectUser) if err != nil { return db.ProjectUser{}, err } return newProjectUser.(db.ProjectUser), nil } func (d *BoltDb) GetProjectUser(projectID, userID int) (user db.ProjectUser, err error) { err = d.getObject(projectID, db.ProjectUserProps, intObjectID(userID), &user) return } func (d *BoltDb) GetProjectUsers(projectID int, params db.RetrieveQueryParams) (users []db.UserWithProjectRole, err error) { var projectUsers []db.ProjectUser err = d.getObjects(projectID, db.ProjectUserProps, params, nil, &projectUsers) if err != nil { return } for _, projUser := range projectUsers { var usr db.User usr, err = d.GetUser(projUser.UserID) if err != nil { return } var usrWithRole = db.UserWithProjectRole{ User: usr, Role: projUser.Role, } users = append(users, usrWithRole) } return } func (d *BoltDb) UpdateProjectUser(projectUser db.ProjectUser) error { return d.updateObject(projectUser.ProjectID, db.ProjectUserProps, projectUser) } func (d *BoltDb) DeleteProjectUser(projectID, userID int) error { return d.deleteObject(projectID, db.ProjectUserProps, intObjectID(userID), nil) } func (d *BoltDb) getTotp(userID int) (res *db.UserTotp, err error) { current := make([]db.UserTotp, 0) err = d.getObjects(userID, db.UserTotpProps, db.RetrieveQueryParams{}, nil, ¤t) if err != nil { return } if len(current) > 0 { res = ¤t[0] } return } // GetUser retrieves a user from the database by ID func (d *BoltDb) GetUser(userID int) (user db.User, err error) { err = d.getObject(0, db.UserProps, intObjectID(userID), &user) if err != nil { return } user.Totp, err = d.getTotp(userID) return } func (d *BoltDb) GetProUserCount() (count int, err error) { var users []db.User err = d.getObjects(0, db.UserProps, db.RetrieveQueryParams{}, func(i any) bool { user := i.(db.User) return user.Pro }, &users) if err != nil { return } count = len(users) return } func (d *BoltDb) GetUserCount() (count int, err error) { var users []db.User err = d.getObjects(0, db.UserProps, db.RetrieveQueryParams{}, nil, &users) if err != nil { return } count = len(users) return } func (d *BoltDb) GetUsers(params db.RetrieveQueryParams) (users []db.User, err error) { err = d.getObjects(0, db.UserProps, params, nil, &users) return } func (d *BoltDb) GetUserByLoginOrEmail(login string, email string) (existingUser db.User, err error) { var users []db.User err = d.getObjects(0, db.UserProps, db.RetrieveQueryParams{}, nil, &users) if err != nil { return } found := false for _, user := range users { if user.Username == login || user.Email == email { existingUser = user found = true break } } if !found { err = db.ErrNotFound return } existingUser.Totp, err = d.getTotp(existingUser.ID) return } func (d *BoltDb) GetAllAdmins() (users []db.User, err error) { err = d.getObjects(0, db.UserProps, db.RetrieveQueryParams{}, func(i any) bool { user := i.(db.User) return user.Admin }, &users) return } func (d *BoltDb) AddTotpVerification(userID int, url string, recoveryHash string) (totp db.UserTotp, err error) { current := make([]db.UserTotp, 0) err = d.getObjects(userID, db.UserTotpProps, db.RetrieveQueryParams{}, nil, ¤t) if len(current) > 0 { err = fmt.Errorf("already exists") return } totp.UserID = userID totp.URL = url totp.RecoveryHash = recoveryHash totp.Created = db.GetParsedTime(tz.Now()) newTotp, err := d.createObject(userID, db.UserTotpProps, totp) if err != nil { return } totp = newTotp.(db.UserTotp) return } func (d *BoltDb) DeleteTotpVerification(userID int, totpID int) error { return d.deleteObject(userID, db.UserTotpProps, intObjectID(totpID), nil) } func (d *BoltDb) AddEmailOtpVerification(userID int, code string) (res db.UserEmailOtp, err error) { err = db.ErrNotFound return } func (d *BoltDb) DeleteEmailOtpVerification(userID int, totpID int) (err error) { err = db.ErrNotFound return } ================================================ FILE: db/bolt/user_test.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/tz" "github.com/stretchr/testify/require" "testing" ) func TestBoltDb_UpdateProjectUser(t *testing.T) { store := CreateTestStore() usr, err := store.CreateUser(db.UserWithPwd{ Pwd: "123456", User: db.User{ Email: "denguk@example.com", Name: "Denis Gukov", Username: "fiftin", }, }) require.NoError(t, err) proj1, err := store.CreateProject(db.Project{ Created: tz.Now(), Name: "Test1", }) require.NoError(t, err) projUser, err := store.CreateProjectUser(db.ProjectUser{ ProjectID: proj1.ID, UserID: usr.ID, Role: db.ProjectOwner, }) require.NoError(t, err) projUser.Role = db.ProjectOwner err = store.UpdateProjectUser(projUser) require.NoError(t, err) } func TestGetUsers(t *testing.T) { store := CreateTestStore() _, err := store.CreateUser(db.UserWithPwd{ Pwd: "123456", User: db.User{ Email: "denguk@example.com", Name: "Denis Gukov", Username: "fiftin", }, }) require.NoError(t, err) found, err := store.GetUsers(db.RetrieveQueryParams{}) require.NoError(t, err) require.Equal(t, 1, len(found)) } func TestGetUser(t *testing.T) { store := CreateTestStore() usr, err := store.CreateUser(db.UserWithPwd{ Pwd: "123456", User: db.User{ Email: "denguk@example.com", Name: "Denis Gukov", Username: "fiftin", }, }) require.NoError(t, err) found, err := store.GetUser(usr.ID) require.NoError(t, err) require.Equal(t, "fiftin", found.Username) err = store.DeleteUser(usr.ID) require.NoError(t, err) } func TestGetUserCount(t *testing.T) { store := CreateTestStore() // Create first user _, err := store.CreateUser(db.UserWithPwd{ Pwd: "123456", User: db.User{ Email: "user1@example.com", Name: "User One", Username: "userone", }, }) require.NoError(t, err) // Create second user _, err = store.CreateUser(db.UserWithPwd{ Pwd: "123456", User: db.User{ Email: "user2@example.com", Name: "User Two", Username: "usertwo", }, }) require.NoError(t, err) // Get user count count, err := store.GetUserCount() require.NoError(t, err) // Verify the count require.Equal(t, 2, count) } func TestBoltDb_DeleteUser(t *testing.T) { store := CreateTestStore() // Create a user usr, err := store.CreateUser(db.UserWithPwd{ Pwd: "123456", User: db.User{ Email: "deleteuser@example.com", Name: "Delete User", Username: "deleteuser", }, }) require.NoError(t, err) // Create a project proj, err := store.CreateProject(db.Project{ Created: tz.Now(), Name: "DeleteUserProject", }) require.NoError(t, err) // Associate the user with the project _, err = store.CreateProjectUser(db.ProjectUser{ ProjectID: proj.ID, UserID: usr.ID, Role: db.ProjectOwner, }) require.NoError(t, err) // Delete the user err = store.DeleteUser(usr.ID) require.NoError(t, err) // Verify the user is deleted _, err = store.GetUser(usr.ID) require.Error(t, err) // Verify the project-user association is deleted _, err = store.GetProjectUser(proj.ID, usr.ID) require.Error(t, err) } ================================================ FILE: db/bolt/view.go ================================================ package bolt import "github.com/semaphoreui/semaphore/db" func (d *BoltDb) GetView(projectID int, viewID int) (view db.View, err error) { err = d.getObject(projectID, db.ViewProps, intObjectID(viewID), &view) return } func (d *BoltDb) GetViews(projectID int) (views []db.View, err error) { err = d.getObjects(projectID, db.ViewProps, db.RetrieveQueryParams{}, nil, &views) return } func (d *BoltDb) UpdateView(view db.View) error { return d.updateObject(view.ProjectID, db.ViewProps, view) } func (d *BoltDb) CreateView(view db.View) (db.View, error) { newView, err := d.createObject(view.ProjectID, db.ViewProps, view) return newView.(db.View), err } func (d *BoltDb) DeleteView(projectID int, viewID int) error { return d.deleteObject(projectID, db.ViewProps, intObjectID(viewID), nil) } func (d *BoltDb) SetViewPositions(projectID int, positions map[int]int) error { for id, position := range positions { view, err := d.GetView(projectID, id) if err != nil { return err } view.Position = position err = d.UpdateView(view) if err != nil { return err } } return nil } ================================================ FILE: db/bolt/view_test.go ================================================ package bolt import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/tz" "sort" "testing" ) func TestGetViews(t *testing.T) { store := CreateTestStore() proj1, err := store.CreateProject(db.Project{ Created: tz.Now(), Name: "Test1", }) if err != nil { t.Fatal(err.Error()) } _, err = store.CreateView(db.View{ ProjectID: proj1.ID, Title: "Test", Position: 1, }) if err != nil { t.Fatal(err.Error()) } found, err := store.GetViews(proj1.ID) if err != nil { t.Fatal(err.Error()) } if len(found) != 1 { t.Fatal() } view, err := store.GetView(proj1.ID, found[0].ID) if err != nil { t.Fatal(err.Error()) } if view.ID != found[0].ID || view.Title != found[0].Title || view.Position != found[0].Position { t.Fatal() } } func TestSetViewPositions(t *testing.T) { store := CreateTestStore() proj1, err := store.CreateProject(db.Project{ Created: tz.Now(), Name: "Test1", }) if err != nil { t.Fatal(err.Error()) } v1, err := store.CreateView(db.View{ ProjectID: proj1.ID, Title: "Test", Position: 4, }) if err != nil { t.Fatal(err.Error()) } v2, err := store.CreateView(db.View{ ProjectID: proj1.ID, Title: "Test", Position: 2, }) if err != nil { t.Fatal(err.Error()) } found, err := store.GetViews(proj1.ID) if err != nil { t.Fatal(err.Error()) } if len(found) != 2 { t.Fatal() } sort.Slice(found, func(i, j int) bool { return found[i].Position < found[j].Position }) if found[0].Position != v2.Position || found[1].Position != v1.Position { t.Fatal() } err = store.SetViewPositions(proj1.ID, map[int]int{ v1.ID: 3, v2.ID: 6, }) if err != nil { t.Fatal(err.Error()) } found, err = store.GetViews(proj1.ID) if err != nil { t.Fatal(err.Error()) } if len(found) != 2 { t.Fatal() } sort.Slice(found, func(i, j int) bool { return found[i].Position < found[j].Position }) if found[0].Position != 3 || found[1].Position != 6 { t.Fatal() } } func TestGetView(t *testing.T) { store := CreateTestStore() proj1, err := store.CreateProject(db.Project{ Created: tz.Now(), Name: "Test1", }) if err != nil { t.Fatal(err.Error()) } view, err := store.CreateView(db.View{ ProjectID: proj1.ID, Title: "Test", Position: 1, }) if err != nil { t.Fatal(err.Error()) } found, err := store.GetView(proj1.ID, view.ID) if err != nil { t.Fatal(err.Error()) } if found.ID != view.ID || found.Title != view.Title || found.Position != view.Position { t.Fatal() } } func TestUpdateView(t *testing.T) { store := CreateTestStore() proj1, err := store.CreateProject(db.Project{ Created: tz.Now(), Name: "Test1", }) if err != nil { t.Fatal(err.Error()) } view, err := store.CreateView(db.View{ ProjectID: proj1.ID, Title: "Test", Position: 1, }) if err != nil { t.Fatal(err.Error()) } view.Title = "Updated Test" err = store.UpdateView(view) if err != nil { t.Fatal(err.Error()) } updatedView, err := store.GetView(proj1.ID, view.ID) if err != nil { t.Fatal(err.Error()) } if updatedView.Title != "Updated Test" { t.Fatal() } } func TestCreateView(t *testing.T) { store := CreateTestStore() proj1, err := store.CreateProject(db.Project{ Created: tz.Now(), Name: "Test1", }) if err != nil { t.Fatal(err.Error()) } view, err := store.CreateView(db.View{ ProjectID: proj1.ID, Title: "Test", Position: 1, }) if err != nil { t.Fatal(err.Error()) } found, err := store.GetView(proj1.ID, view.ID) if err != nil { t.Fatal(err.Error()) } if found.ID != view.ID || found.Title != view.Title || found.Position != view.Position { t.Fatal() } } func TestDeleteView(t *testing.T) { store := CreateTestStore() proj1, err := store.CreateProject(db.Project{ Created: tz.Now(), Name: "Test1", }) if err != nil { t.Fatal(err.Error()) } view, err := store.CreateView(db.View{ ProjectID: proj1.ID, Title: "Test", Position: 1, }) if err != nil { t.Fatal(err.Error()) } err = store.DeleteView(proj1.ID, view.ID) if err != nil { t.Fatal(err.Error()) } _, err = store.GetView(proj1.ID, view.ID) if err == nil { t.Fatal("Expected error, got nil") } } ================================================ FILE: db/config.go ================================================ package db import ( "github.com/semaphoreui/semaphore/util" "strings" ) func ConvertFlatToNested(flatMap map[string]string) map[string]any { nestedMap := make(map[string]any) for key, value := range flatMap { parts := strings.Split(key, ".") currentMap := nestedMap for i, part := range parts { if i == len(parts)-1 { currentMap[part] = value } else { if _, exists := currentMap[part]; !exists { currentMap[part] = make(map[string]any) } currentMap = currentMap[part].(map[string]any) } } } return nestedMap } func FillConfigFromDB(store Store) (err error) { opts, err := store.GetOptions(RetrieveQueryParams{}) if err != nil { return } options := ConvertFlatToNested(opts) if options["apps"] == nil { options["apps"] = make(map[string]any) } err = util.AssignMapToStruct(options, util.Config) return } ================================================ FILE: db/config_test.go ================================================ package db import ( "testing" "github.com/semaphoreui/semaphore/util" ) func TestConfig_assignMapToStruct(t *testing.T) { type Address struct { Street string `json:"street"` City string `json:"city"` } type Detail struct { Value string `json:"value"` Description string `json:"description"` } type User struct { Name string `json:"name"` Age int `json:"age"` Email string `json:"email"` Address Address `json:"address"` Details map[string]Detail `json:"details"` Tags []string `json:"tags"` } johnData := map[string]any{ "name": "John Doe", "age": 30, "email": "john.doe@example.com", "address": map[string]any{ "street": "123 Main St", "city": "Anytown", }, "details": map[string]any{ "occupation": map[string]any{ "value": "engineer", "description": "Works with computers", }, "hobby": map[string]any{ "value": "hiking", "description": "Enjoys the outdoors", }, "interests": map[string]any{ "description": "Ho ho ho", }, }, "tags": "[\"test\"]", } var john User john.Details = make(map[string]Detail) john.Details["interests"] = Detail{ Value: "politics", Description: "Follows current events", } err := util.AssignMapToStruct(johnData, &john) if err != nil { t.Fatal(err) } //if john.Name != "John Doe" { // t.Errorf("Expected name to be John Doe but got %s", john.Name) //} if john.Details["interests"].Description != "Ho ho ho" { t.Errorf("Expected interests description to be 'Ho ho ho' but got %s", john.Details["interests"].Description) } if john.Details["interests"].Value != "politics" { t.Errorf("Expected interests to be politics but got '%s'", john.Details["interests"].Value) } if len(john.Tags) < 1 { t.Fatal("Expected user tags") } //if john.Details["occupation"].Value != "engineer" { // t.Errorf("Expected occupation to be engineer but got %s", john.Details["occupation"].Value) //} } ================================================ FILE: db/factory/store.go ================================================ package factory import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/db/bolt" "github.com/semaphoreui/semaphore/db/sql" "github.com/semaphoreui/semaphore/util" ) func CreateStore() db.Store { config, err := util.Config.GetDBConfig() if err != nil { panic("Can not read configuration") } switch config.Dialect { case util.DbDriverMySQL: return sql.CreateDb(config.Dialect) case util.DbDriverBolt: return bolt.CreateBoltDB() case util.DbDriverPostgres: return sql.CreateDb(config.Dialect) case util.DbDriverSQLite: return sql.CreateDb(config.Dialect) default: panic("Unsupported database dialect: " + config.Dialect) } // This line should never be reached due to panic above, but satisfies linter return nil } ================================================ FILE: db/migration/migration.go ================================================ package migration import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/services/export" ) type Migrator struct { OldStore db.Store NewStore db.Store ErrLogSize int SkipTaskOutput bool MergeExistingUsers bool } func (m *Migrator) Migrate() error { if err := m.migrateProject(); err != nil { return err } return nil } func (m *Migrator) migrateProject() error { mapper := export.NewKeyMapper() p := export.InitProjectExporters(mapper, m.SkipTaskOutput, m.MergeExistingUsers) err := p.Load(m.OldStore) if err != nil { return err } err = p.Restore(m.NewStore, m.ErrLogSize) if err != nil { return err } return err } ================================================ FILE: db/sql/SqlDb.go ================================================ package sql import ( "database/sql" "embed" "errors" "fmt" "reflect" "regexp" "strconv" "strings" "time" "github.com/Masterminds/squirrel" "github.com/go-gorp/gorp/v3" _ "github.com/go-sql-driver/mysql" // imports mysql driver _ "github.com/lib/pq" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" _ "modernc.org/sqlite" // Import the driver ) type SqlDbConnection struct { sql *gorp.DbMap dialect string } type SqlDb struct { connection SqlDbConnection } func (d *SqlDb) Sql() *gorp.DbMap { return d.connection.sql } func (d *SqlDbConnection) Connect() { sqlDb, err := connect() if err != nil { panic(err) } err = sqlDb.Ping() if err != nil { if err = sqlDb.Close(); err != nil { log.Warn("Cannot close database connection: " + err.Error()) } if err = createDb(); err != nil { panic(err) } sqlDb, err = connect() if err != nil { panic(err) } if err = sqlDb.Ping(); err != nil { panic(err) } } cfg, err := util.Config.GetDBConfig() if err != nil { panic(err) } var dialect gorp.Dialect switch cfg.Dialect { case util.DbDriverMySQL: dialect = gorp.MySQLDialect{Engine: "InnoDB", Encoding: "UTF8"} case util.DbDriverPostgres: dialect = gorp.PostgresDialect{} case util.DbDriverSQLite: dialect = gorp.SqliteDialect{} } d.sql = &gorp.DbMap{Db: sqlDb, Dialect: dialect} if d.GetDialect() == util.DbDriverSQLite { sqlDb.SetMaxOpenConns(1) } d.sql.AddTableWithName(db.APIToken{}, "user__token").SetKeys(false, "id") d.sql.AddTableWithName(db.AccessKey{}, "access_key").SetKeys(true, "id") d.sql.AddTableWithName(db.Environment{}, "project__environment").SetKeys(true, "id") d.sql.AddTableWithName(db.Inventory{}, "project__inventory").SetKeys(true, "id") d.sql.AddTableWithName(db.Project{}, "project").SetKeys(true, "id") d.sql.AddTableWithName(db.Repository{}, "project__repository").SetKeys(true, "id") d.sql.AddTableWithName(db.Task{}, "task").SetKeys(true, "id") d.sql.AddTableWithName(db.TaskOutput{}, "task__output").SetUniqueTogether("task_id", "time") d.sql.AddTableWithName(db.Template{}, "project__template").SetKeys(true, "id") d.sql.AddTableWithName(db.User{}, "user").SetKeys(true, "id") d.sql.AddTableWithName(db.Session{}, "session").SetKeys(true, "id") d.sql.AddTableWithName(db.TaskParams{}, "project__task_params").SetKeys(true, "id") if d.GetDialect() == util.DbDriverSQLite { _, err = d.Exec("PRAGMA foreign_keys = ON") if err != nil { panic(err) } } } func (d *SqlDbConnection) Close() { err := d.sql.Db.Close() if err != nil { panic(err) } } func CreateTestStore() *SqlDb { util.Config = &util.ConfigType{ SQLite: &util.DbConfig{ Hostname: ":memory:", }, Dialect: "sqlite", Log: &util.ConfigLog{ Events: &util.EventLogType{}, Tasks: &util.TaskLogType{}, }, } store := CreateDb(util.DbDriverSQLite) store.Connect("") db.Migrate(store, nil) return store } func (d *SqlDbConnection) prepareQueryWithDialect(query string, dialect gorp.Dialect) string { switch dialect.(type) { case gorp.PostgresDialect: var queryBuilder strings.Builder argNum := 1 for _, r := range query { switch r { case '?': queryBuilder.WriteString("$" + strconv.Itoa(argNum)) argNum++ case '`': queryBuilder.WriteRune('"') default: queryBuilder.WriteRune(r) } } query = queryBuilder.String() } return query } func (d *SqlDbConnection) PrepareDateQueryParam(paramName string) string { if d.dialect == util.DbDriverSQLite { return "substr(" + paramName + ", 1, 25)" } return paramName } func (d *SqlDbConnection) PrepareQuery(query string) string { return d.prepareQueryWithDialect(query, d.sql.Dialect) } func formatArgs(args []any) (formattedArgs []any) { for _, arg := range args { switch typedArg := arg.(type) { case time.Time: formattedArgs = append(formattedArgs, typedArg.Format("2006-01-02 15:04:05.000000")) default: formattedArgs = append(formattedArgs, arg) } } return } func (d *SqlDbConnection) Insert(primaryKeyColumnName string, query string, args ...any) (int, error) { var insertId int64 formattedArgs := formatArgs(args) switch d.sql.Dialect.(type) { case gorp.PostgresDialect: var err error if primaryKeyColumnName != "" { query += " returning " + primaryKeyColumnName err = d.sql.QueryRow(d.PrepareQuery(query), formattedArgs...).Scan(&insertId) } else { _, err = d.sql.Exec(d.PrepareQuery(query), formattedArgs...) } if err != nil { return 0, err } default: res, err := d.sql.Exec(d.PrepareQuery(query), formattedArgs...) if err != nil { return 0, err } insertId, err = res.LastInsertId() if err != nil { return 0, err } } return int(insertId), nil } func (d *SqlDbConnection) Exec(query string, args ...any) (sql.Result, error) { q := d.PrepareQuery(query) return d.sql.Exec(q, args...) } func (d *SqlDbConnection) ExecTx(tx *gorp.Transaction, query string, args ...any) (sql.Result, error) { q := d.PrepareQuery(query) return tx.Exec(q, args...) } func (d *SqlDbConnection) SelectOne(holder any, query string, args ...any) error { err := d.sql.SelectOne(holder, d.PrepareQuery(query), args...) if errors.Is(err, sql.ErrNoRows) { err = db.ErrNotFound } return err } func (d *SqlDbConnection) SelectAll(i any, query string, args ...any) ([]any, error) { q := d.PrepareQuery(query) return d.sql.Select(i, q, args...) } func (d *SqlDbConnection) DeleteObject(projectID int, props db.ObjectProps, objectID any) error { primaryColumnName := "id" if props.PrimaryColumnName != "" { primaryColumnName = props.PrimaryColumnName } if props.IsGlobal { return validateMutationResult( d.Exec( "delete from "+props.TableName+" where "+primaryColumnName+"=?", objectID)) } else { return validateMutationResult( d.Exec( "delete from "+props.TableName+" where project_id=? and "+primaryColumnName+"=?", projectID, objectID)) } } func (d *SqlDbConnection) GetObject(projectID int, props db.ObjectProps, objectID int, object any) (err error) { q := squirrel.Select("*"). From(props.TableName). Where("id=?", objectID) if props.IsGlobal { q = q.Where("project_id is null") } else { q = q.Where("project_id=?", projectID) } query, args, err := q.ToSql() if err != nil { return } err = d.SelectOne(object, query, args...) return } func (d *SqlDbConnection) GetObjectsByReferrer( referrerID int, referringObjectProps db.ObjectProps, props db.ObjectProps, params db.RetrieveQueryParams, objects any, ) (err error) { referringColumn := referringObjectProps.ReferringColumnSuffix columns := []string{"*"} if len(props.SelectColumns) > 0 { columns = props.SelectColumns } q := squirrel.Select(columns...).From(props.TableName + " pe") if props.IsGlobal { q = q.Where("pe." + referringColumn + " is null") } else { q = q.Where("pe."+referringColumn+"=?", referrerID) } q, err = getQueryForParams(q, "pe.", props, params) if err != nil { return } query, args, err := q.ToSql() if err != nil { return } _, err = d.SelectAll(objects, query, args...) return } var initialSQL = ` create table ` + "`migrations`" + ` ( ` + "`version`" + ` varchar(255) not null primary key, ` + "`upgraded_date`" + ` datetime null, ` + "`notes`" + ` text null ); ` //go:embed migrations/*.sql var dbAssets embed.FS func CreateDb(dialect string) *SqlDb { return &SqlDb{ connection: SqlDbConnection{ dialect: dialect, }, } } func (d *SqlDbConnection) GetDialect() string { return d.dialect } func (d *SqlDb) GetConnection() *SqlDbConnection { return &d.connection } func (d *SqlDb) GetDialect() string { return d.connection.GetDialect() } func (d *SqlDb) Close(token string) { d.connection.Close() } func getQueryForParams(q squirrel.SelectBuilder, prefix string, props db.ObjectProps, params db.RetrieveQueryParams) (res squirrel.SelectBuilder, err error) { pp, err := params.Validate(props) if err != nil { return } orderDirection := "ASC" if pp.SortInverted { orderDirection = "DESC" } var orderColumn string if pp.SortBy == "" { orderColumn = props.DefaultSortingColumn if props.SortInverted { orderDirection = "DESC" } } else { orderColumn = pp.SortBy } if orderColumn != "" { q = q.OrderBy(prefix + orderColumn + " " + orderDirection) } if pp.Count > 0 { q = q.Limit(uint64(pp.Count)) } if pp.Offset > 0 { q = q.Offset(uint64(pp.Offset)) } res = q return } func handleRollbackError(err error) { if err != nil { log.Warn(err.Error()) } } var identifierQuoteRE = regexp.MustCompile("`") // validateMutationResult checks the success of the update query func validateMutationResult(res sql.Result, err error) error { if err != nil { if strings.Contains(err.Error(), "foreign key") { err = db.ErrInvalidOperation } return err } return nil } func (d *SqlDb) PrepareQuery(query string) string { return d.connection.PrepareQuery(query) } func (d *SqlDb) insert(primaryKeyColumnName string, query string, args ...any) (int, error) { return d.connection.Insert(primaryKeyColumnName, query, args...) } func (d *SqlDb) exec(query string, args ...any) (sql.Result, error) { return d.connection.Exec(query, args...) } func (d *SqlDb) execTx(tx *gorp.Transaction, query string, args ...any) (sql.Result, error) { q := d.PrepareQuery(query) return tx.Exec(q, args...) } func (d *SqlDb) selectOne(holder any, query string, args ...any) error { err := d.Sql().SelectOne(holder, d.PrepareQuery(query), args...) if errors.Is(err, sql.ErrNoRows) { err = db.ErrNotFound } return err } func (d *SqlDb) selectAll(i any, query string, args ...any) ([]any, error) { return d.connection.SelectAll(i, query, args...) } func connect() (*sql.DB, error) { cfg, err := util.Config.GetDBConfig() if err != nil { return nil, err } connectionString, err := cfg.GetConnectionString(true) if err != nil { return nil, err } dialect := cfg.Dialect return sql.Open(dialect, connectionString) } func createDb() error { cfg, err := util.Config.GetDBConfig() if err != nil { return err } if !cfg.HasSupportMultipleDatabases() { return nil } connectionString, err := cfg.GetConnectionString(false) if err != nil { return err } conn, err := sql.Open(cfg.Dialect, connectionString) if err != nil { return err } defer conn.Close() //nolint:errcheck _, err = conn.Exec("create database " + cfg.GetDbName()) if err != nil { log.Warn(err.Error()) } return nil } func (d *SqlDb) getObject(projectID int, props db.ObjectProps, objectID int, object any) (err error) { return d.connection.GetObject(projectID, props, objectID, object) } func (d *SqlDb) makeObjectsQuery(projectID int, props db.ObjectProps, params db.RetrieveQueryParams) (q squirrel.SelectBuilder, err error) { columns := []string{"*"} if len(props.SelectColumns) > 0 { columns = props.SortableColumns } q = squirrel.Select(columns...).From("`" + props.TableName + "` pe") if !props.IsGlobal { q = q.Where("pe.project_id=?", projectID) } if len(props.Ownerships) > 0 { for _, ownership := range props.Ownerships { if params.Ownership.WithoutOwnerOnly { q = q.Where(squirrel.Eq{ "pe." + string(ownership.ReferringColumnSuffix): nil, }) } else { ownerID := params.Ownership.GetOwnerID(*ownership) if ownerID != nil { q = q.Where(squirrel.Eq{ "pe." + string(ownership.ReferringColumnSuffix): *ownerID, }) } } } } q, err = getQueryForParams(q, "pe.", props, params) //if params.Count > 0 { // q = q.Limit(uint64(params.Count)) //} // //if params.Offset > 0 { // q = q.Offset(uint64(params.Offset)) //} return } func (d *SqlDb) getObjects( projectID int, props db.ObjectProps, params db.RetrieveQueryParams, prepare func(squirrel.SelectBuilder) squirrel.SelectBuilder, objects any, ) (err error) { q, err := d.makeObjectsQuery(projectID, props, params) if err != nil { return } if prepare != nil { q = prepare(q) } query, args, err := q.ToSql() if err != nil { return } _, err = d.selectAll(objects, query, args...) return } func (d *SqlDb) deleteObject(projectID int, props db.ObjectProps, objectID any) error { return d.connection.DeleteObject(projectID, props, objectID) } func (d *SqlDb) PermanentConnection() bool { return true } func (d *SqlDb) Connect(_ string) { d.connection.Connect() } func (d *SqlDb) getObjectRefs(projectID int, objectProps db.ObjectProps, objectID int) (refs db.ObjectReferrers, err error) { refs.Templates, err = d.getObjectRefsFrom(projectID, objectProps, objectID, db.TemplateProps) if err != nil { return } refs.Repositories, err = d.getObjectRefsFrom(projectID, objectProps, objectID, db.RepositoryProps) if err != nil { return } refs.Inventories, err = d.getObjectRefsFrom(projectID, objectProps, objectID, db.InventoryProps) if err != nil { return } refs.Schedules, err = d.getObjectRefsFrom(projectID, objectProps, objectID, db.ScheduleProps) if err != nil { return } refs.Integrations, err = d.getObjectRefsFrom(projectID, objectProps, objectID, db.IntegrationAliasProps) if err != nil { return } refs.AccessKeys, err = d.getObjectRefsFrom(projectID, objectProps, objectID, db.AccessKeyProps) if err != nil { return } return } func (d *SqlDb) getObjectRefsFrom( projectID int, objectProps db.ObjectProps, objectID int, referringObjectProps db.ObjectProps, ) (referringObjs []db.ObjectReferrer, err error) { referringObjs = make([]db.ObjectReferrer, 0) fields, err := objectProps.GetReferringFieldsFrom(referringObjectProps.Type) cond := "" vals := []any{projectID} for _, f := range fields { if cond != "" { cond += " or " } cond += f + " = ?" vals = append(vals, objectID) } if cond == "" { return } cond = "(" + cond + ")" // do not check access keys which belong to the owner. if referringObjectProps.Type == db.AccessKeyProps.Type { cond += " and owner = ''" } var referringObjects reflect.Value if referringObjectProps.Type == db.ScheduleProps.Type { var referringSchedules []db.Schedule _, err = d.selectAll(&referringSchedules, "select template_id id from project__schedule where project_id = ? and ("+cond+")", vals...) if err != nil { return } if len(referringSchedules) == 0 { return } var ids []string for _, schedule := range referringSchedules { ids = append(ids, strconv.Itoa(schedule.ID)) } referringObjects = reflect.New(reflect.SliceOf(db.TemplateProps.Type)) _, err = d.selectAll(referringObjects.Interface(), "select id, name from project__template where id in ("+strings.Join(ids, ",")+")") } else { referringObjects = reflect.New(reflect.SliceOf(referringObjectProps.Type)) _, err = d.selectAll( referringObjects.Interface(), "select id, name from "+referringObjectProps.TableName+" where project_id = ? and "+cond, vals...) } if err != nil { return } for i := 0; i < referringObjects.Elem().Len(); i++ { id := int(referringObjects.Elem().Index(i).FieldByName("ID").Int()) name := referringObjects.Elem().Index(i).FieldByName("Name").String() referringObjs = append(referringObjs, db.ObjectReferrer{ID: id, Name: name}) } return } func (d *SqlDb) IsInitialized() (bool, error) { _, err := d.Sql().SelectInt(d.PrepareQuery("select count(1) from migrations")) return err == nil, nil } func (d *SqlDb) getObjectByReferrer(referrerID int, referringObjectProps db.ObjectProps, props db.ObjectProps, objectID int, object any) (err error) { query, args, err := squirrel.Select("*"). From(props.TableName). Where("id=?", objectID). Where(referringObjectProps.ReferringColumnSuffix+"=?", referrerID). ToSql() if err != nil { return } err = d.selectOne(object, query, args...) return } func (d *SqlDb) deleteByReferrer(referrerID int, referringObjectProps db.ObjectProps, props db.ObjectProps, objectID int) error { referringColumn := referringObjectProps.ReferringColumnSuffix return validateMutationResult( d.exec( "delete from "+props.TableName+" where "+referringColumn+"=? and id=?", referrerID, objectID)) } func (d *SqlDb) deleteObjectByReferencedID(referencedID int, referencedProps db.ObjectProps, props db.ObjectProps, objectID int) error { field := referencedProps.ReferringColumnSuffix return validateMutationResult( d.exec("delete from "+props.TableName+" t where t."+field+"=? and t.id=?", referencedID, objectID)) } /** GENERIC IMPLEMENTATION **/ func InsertTemplateFromType(typeInstance any) (string, []any) { val := reflect.Indirect(reflect.ValueOf(typeInstance)) typeFieldSize := val.Type().NumField() fields := "" values := "" args := make([]any, 0) if typeFieldSize > 1 { fields += "(" values += "(" } for i := 0; i < typeFieldSize; i++ { if val.Type().Field(i).Name == "ID" { continue } fields += val.Type().Field(i).Tag.Get("db") values += "?" args = append(args, val.Field(i)) if i != (typeFieldSize - 1) { fields += ", " values += ", " } } if typeFieldSize > 1 { fields += ")" values += ")" } return fields + " values " + values, args } func (d *SqlDb) GetObject(props db.ObjectProps, ID int) (object any, err error) { query, args, err := squirrel.Select("t.*"). From(props.TableName + " as t"). Where(squirrel.Eq{"t.id": ID}). OrderBy("t.id"). ToSql() if err != nil { return } err = d.selectOne(&object, query, args...) return } func (d *SqlDb) CreateObject(props db.ObjectProps, object any) (newObject any, err error) { // err = newObject.Validate() if err != nil { return } template, args := InsertTemplateFromType(object) insertID, err := d.insert( "id", "insert into "+props.TableName+" "+template, args...) if err != nil { return } newObject = object v := reflect.ValueOf(newObject) field := v.FieldByName("ID") field.SetInt(int64(insertID)) return } func (d *SqlDb) GetObjectsByForeignKeyQuery(props db.ObjectProps, foreignID int, foreignProps db.ObjectProps, params db.RetrieveQueryParams, objects any) (err error) { q := squirrel.Select("*"). From(props.TableName+" as t"). Where(foreignProps.ReferringColumnSuffix+"=?", foreignID) q, err = getQueryForParams(q, "t.", props, params) if err != nil { return } query, args, err := q. OrderBy("t.id"). ToSql() if err != nil { return } err = d.selectOne(&objects, query, args...) return } func (d *SqlDb) GetAllObjectsByForeignKey(props db.ObjectProps, foreignID int, foreignProps db.ObjectProps) (objects any, err error) { query, args, err := squirrel.Select("*"). From(props.TableName+" as t"). Where(foreignProps.ReferringColumnSuffix+"=?", foreignID). OrderBy("t.id"). ToSql() if err != nil { return } results, errQuery := d.selectAll(&objects, query, args...) return results, errQuery } func (d *SqlDb) GetAllObjects(props db.ObjectProps) (objects any, err error) { query, args, err := squirrel.Select("*"). From(props.TableName + " as t"). OrderBy("t.id"). ToSql() if err != nil { return } var results []any results, err = d.selectAll(&objects, query, args...) return results, err } // Retrieve the Matchers & Values referencing `id' from WebhookExtractor // -- // Examples: // referrerCollection := db.ObjectReferrers{} // // d.GetReferencesForForeignKey(db.ProjectProps, id, map[string]db.ObjectProps{ // 'Templates': db.TemplateProps, // 'Inventories': db.InventoryProps, // 'Repositories': db.RepositoryProps // }, &referrerCollection) // // // // // referrerCollection := db.WebhookExtractorReferrers{} // // d.GetReferencesForForeignKey(db.WebhookProps, id, map[string]db.ObjectProps{ // "Matchers": db.WebhookMatcherProps, // "Values": db.WebhookExtractValueProps // }, &referrerCollection) func (d *SqlDb) GetReferencesForForeignKey(objectProps db.ObjectProps, objectID int, referrerMapping map[string]db.ObjectProps, referrerCollection *any) (err error) { for key, value := range referrerMapping { // v := reflect.ValueOf(referrerCollection) referrers, errRef := d.GetObjectReferences(objectProps, value, objectID) if errRef != nil { return errRef } reflect.ValueOf(referrerCollection).FieldByName(key).Set(reflect.ValueOf(referrers)) } return } // Find Object Referrers for objectID based on referring column taken from referringObjectProps // Example: // GetObjectReferences(db.WebhookMatchers, db.WebhookExtractorProps, integrationID) func (d *SqlDb) GetObjectReferences(objectProps db.ObjectProps, referringObjectProps db.ObjectProps, objectID int) (referringObjs []db.ObjectReferrer, err error) { referringObjs = make([]db.ObjectReferrer, 0) fields, err := objectProps.GetReferringFieldsFrom(objectProps.Type) cond := "" vals := []any{} for _, f := range fields { if cond != "" { cond += " or " } cond += f + " = ?" vals = append(vals, objectID) } if cond == "" { return } referringObjects := reflect.New(reflect.SliceOf(referringObjectProps.Type)) _, err = d.selectAll( referringObjects.Interface(), "select id, name from "+referringObjectProps.TableName+" where "+objectProps.ReferringColumnSuffix+" = ? and "+cond, vals...) if err != nil { return } for i := 0; i < referringObjects.Elem().Len(); i++ { id := int(referringObjects.Elem().Index(i).FieldByName("ID").Int()) name := referringObjects.Elem().Index(i).FieldByName("Name").String() referringObjs = append(referringObjs, db.ObjectReferrer{ID: id, Name: name}) } return } func (d *SqlDb) GetTaskStats(projectID int, templateID *int, unit db.TaskStatUnit, filter db.TaskFilter) (stats []db.TaskStat, err error) { stats = make([]db.TaskStat, 0) if unit != db.TaskStatUnitDay { err = fmt.Errorf("only day unit is supported") return } var res []struct { Date string `db:"date"` Status task_logger.TaskStatus `db:"status"` Count int `db:"count"` } q := squirrel.Select("DATE("+d.connection.PrepareDateQueryParam("created")+") AS date, status, COUNT(*) AS count"). From("task"). Where("project_id=?", projectID). GroupBy("date, status"). OrderBy("date DESC") if templateID != nil { q = q.Where("template_id=?", *templateID) } if filter.UserID != nil { q = q.Where("user_id=?", *filter.UserID) } if filter.Start != nil { q = q.Where("start>=?", *filter.Start) } if filter.End != nil { q = q.Where("end 0 { q = q.Limit(uint64(params.Count)) } query, args, err := q.ToSql() if err != nil { return } _, err = d.selectAll(&events, query, args...) if err != nil { return } err = db.FillEvents(d, events) return } func (d *SqlDb) CreateEvent(evt db.Event) (newEvent db.Event, err error) { var created = tz.Now() _, err = d.exec( "insert into event(user_id, project_id, object_id, object_type, description, created) values (?, ?, ?, ?, ?, ?)", evt.UserID, evt.ProjectID, evt.ObjectID, evt.ObjectType, evt.Description, created) if err != nil { return } newEvent = evt newEvent.Created = created return } func (d *SqlDb) GetUserEvents(userID int, params db.RetrieveQueryParams) ([]db.Event, error) { q := squirrel.Select("event.*, p.name as project_name"). From("event"). LeftJoin("project as p on event.project_id=p.id"). OrderBy("id desc"). LeftJoin("project__user as pu on pu.project_id=p.id"). Where("p.id IS NULL or pu.user_id=?", userID) return d.getEvents(q, params) } func (d *SqlDb) GetEvents(projectID int, params db.RetrieveQueryParams) ([]db.Event, error) { q := squirrel.Select("event.*, p.name as project_name"). From("event"). LeftJoin("project as p on event.project_id=p.id"). OrderBy("id desc"). Where("event.project_id=?", projectID) return d.getEvents(q, params) } func (d *SqlDb) GetAllEvents(params db.RetrieveQueryParams) ([]db.Event, error) { q := squirrel.Select("event.*, p.name as project_name"). From("event"). LeftJoin("project as p on event.project_id=p.id"). OrderBy("id desc") return d.getEvents(q, params) } ================================================ FILE: db/sql/global_runner.go ================================================ package sql import ( "encoding/base64" "github.com/Masterminds/squirrel" "github.com/gorilla/securecookie" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/tz" ) func (d *SqlDb) GetRunnerByToken(token string) (runner db.Runner, err error) { runners := make([]db.Runner, 0) err = d.getObjects(0, db.GlobalRunnerProps, db.RetrieveQueryParams{}, func(builder squirrel.SelectBuilder) squirrel.SelectBuilder { return builder.Where("token=?", token) }, &runners) if err != nil { return } if len(runners) == 0 { err = db.ErrNotFound return } runner = runners[0] return } func (d *SqlDb) GetGlobalRunner(runnerID int) (runner db.Runner, err error) { err = d.getObject(0, db.GlobalRunnerProps, runnerID, &runner) return } func (d *SqlDb) GetAllRunners(activeOnly bool, globalOnly bool) (runners []db.Runner, err error) { err = d.getObjects(0, db.GlobalRunnerProps, db.RetrieveQueryParams{}, func(builder squirrel.SelectBuilder) squirrel.SelectBuilder { if globalOnly { builder = builder.Where("project_id is null") } if activeOnly { builder = builder.Where("active=?", activeOnly) } return builder }, &runners) return } func (d *SqlDb) DeleteGlobalRunner(runnerID int) (err error) { err = d.deleteObject(0, db.GlobalRunnerProps, runnerID) return } func (d *SqlDb) ClearRunnerCache(runner db.Runner) (err error) { if runner.ProjectID == nil { _, err = d.exec( "update `runner` set `cleaning_requested`=? where id=?", tz.Now(), runner.ID) return } _, err = d.exec( "update `runner` set `cleaning_requested`=? where id=? and project_id=?", tz.Now(), runner.ID, runner.ProjectID) return } func (d *SqlDb) TouchRunner(runner db.Runner) (err error) { if runner.ProjectID == nil { _, err = d.exec( "update `runner` set `touched`=? where id=?", tz.Now(), runner.ID) return } _, err = d.exec( "update `runner` set `touched`=? where id=? and project_id=?", tz.Now(), runner.ID, runner.ProjectID) return } func (d *SqlDb) UpdateRunner(runner db.Runner) (err error) { _, err = d.exec( "update `runner` set `name`=?, `active`=?, webhook=?, max_parallel_tasks=?, tag=? where id=?", runner.Name, runner.Active, runner.Webhook, runner.MaxParallelTasks, runner.Tag, runner.ID) return } func (d *SqlDb) CreateRunner(runner db.Runner) (newRunner db.Runner, err error) { token := base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(32)) insertID, err := d.insert( "id", "insert into `runner` (project_id, token, webhook, max_parallel_tasks, `name`, `active`, public_key, tag) values (?, ?, ?, ?, ?, ?, ?, ?)", runner.ProjectID, token, runner.Webhook, runner.MaxParallelTasks, runner.Name, runner.Active, runner.PublicKey, runner.Tag) if err != nil { return } newRunner = runner newRunner.ID = insertID newRunner.Token = token return } ================================================ FILE: db/sql/integration.go ================================================ package sql import ( "github.com/Masterminds/squirrel" "github.com/semaphoreui/semaphore/db" ) func (d *SqlDb) CreateIntegration(integration db.Integration) (newIntegration db.Integration, err error) { err = integration.Validate() if err != nil { return } if integration.TaskParams != nil { params := *integration.TaskParams params.ProjectID = integration.ProjectID err = d.Sql().Insert(¶ms) if err != nil { return } integration.TaskParamsID = ¶ms.ID } insertID, err := d.insert( "id", "insert into project__integration "+ "(project_id, name, template_id, auth_method, auth_secret_id, auth_header, searchable, task_params_id) values "+ "(?, ?, ?, ?, ?, ?, ?, ?)", integration.ProjectID, integration.Name, integration.TemplateID, integration.AuthMethod, integration.AuthSecretID, integration.AuthHeader, integration.Searchable, integration.TaskParamsID) if err != nil { return } newIntegration = integration newIntegration.ID = insertID return } func (d *SqlDb) GetIntegrations(projectID int, params db.RetrieveQueryParams, includeTaskParams bool) (integrations []db.Integration, err error) { err = d.getObjects(projectID, db.IntegrationProps, params, nil, &integrations) if includeTaskParams { for i := range integrations { if integrations[i].TaskParamsID == nil { continue } var taskParams db.TaskParams err = d.getObject(projectID, db.TaskParamsProps, *integrations[i].TaskParamsID, &taskParams) if err != nil { return nil, err } integrations[i].TaskParams = &taskParams } } return integrations, err } func (d *SqlDb) GetIntegration(projectID int, integrationID int) (integration db.Integration, err error) { err = d.getObject(projectID, db.IntegrationProps, integrationID, &integration) if err != nil { return } if integration.TaskParamsID != nil { var taskParams db.TaskParams err = d.getObject(projectID, db.TaskParamsProps, *integration.TaskParamsID, &taskParams) if err != nil { return } integration.TaskParams = &taskParams } return } func (d *SqlDb) GetIntegrationRefs(projectID int, integrationID int) (referrers db.IntegrationReferrers, err error) { //var extractorReferrer []db.ObjectReferrer //extractorReferrer, err = d.GetObjectReferences(db.IntegrationProps, db.IntegrationExtractorProps, integrationID) //referrers = db.IntegrationReferrers{ // IntegrationExtractors: extractorReferrer, //} return } func (d *SqlDb) DeleteIntegration(projectID int, integrationID int) (err error) { var integration db.Integration err = d.getObject(projectID, db.IntegrationProps, integrationID, &integration) if err != nil { return } err = d.deleteObject(projectID, db.IntegrationProps, integrationID) if err != nil { return } if integration.TaskParamsID != nil { err = d.deleteObject(projectID, db.TaskParamsProps, *integration.TaskParamsID) } return } func (d *SqlDb) UpdateIntegration(integration db.Integration) (err error) { if err = integration.Validate(); err != nil { return } if integration.TaskParams != nil { var curr db.Integration err = d.getObject(integration.ProjectID, db.IntegrationProps, integration.ID, &curr) if err != nil { return } params := *integration.TaskParams params.ProjectID = integration.ProjectID if curr.TaskParamsID == nil { err = d.Sql().Insert(¶ms) } else { params.ID = *curr.TaskParamsID _, err = d.Sql().Update(¶ms) } if err != nil { return } integration.TaskParamsID = ¶ms.ID } _, err = d.exec( "update project__integration set "+ "`name`=?, "+ "template_id=?, "+ "auth_method=?, "+ "auth_secret_id=?, "+ "auth_header=?, "+ "searchable=?, "+ "task_params_id=? "+ "where project_id=? AND `id`=?", integration.Name, integration.TemplateID, integration.AuthMethod, integration.AuthSecretID, integration.AuthHeader, integration.Searchable, integration.TaskParamsID, integration.ProjectID, integration.ID) return err } func (d *SqlDb) CreateIntegrationExtractValue(projectId int, value db.IntegrationExtractValue) (newValue db.IntegrationExtractValue, err error) { err = value.Validate() if err != nil { return } insertID, err := d.insert("id", "insert into project__integration_extract_value "+ "(value_source, body_data_type, `key`, `variable`, `name`, integration_id, variable_type) values "+ "(?, ?, ?, ?, ?, ?, ?)", value.ValueSource, value.BodyDataType, value.Key, value.Variable, value.Name, value.IntegrationID, value.VariableType) if err != nil { return } newValue = value newValue.ID = insertID return } func (d *SqlDb) GetIntegrationExtractValues(projectID int, params db.RetrieveQueryParams, integrationID int) ([]db.IntegrationExtractValue, error) { var values []db.IntegrationExtractValue err := d.connection.GetObjectsByReferrer(integrationID, db.IntegrationProps, db.IntegrationExtractValueProps, params, &values) return values, err } func (d *SqlDb) GetIntegrationExtractValue(projectID int, valueID int, integrationID int) (value db.IntegrationExtractValue, err error) { query, args, err := squirrel.Select("v.*"). From("project__integration_extract_value as v"). Where(squirrel.Eq{"id": valueID}). OrderBy("v.id"). ToSql() if err != nil { return } err = d.selectOne(&value, query, args...) return value, err } func (d *SqlDb) GetIntegrationExtractValueRefs(projectID int, valueID int, integrationID int) (refs db.IntegrationExtractorChildReferrers, err error) { refs.Integrations, err = d.GetObjectReferences(db.IntegrationProps, db.IntegrationExtractValueProps, integrationID) return } func (d *SqlDb) DeleteIntegrationExtractValue(projectID int, valueID int, integrationID int) error { return d.deleteObjectByReferencedID(integrationID, db.IntegrationProps, db.IntegrationExtractValueProps, valueID) } func (d *SqlDb) UpdateIntegrationExtractValue(projectID int, integrationExtractValue db.IntegrationExtractValue) error { err := integrationExtractValue.Validate() if err != nil { return err } _, err = d.exec( "update project__integration_extract_value set value_source=?, body_data_type=?, `key`=?, `variable`=?, `name`=?, `variable_type`=? where `id`=?", integrationExtractValue.ValueSource, integrationExtractValue.BodyDataType, integrationExtractValue.Key, integrationExtractValue.Variable, integrationExtractValue.Name, integrationExtractValue.VariableType, integrationExtractValue.ID) return err } func (d *SqlDb) CreateIntegrationMatcher(projectID int, matcher db.IntegrationMatcher) (newMatcher db.IntegrationMatcher, err error) { err = matcher.Validate() if err != nil { return } insertID, err := d.insert( "id", "insert into project__integration_matcher "+ "(match_type, `method`, body_data_type, `key`, `value`, integration_id, `name`) values "+ "(?, ?, ?, ?, ?, ?, ?)", matcher.MatchType, matcher.Method, matcher.BodyDataType, matcher.Key, matcher.Value, matcher.IntegrationID, matcher.Name) if err != nil { return } newMatcher = matcher newMatcher.ID = insertID return } func (d *SqlDb) GetIntegrationMatchers(projectID int, params db.RetrieveQueryParams, integrationID int) (matchers []db.IntegrationMatcher, err error) { query, args, err := squirrel.Select("m.*"). From("project__integration_matcher as m"). Where(squirrel.Eq{"integration_id": integrationID}). OrderBy("m.id"). ToSql() if err != nil { return } _, err = d.selectAll(&matchers, query, args...) return } func (d *SqlDb) GetIntegrationMatcher(projectID int, matcherID int, integrationID int) (matcher db.IntegrationMatcher, err error) { query, args, err := squirrel.Select("m.*"). From("project__integration_matcher as m"). Where(squirrel.Eq{"id": matcherID}). OrderBy("m.id"). ToSql() if err != nil { return } err = d.selectOne(&matcher, query, args...) return matcher, err } func (d *SqlDb) GetIntegrationMatcherRefs(projectID int, matcherID int, integrationID int) (refs db.IntegrationExtractorChildReferrers, err error) { refs.Integrations, err = d.GetObjectReferences(db.IntegrationProps, db.IntegrationMatcherProps, matcherID) return } func (d *SqlDb) DeleteIntegrationMatcher(projectID int, matcherID int, integrationID int) error { return d.deleteObjectByReferencedID(integrationID, db.IntegrationProps, db.IntegrationMatcherProps, matcherID) } func (d *SqlDb) UpdateIntegrationMatcher(projectID int, integrationMatcher db.IntegrationMatcher) error { err := integrationMatcher.Validate() if err != nil { return err } _, err = d.exec( "update project__integration_matcher set match_type=?, `method`=?, body_data_type=?, `key`=?, `value`=?, `name`=? where integration_id=? and `id`=?", integrationMatcher.MatchType, integrationMatcher.Method, integrationMatcher.BodyDataType, integrationMatcher.Key, integrationMatcher.Value, integrationMatcher.Name, integrationMatcher.IntegrationID, integrationMatcher.ID) return err } ================================================ FILE: db/sql/integration_alias.go ================================================ package sql import ( "github.com/Masterminds/squirrel" "github.com/semaphoreui/semaphore/db" ) func (d *SqlDb) CreateIntegrationAlias(alias db.IntegrationAlias) (res db.IntegrationAlias, err error) { insertID, err := d.insert( "id", "insert into project__integration_alias (project_id, integration_id, alias) values (?, ?, ?)", alias.ProjectID, alias.IntegrationID, alias.Alias) if err != nil { return } res = alias res.ID = insertID return } func (d *SqlDb) GetIntegrationAliases(projectID int, integrationID *int) (res []db.IntegrationAlias, err error) { q := squirrel.Select("*").From(db.IntegrationAliasProps.TableName) if integrationID == nil { q = q.Where("project_id=? AND integration_id is null", projectID) } else { q = q.Where("project_id=? AND integration_id=?", projectID, integrationID) } query, args, err := q.ToSql() if err != nil { return } _, err = d.selectAll(&res, query, args...) return } func (d *SqlDb) GetIntegrationsByAlias(alias string) (res []db.Integration, level db.IntegrationAliasLevel, err error) { var aliasObj db.IntegrationAlias q := squirrel.Select("*"). From(db.IntegrationAliasProps.TableName). Where("alias=?", alias) query, args, err := q.ToSql() if err != nil { return } err = d.selectOne(&aliasObj, query, args...) if err != nil { return } if aliasObj.IntegrationID == nil { level = db.IntegrationAliasProject var projIntegrations []db.Integration projIntegrations, err = d.GetIntegrations(aliasObj.ProjectID, db.RetrieveQueryParams{}, true) if err != nil { return } for _, integration := range projIntegrations { if integration.Searchable { res = append(res, integration) } } } else { level = db.IntegrationAliasSingle var integration db.Integration integration, err = d.GetIntegration(aliasObj.ProjectID, *aliasObj.IntegrationID) if err != nil { return } if integration.Searchable { err = db.ErrNotFound return } res = append(res, integration) } return } func (d *SqlDb) DeleteIntegrationAlias(projectID int, aliasID int) error { return d.deleteObject(projectID, db.IntegrationAliasProps, aliasID) } ================================================ FILE: db/sql/inventory.go ================================================ package sql import ( "github.com/Masterminds/squirrel" "github.com/semaphoreui/semaphore/db" ) func (d *SqlDb) GetInventory(projectID int, inventoryID int) (inventory db.Inventory, err error) { err = d.getObject(projectID, db.InventoryProps, inventoryID, &inventory) return } func (d *SqlDb) GetInventories(projectID int, params db.RetrieveQueryParams, types []db.InventoryType) ([]db.Inventory, error) { var inventories []db.Inventory err := d.getObjects(projectID, db.InventoryProps, params, func(builder squirrel.SelectBuilder) squirrel.SelectBuilder { if len(types) == 0 { return builder } return builder.Where(squirrel.Eq{"type": types}) }, &inventories) return inventories, err } func (d *SqlDb) GetInventoryRefs(projectID int, inventoryID int) (db.ObjectReferrers, error) { return d.getObjectRefs(projectID, db.InventoryProps, inventoryID) } func (d *SqlDb) DeleteInventory(projectID int, inventoryID int) error { return d.deleteObject(projectID, db.InventoryProps, inventoryID) } func (d *SqlDb) UpdateInventory(inventory db.Inventory) error { _, err := d.exec( "update project__inventory set "+ "name=?, "+ "type=?, "+ "runner_tag=?, "+ "ssh_key_id=?, "+ "inventory=?, "+ "become_key_id=?, "+ "template_id=?, "+ "repository_id=? "+ "where id=?", inventory.Name, inventory.Type, inventory.RunnerTag, inventory.SSHKeyID, inventory.Inventory, inventory.BecomeKeyID, inventory.TemplateID, inventory.RepositoryID, inventory.ID) return err } func (d *SqlDb) CreateInventory(inventory db.Inventory) (newInventory db.Inventory, err error) { insertID, err := d.insert( "id", "insert into project__inventory ("+ "project_id, name, type, "+ "ssh_key_id, inventory, become_key_id, "+ "template_id, repository_id, runner_tag) values "+ "(?, ?, ?, "+ "?, ?, ?, "+ "?, ?, ?)", inventory.ProjectID, inventory.Name, inventory.Type, inventory.SSHKeyID, inventory.Inventory, inventory.BecomeKeyID, inventory.TemplateID, inventory.RepositoryID, inventory.RunnerTag, ) if err != nil { return } newInventory = inventory newInventory.ID = insertID return } ================================================ FILE: db/sql/migration.go ================================================ package sql import ( "bytes" "fmt" "path" "regexp" "strings" "text/template" "github.com/go-gorp/gorp/v3" "github.com/semaphoreui/semaphore/pkg/tz" "github.com/semaphoreui/semaphore/util" "github.com/semaphoreui/semaphore/db" log "github.com/sirupsen/logrus" ) var ( autoIncrementRE = regexp.MustCompile(`(?i)\bautoincrement\b`) serialRE = regexp.MustCompile(`(?i)\binteger primary key autoincrement\b`) dateTimeTypeRE = regexp.MustCompile(`(?i)\bdatetime\b`) tinyintRE = regexp.MustCompile(`(?i)\btinyint\b`) longtextRE = regexp.MustCompile(`(?i)\blongtext\b`) ifExistsRE = regexp.MustCompile(`(?i)\bif exists\b`) changeRE = regexp.MustCompile(`^alter table \x60(\w+)\x60 change \x60(\w+)\x60 \x60(\w+)\x60 ([\w\(\)]+)( autoincrement)?( not null)?$`) dropForeignKeyRE = regexp.MustCompile(`(?i)\bdrop foreign key\b`) ) // getVersionPath is the humanoid version with the file format appended func getVersionPath(version db.Migration) string { return version.HumanoidVersion() + ".sql" } // getVersionErrPath is the humanoid version with '.err' and file format appended func getVersionErrPath(version db.Migration) string { return version.HumanoidVersion() + ".err.sql" } // getVersionSQL takes a path to an SQL file and returns it from embed.FS // a slice of strings separated by newlines func getVersionSQL(dialect string, name string, ignoreErrors bool) (queries []string) { sql, err := dbAssets.ReadFile(path.Join("migrations", name)) if err != nil { if ignoreErrors { log.WithError(err).Warnf("migration %s not found", name) return nil } else { panic(err) } } processedSql, err := preprocessSqlDialect(dialect, string(sql)) if err != nil { panic(err) } queries = strings.Split(strings.ReplaceAll(processedSql, ";\r\n", ";\n"), ";\n") for i := range queries { queries[i] = strings.Trim(queries[i], "\r\n\t ") } return } func getDialectConfig(dialect string) interface{} { type Config struct { Sqlite bool Mysql bool Postgresql bool } conf := Config{} switch dialect { case util.DbDriverSQLite: conf.Sqlite = true case "mysql": conf.Mysql = true case "postgres": conf.Postgresql = true } return conf } func preprocessSqlDialect(dialect string, sql string) (string, error) { tmpl, err := template.New("sql").Parse(sql) if err != nil { return "", err } var buf bytes.Buffer err = tmpl.Execute(&buf, getDialectConfig(dialect)) if err != nil { panic(err) } return buf.String(), nil } // prepareMigration converts migration SQLite-query to current dialect. // Supported MySQL and Postgres dialects. func (d *SqlDb) prepareMigration(query string) string { switch d.Sql().Dialect.(type) { case gorp.MySQLDialect: query = autoIncrementRE.ReplaceAllString(query, "auto_increment") query = ifExistsRE.ReplaceAllString(query, "") case gorp.PostgresDialect: m := changeRE.FindStringSubmatch(query) if m != nil { tableName := m[1] oldColumnName := m[2] newColumnName := m[3] columnType := m[4] //autoincrement := m[5] != "" columnNotNull := m[6] != "" var queries []string queries = append(queries, "alter table `"+tableName+"` alter column `"+oldColumnName+"` type "+columnType) if columnNotNull { queries = append(queries, "alter table `"+tableName+"` alter column `"+oldColumnName+"` set not null") } else { queries = append(queries, "alter table `"+tableName+"` alter column `"+oldColumnName+"` drop not null") } if oldColumnName != newColumnName { queries = append(queries, "alter table `"+tableName+"` rename column `"+oldColumnName+"` to `"+newColumnName+"`") } query = strings.Join(queries, "; ") } query = dateTimeTypeRE.ReplaceAllString(query, "timestamp") query = tinyintRE.ReplaceAllString(query, "smallint") query = longtextRE.ReplaceAllString(query, "text") query = serialRE.ReplaceAllString(query, "serial primary key") query = dropForeignKeyRE.ReplaceAllString(query, "drop constraint") query = identifierQuoteRE.ReplaceAllString(query, "\"") } return query } // IsMigrationApplied queries the database to see if a migration table with this version id exists already func (d *SqlDb) IsMigrationApplied(migration db.Migration) (bool, error) { initialized, err := d.IsInitialized() if err != nil { return false, err } if !initialized { return false, nil } exists, err := d.Sql().SelectInt( d.PrepareQuery("select count(1) as ex from migrations where version = ?"), migration.Version) if err != nil { return false, err } return exists > 0, nil } // ApplyMigration runs executes a database migration func (d *SqlDb) ApplyMigration(migration db.Migration) error { initialized, err := d.IsInitialized() if err != nil { return err } if !initialized { fmt.Println("Creating migrations table") query := d.prepareMigration(initialSQL) if query == "" { return nil } _, err = d.exec(query) if err != nil { return err } } tx, err := d.Sql().Begin() if err != nil { return err } switch migration.Version { case "2.10.24": err = migration_2_10_24{db: d}.PreApply(tx) } if err != nil { handleRollbackError(tx.Rollback()) return err } queries := getVersionSQL(d.GetDialect(), getVersionPath(migration), false) for i, query := range queries { fmt.Printf("\r [%d/%d]", i+1, len(query)) if len(query) == 0 { continue } q := d.prepareMigration(query) if q == "" { continue } _, err = tx.Exec(q) if err != nil { handleRollbackError(tx.Rollback()) log.Warnf("\n ERR! Query: %s\n\n", q) log.Fatal(err.Error()) return err } } switch migration.Version { case "2.8.26": err = migration_2_8_26{db: d}.PostApply(tx) case "2.8.42": err = migration_2_8_42{db: d}.PostApply(tx) } if err != nil { handleRollbackError(tx.Rollback()) return err } _, err = tx.Exec(d.PrepareQuery("insert into migrations(version, upgraded_date) values (?, ?)"), migration.Version, tz.Now()) if err != nil { handleRollbackError(tx.Rollback()) return err } fmt.Println() return tx.Commit() } // TryRollbackMigration attempts to rollback the database to an earlier version if a rollback exists func (d *SqlDb) TryRollbackMigration(version db.Migration) { var err error tx, err := d.Sql().Begin() if err != nil { panic(err) } defer func() { if err == nil { err = tx.Commit() if err != nil { log.WithError(err).WithFields(log.Fields{ "context": "migration", "version": version.Version, }).Error("failed to commit undo migration transaction") } } else { _ = tx.Rollback() log.Error(err) } }() queries := getVersionSQL(d.GetDialect(), getVersionErrPath(version), true) for _, query := range queries { fmt.Printf(" [ROLLBACK] > %v\n", query) q := d.prepareMigration(query) if q == "" { continue } if _, err = d.execTx(tx, q); err != nil { fmt.Println(" [ROLLBACK] - Stopping") return } } _, err = d.execTx(tx, "delete from migrations where version=?", version.Version) } ================================================ FILE: db/sql/migration_2_10_24.go ================================================ package sql import "github.com/go-gorp/gorp/v3" type migration_2_10_24 struct { db *SqlDb } func (m migration_2_10_24) PreApply(tx *gorp.Transaction) error { switch m.db.Sql().Dialect.(type) { case gorp.MySQLDialect: _, _ = tx.Exec(m.db.PrepareQuery("alter table `project__template` drop foreign key `project__template_ibfk_6`")) case gorp.PostgresDialect: _, err := tx.Exec( m.db.PrepareQuery("alter table `project__template` drop constraint if exists `project__template_vault_key_id_fkey`")) return err } return nil } ================================================ FILE: db/sql/migration_2_8_28.go ================================================ package sql import ( "github.com/go-gorp/gorp/v3" "strings" ) type migration_2_8_26 struct { db *SqlDb } func (m migration_2_8_26) PostApply(tx *gorp.Transaction) error { rows, err := tx.Query(m.db.PrepareQuery("SELECT id, git_url FROM project__repository")) if err != nil { return err } repoUrls := make(map[string]string) for rows.Next() { var id, url string err3 := rows.Scan(&id, &url) if err3 != nil { continue } repoUrls[id] = url } err = rows.Close() if err != nil { return err } for id, url := range repoUrls { branch := "master" parts := strings.Split(url, "#") if len(parts) > 1 { url, branch = parts[0], parts[1] } q := m.db.PrepareQuery("UPDATE project__repository " + "SET git_url = ?, git_branch = ? " + "WHERE id = ?") _, err = tx.Exec(q, url, branch, id) if err != nil { return err } } return nil } ================================================ FILE: db/sql/migration_2_8_42.go ================================================ package sql import "github.com/go-gorp/gorp/v3" type migration_2_8_42 struct { db *SqlDb } func (m migration_2_8_42) PostApply(tx *gorp.Transaction) error { switch m.db.Sql().Dialect.(type) { case gorp.MySQLDialect: _, _ = tx.Exec(m.db.PrepareQuery("alter table `task` drop foreign key `task_ibfk_3`")) case gorp.PostgresDialect: _, err := tx.Exec( m.db.PrepareQuery("alter table `task` drop constraint if exists `task_build_task_id_fkey`")) return err } return nil } ================================================ FILE: db/sql/migrations/v0.0.0.sql ================================================ create table `user` ( `id` integer primary key autoincrement, `created` datetime not null, `username` varchar(255) not null, `name` varchar(255) not null, `email` varchar(255) not null, `password` varchar(255) not null, unique (`username`), unique (`email`) ); create table `project` ( `id` integer primary key autoincrement, `created` datetime not null, `name` varchar(255) not null ); create table `project__user` ( `project_id` int not null, `user_id` int not null, `admin` boolean not null default false, unique (`project_id`, `user_id`), foreign key (`project_id`) references project(`id`) on delete cascade, foreign key (`user_id`) references `user`(`id`) on delete cascade ); create table `access_key` ( `id` integer primary key autoincrement, `name` varchar(255) not null, `type` varchar(255) not null, `project_id` int null, `key` text null, `secret` text null, foreign key (`project_id`) references project(`id`) on delete set null ); create table `project__repository` ( `id` integer primary key autoincrement, `project_id` int not null, `git_url` text not null, `ssh_key_id` int not null, foreign key (`project_id`) references project(`id`) on delete cascade, foreign key (`ssh_key_id`) references access_key(`id`) ); create table `project__inventory` ( `id` integer primary key autoincrement, `project_id` int not null, `type` varchar(255) not null, `key_id` int null, `inventory` longtext not null, foreign key (`project_id`) references project(`id`) on delete cascade, foreign key (`key_id`) references access_key(`id`) ); create table `project__environment` ( `id` integer primary key autoincrement, `project_id` int not null, `password` varchar(255) null, `json` longtext not null, foreign key (`project_id`) references project(`id`) on delete cascade ); create table `project__template` ( `id` integer primary key autoincrement, `ssh_key_id` int not null, `project_id` int not null, `inventory_id` int not null, `repository_id` int not null, `environment_id` int null, `playbook` varchar(255) not null, foreign key (`project_id`) references project(`id`) on delete cascade, foreign key (`ssh_key_id`) references access_key(`id`), foreign key (`inventory_id`) references project__inventory(`id`), foreign key (`repository_id`) references project__repository(`id`), foreign key (`environment_id`) references project__environment(`id`) ); create table `project__template_schedule` ( `template_id` int primary key, `cron_format` varchar(255) not null, foreign key (`template_id`) references project__template(`id`) on delete cascade ); create table `task` ( `id` integer primary key autoincrement, `template_id` int not null, `status` varchar(255) not null, `playbook` varchar(255) not null, `environment` longtext null, foreign key (`template_id`) references project__template(`id`) on delete cascade ); create table `task__output` ( `task_id` int not null, `task` varchar(255) not null, `time` datetime not null, `output` longtext not null, unique (`task_id`, `time`), foreign key (`task_id`) references task(`id`) on delete cascade ); ================================================ FILE: db/sql/migrations/v1.0.0.sql ================================================ alter table task add `debug` boolean not null default false; alter table `project__template` add `arguments` text null; alter table `project__template` add `override_args` boolean not null default false; alter table `project__inventory` add `ssh_key_id` int null references access_key(`id`); ================================================ FILE: db/sql/migrations/v1.2.0.sql ================================================ create table `user__token` ( `id` varchar(32) not null primary key, `created` datetime not null, `expired` boolean not null default false, `user_id` int not null, foreign key (`user_id`) references `user`(`id`) on delete cascade ); ================================================ FILE: db/sql/migrations/v1.3.0.sql ================================================ alter table project__environment add `name` varchar(255); alter table project__inventory add `name` varchar(255); alter table project__repository add `name` varchar(255); ================================================ FILE: db/sql/migrations/v1.4.0.sql ================================================ CREATE TABLE `event` ( `project_id` int DEFAULT NULL, `object_id` int DEFAULT NULL, `object_type` varchar(20) DEFAULT '', `description` text, `created` datetime NOT NULL ); alter table `task` add `created` datetime null; alter table `task` add `start` datetime null; alter table `task` add `end` datetime null; ================================================ FILE: db/sql/migrations/v1.5.0.sql ================================================ CREATE TABLE `session` ( `id` integer primary key autoincrement, `user_id` int NOT NULL, `created` datetime NOT NULL, `last_active` datetime NOT NULL, `ip` varchar(15) NOT NULL DEFAULT '', `user_agent` text NOT NULL, `expired` boolean NOT NULL DEFAULT false ); CREATE INDEX `user_id` ON `session`(`user_id`); CREATE INDEX `expired` ON `session`(`expired`); ================================================ FILE: db/sql/migrations/v1.6.0.sql ================================================ alter table project__environment add `removed` boolean default false; alter table project__inventory add `removed` boolean default false; alter table project__repository add `removed` boolean default false; alter table access_key add `removed` boolean default false; ================================================ FILE: db/sql/migrations/v1.7.0.sql ================================================ alter table task add user_id int; ================================================ FILE: db/sql/migrations/v1.8.0.sql ================================================ ALTER TABLE task ADD dry_run boolean NOT NULL DEFAULT false; ================================================ FILE: db/sql/migrations/v1.9.0.sql ================================================ ALTER TABLE project__template ADD alias varchar(100); ================================================ FILE: db/sql/migrations/v2.10.12.sql ================================================ alter table `project__template` add `tasks` int not null default 0; alter table `project__schedule` add `name` varchar(100) not null default ''; alter table `project__schedule` add `active` boolean not null default true; ================================================ FILE: db/sql/migrations/v2.10.15.sql ================================================ alter table `access_key` add `environment_id` int null references project__environment(`id`) on delete cascade; alter table `access_key` add `user_id` int null references `user`(`id`) on delete cascade; alter table `project__integration` add `task_params` text null; alter table `task` add `schedule_id` int null references project__schedule(`id`) on delete set null; ================================================ FILE: db/sql/migrations/v2.10.16.sql ================================================ update `project__template` set `app` = 'ansible' where `app` = ''; alter table `project__template` change `app` `app` varchar(50) not null; ================================================ FILE: db/sql/migrations/v2.10.24.sql ================================================ create table `project__template_vault` ( `id` integer primary key autoincrement, `project_id` int not null, `template_id` int not null, `vault_key_id` int not null, `name` varchar(255), unique (`template_id`, `vault_key_id`, `name`), foreign key (`project_id`) references project(`id`) on delete cascade, foreign key (`template_id`) references project__template(`id`) on delete cascade, foreign key (`vault_key_id`) references `access_key`(`id`) on delete cascade ); insert into `project__template_vault` (template_id, project_id, vault_key_id) select `id` as template_id, project_id, vault_key_id from `project__template` where `vault_key_id` is not null; alter table `project__template` drop column `vault_key_id`; ================================================ FILE: db/sql/migrations/v2.10.26.sql ================================================ alter table `runner` add `name` varchar(100) not null default ''; alter table `runner` add `active` boolean not null default true; ================================================ FILE: db/sql/migrations/v2.10.28.sql ================================================ alter table task add git_branch varchar(255); alter table project__template add git_branch varchar(255); ================================================ FILE: db/sql/migrations/v2.10.33.sql ================================================ alter table `project__template_vault` change `vault_key_id` `vault_key_id` int; alter table `project__template_vault` add `type` varchar(20) not null default 'password'; alter table `project__template_vault` add `script` text; update `project__template_vault` set `type` = 'password' where `vault_key_id` IS NOT NULL; ================================================ FILE: db/sql/migrations/v2.10.46.sql ================================================ alter table `project__template` add `task_params` text; alter table `task` add `params` text; ================================================ FILE: db/sql/migrations/v2.11.5.sql ================================================ create table project__terraform_inventory_alias( `alias` varchar(100) primary key, `project_id` int NOT NULL, `inventory_id` int NOT NULL, `auth_key_id` int NOT NULL, foreign key (`project_id`) references project(`id`) on delete cascade, foreign key (`inventory_id`) references project__inventory(`id`) on delete cascade, foreign key (`auth_key_id`) references access_key(`id`) ); create table project__terraform_inventory_state( `id` integer primary key autoincrement, `project_id` int NOT NULL, `inventory_id` int NOT NULL, `state` text NOT NULL, `created` datetime NOT NULL, `task_id` int, foreign key (`task_id`) references task(`id`) on delete set null, foreign key (`project_id`) references project(`id`) on delete cascade, foreign key (`inventory_id`) references project__inventory(`id`) on delete cascade ); alter table `project__inventory` change `holder_id` `template_id` int ================================================ FILE: db/sql/migrations/v2.12.0.sql ================================================ create table user__totp( `id` integer primary key autoincrement, `user_id` int NOT NULL, `url` varchar(250) NOT NULL, recovery_hash varchar(250) NOT NULL, `created` datetime NOT NULL, unique (`user_id`), foreign key (`user_id`) references `user`(`id`) on delete cascade ); alter table `session` add column verification_method int not null default 0; alter table `session` add column verified boolean not null default false; ================================================ FILE: db/sql/migrations/v2.12.15.sql ================================================ alter table `project__schedule` change `last_commit_hash` `last_commit_hash` varchar(64); alter table `task` change `commit_hash` `commit_hash` varchar(64); ================================================ FILE: db/sql/migrations/v2.12.3.sql ================================================ alter table `task__output` drop `task`; alter table `task__output` change `id` `id` bigint autoincrement not null ================================================ FILE: db/sql/migrations/v2.12.4.sql ================================================ alter table `user` add column pro boolean not null default false; ================================================ FILE: db/sql/migrations/v2.12.5.sql ================================================ alter table `runner` add column public_key text; ================================================ FILE: db/sql/migrations/v2.13.0.sql ================================================ alter table `runner` add column tag varchar(200) not null default ''; alter table `project__template` add column runner_tag varchar(50); ================================================ FILE: db/sql/migrations/v2.14.0.err.sql ================================================ alter table project__template drop column allow_override_branch_in_task; ALTER TABLE task ADD diff boolean NOT NULL DEFAULT false; alter table task add `debug` boolean not null default false; ALTER TABLE task ADD dry_run boolean NOT NULL DEFAULT false; alter table `task` add column `hosts_limit` varchar(255) not null default ''; alter table runner drop column touched; alter table runner drop column cleaning_requested; ================================================ FILE: db/sql/migrations/v2.14.0.sql ================================================ alter table project__template add allow_override_branch_in_task bool not null default false; alter table `task` drop column `diff`; alter table `task` drop column `debug`; alter table `task` drop column `dry_run`; alter table `task` drop column `hosts_limit`; alter table runner add touched datetime; alter table runner add cleaning_requested datetime; ================================================ FILE: db/sql/migrations/v2.14.1.err.sql ================================================ alter table `project__integration_extract_value` drop column `variable_type`; ================================================ FILE: db/sql/migrations/v2.14.1.sql ================================================ alter table `project__integration_extract_value` add `variable_type` varchar(255); update `project__integration_extract_value` set `variable_type` = 'environment' where `variable_type` is null or `variable_type` = ''; ================================================ FILE: db/sql/migrations/v2.14.12.err.sql ================================================ drop index task__output_time_idx; ================================================ FILE: db/sql/migrations/v2.14.12.sql ================================================ create index task__output_time_idx on task__output (time); ================================================ FILE: db/sql/migrations/v2.14.5.sql ================================================ update `option` set `value` = '' where `value` is null; alter table `option` change `value` `value` varchar(1000) not null; ================================================ FILE: db/sql/migrations/v2.14.7.sql ================================================ -- Mock for BoltDB migration ================================================ FILE: db/sql/migrations/v2.15.0.err.sql ================================================ drop table task__stage_result; drop table task__stage; ================================================ FILE: db/sql/migrations/v2.15.0.sql ================================================ create table task__stage ( `id` integer primary key autoincrement, `task_id` int NOT NULL, `start` datetime null, `start_output_id` bigint null, `end` datetime null, `end_output_id` bigint null, `type` varchar(100), foreign key (`task_id`) references `task` (`id`) on delete cascade, foreign key (`start_output_id`) references `task__output` (`id`) on delete set null, foreign key (`end_output_id`) references `task__output` (`id`) on delete set null ); create table task__stage_result ( `id` integer primary key autoincrement, `task_id` int NOT NULL, `stage_id` int NOT NULL, `json` text, foreign key (`task_id`) references `task` (`id`) on delete cascade, foreign key (`stage_id`) references `task__stage` (`id`) on delete cascade ); ================================================ FILE: db/sql/migrations/v2.15.1.err.sql ================================================ alter table `project__inventory` drop `runner_tag`; ================================================ FILE: db/sql/migrations/v2.15.1.sql ================================================ alter table `project__inventory` add `runner_tag` varchar(255); ================================================ FILE: db/sql/migrations/v2.15.1.sqlite.sql ================================================ CREATE TABLE "option" ( "key" VARCHAR(255) NOT NULL PRIMARY KEY, value VARCHAR(1000) NOT NULL ); CREATE TABLE project ( id INTEGER PRIMARY KEY AUTOINCREMENT, created DATETIME NOT NULL, name VARCHAR(255) NOT NULL, alert INTEGER NOT NULL DEFAULT 0, alert_chat VARCHAR(30) NULL, max_parallel_tasks INTEGER NOT NULL DEFAULT 0, type VARCHAR(20) NULL DEFAULT '' ); CREATE TABLE project__environment ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE, password VARCHAR(255) NULL, json TEXT NOT NULL, name VARCHAR(255) NULL, env TEXT ); CREATE INDEX project__environment__project__environment_project_id ON project__environment(project_id); CREATE TABLE project__view ( id INTEGER PRIMARY KEY AUTOINCREMENT, title VARCHAR(100) NOT NULL, project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE, position INTEGER NOT NULL ); CREATE INDEX project__view__project__view_project_id ON project__view(project_id); CREATE TABLE runner ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER REFERENCES project(id) ON DELETE CASCADE, token VARCHAR(255) NOT NULL, webhook VARCHAR(1000) NOT NULL DEFAULT '', max_parallel_tasks INTEGER NOT NULL DEFAULT 0, name VARCHAR(100) NOT NULL DEFAULT '', active INTEGER NOT NULL DEFAULT 1, public_key TEXT NULL, tag VARCHAR(200) NOT NULL DEFAULT '', touched DATETIME NULL, cleaning_requested DATETIME NULL ); CREATE INDEX runner__runner__project_id ON runner(project_id); CREATE TABLE session ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, created DATETIME NOT NULL, last_active DATETIME NOT NULL, ip VARCHAR(39) NOT NULL DEFAULT '', user_agent TEXT NOT NULL, expired INTEGER NOT NULL DEFAULT 0, verification_method INTEGER NOT NULL DEFAULT 0, verified INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(user_id) REFERENCES user(id) ); CREATE INDEX session__session__expired ON session(expired); CREATE INDEX session__session__user_id ON session(user_id); CREATE TABLE user ( id INTEGER PRIMARY KEY AUTOINCREMENT, created DATETIME NOT NULL, username VARCHAR(255) NOT NULL UNIQUE, name VARCHAR(255) NOT NULL, email VARCHAR(255) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, alert INTEGER NOT NULL DEFAULT 0, external INTEGER NOT NULL DEFAULT 0, admin INTEGER NOT NULL DEFAULT 1, pro INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE access_key ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255) NOT NULL, type VARCHAR(255) NOT NULL, project_id INTEGER REFERENCES project(id) ON DELETE SET NULL, secret TEXT NULL, environment_id INTEGER REFERENCES project__environment(id) ON DELETE CASCADE, user_id INTEGER REFERENCES user(id) ON DELETE CASCADE ); CREATE INDEX access_key__environment_id ON access_key(environment_id); CREATE INDEX access_key__project_id ON access_key(project_id); CREATE INDEX access_key__user_id ON access_key(user_id); CREATE TABLE event ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER REFERENCES project(id) ON DELETE CASCADE, object_id INTEGER NULL, object_type VARCHAR(20) NULL DEFAULT '', description TEXT NULL, created DATETIME NOT NULL, user_id INTEGER REFERENCES user(id) ON DELETE SET NULL ); CREATE INDEX event__project_id ON event(project_id); CREATE INDEX event__user_id ON event(user_id); CREATE TABLE event_backup_5784568 ( project_id INTEGER NULL, object_id INTEGER NULL, object_type VARCHAR(20) NULL DEFAULT '', description TEXT NULL, created DATETIME NOT NULL, user_id INTEGER REFERENCES user(id) ON DELETE SET NULL ); CREATE INDEX event_backup_5784568__user_id ON event_backup_5784568(user_id); CREATE TABLE project__repository ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE, git_url TEXT NOT NULL, ssh_key_id INTEGER NOT NULL REFERENCES access_key(id), name VARCHAR(255) NULL, git_branch VARCHAR(255) NOT NULL DEFAULT '' ); CREATE INDEX project__repository__project_id ON project__repository(project_id); CREATE INDEX project__repository__ssh_key_id ON project__repository(ssh_key_id); CREATE TABLE project__inventory ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE, type VARCHAR(255) NOT NULL, inventory TEXT NOT NULL, ssh_key_id INTEGER REFERENCES access_key(id), name VARCHAR(255) NULL, become_key_id INTEGER REFERENCES access_key(id), template_id INTEGER REFERENCES project__template(id) ON DELETE SET NULL, repository_id INTEGER REFERENCES project__repository(id) ON DELETE SET NULL, runner_tag VARCHAR(255) NULL ); CREATE INDEX project__inventory__become_key_id ON project__inventory(become_key_id); CREATE INDEX project__inventory__holder_id ON project__inventory(template_id); CREATE INDEX project__inventory__project_id ON project__inventory(project_id); CREATE INDEX project__inventory__repository_id ON project__inventory(repository_id); CREATE INDEX project__inventory__ssh_key_id ON project__inventory(ssh_key_id); CREATE TABLE project__template ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE, inventory_id INTEGER REFERENCES project__inventory(id), repository_id INTEGER NOT NULL REFERENCES project__repository(id), environment_id INTEGER REFERENCES project__environment(id), playbook VARCHAR(255) NOT NULL, arguments TEXT NULL, name VARCHAR(100) NOT NULL, description TEXT NULL, type VARCHAR(10) NOT NULL DEFAULT '', start_version VARCHAR(20) NULL, build_template_id INTEGER REFERENCES project__template(id), view_id INTEGER REFERENCES project__view(id) ON DELETE SET NULL, survey_vars TEXT NULL, autorun INTEGER NULL DEFAULT 0, allow_override_args_in_task INTEGER NOT NULL DEFAULT 0, suppress_success_alerts INTEGER NOT NULL DEFAULT 0, app VARCHAR(50) NOT NULL, tasks INTEGER NOT NULL DEFAULT 0, git_branch VARCHAR(255) NULL, task_params TEXT NULL, runner_tag VARCHAR(50) NULL, allow_override_branch_in_task INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX project__template__build_template_id ON project__template(build_template_id); CREATE INDEX project__template__environment_id ON project__template(environment_id); CREATE INDEX project__template__inventory_id ON project__template(inventory_id); CREATE INDEX project__template__project_id ON project__template(project_id); CREATE INDEX project__template__repository_id ON project__template(repository_id); CREATE INDEX project__template__view_id ON project__template(view_id); CREATE TABLE project__integration ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255) NOT NULL, project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE, template_id INTEGER NOT NULL REFERENCES project__template(id) ON DELETE CASCADE, auth_method VARCHAR(15) NOT NULL DEFAULT 'none', auth_secret_id INTEGER REFERENCES access_key(id) ON DELETE SET NULL, auth_header VARCHAR(255) NULL, searchable INTEGER NOT NULL DEFAULT 0, task_params TEXT NULL ); CREATE INDEX project__integration__auth_secret_id ON project__integration(auth_secret_id); CREATE INDEX project__integration__project_id ON project__integration(project_id); CREATE INDEX project__integration__template_id ON project__integration(template_id); CREATE TABLE project__integration_alias ( id INTEGER PRIMARY KEY AUTOINCREMENT, alias VARCHAR(50) NOT NULL UNIQUE, project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE, integration_id INTEGER REFERENCES project__integration(id) ON DELETE CASCADE ); CREATE INDEX project__integration_alias__integration_id ON project__integration_alias(integration_id); CREATE INDEX project__integration_alias__project_id ON project__integration_alias(project_id); CREATE TABLE project__integration_extract_value ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255) NOT NULL, integration_id INTEGER NOT NULL REFERENCES project__integration(id) ON DELETE CASCADE, value_source VARCHAR(255) NOT NULL, body_data_type VARCHAR(255) NULL, "key" VARCHAR(255) NULL, variable VARCHAR(255) NULL, variable_type VARCHAR(255) NULL ); CREATE INDEX project__integration_extract_value__integration_id ON project__integration_extract_value(integration_id); CREATE TABLE project__integration_matcher ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(255) NOT NULL, integration_id INTEGER NOT NULL REFERENCES project__integration(id) ON DELETE CASCADE, match_type VARCHAR(255) NULL, method VARCHAR(255) NULL, body_data_type VARCHAR(255) NULL, "key" VARCHAR(510) NULL, value VARCHAR(510) NULL ); CREATE INDEX project__integration_matcher__integration_id ON project__integration_matcher(integration_id); CREATE TABLE project__schedule ( id INTEGER PRIMARY KEY AUTOINCREMENT, template_id INTEGER NOT NULL REFERENCES project__template(id) ON DELETE CASCADE, project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE, cron_format VARCHAR(255) NOT NULL, repository_id INTEGER REFERENCES project__repository(id), last_commit_hash VARCHAR(64) NULL, name VARCHAR(100) NOT NULL DEFAULT '', active INTEGER NOT NULL DEFAULT 1 ); CREATE INDEX project__schedule__project_id ON project__schedule(project_id); CREATE INDEX project__schedule__repository_id ON project__schedule(repository_id); CREATE INDEX project__schedule__template_id ON project__schedule(template_id); CREATE TABLE project__template_vault ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE, template_id INTEGER NOT NULL REFERENCES project__template(id) ON DELETE CASCADE, vault_key_id INTEGER REFERENCES access_key(id) ON DELETE CASCADE, name VARCHAR(255) NULL, type VARCHAR(20) NOT NULL DEFAULT 'password', script TEXT NULL, UNIQUE(template_id, vault_key_id, name) ); CREATE INDEX project__template_vault__project_id ON project__template_vault(project_id); CREATE INDEX project__template_vault__vault_key_id ON project__template_vault(vault_key_id); CREATE TABLE project__terraform_inventory_alias ( alias VARCHAR(100) PRIMARY KEY, project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE, inventory_id INTEGER NOT NULL REFERENCES project__inventory(id) ON DELETE CASCADE, auth_key_id INTEGER NOT NULL REFERENCES access_key(id) ); CREATE INDEX project__terraform_inventory_alias__auth_key_id ON project__terraform_inventory_alias(auth_key_id); CREATE INDEX project__terraform_inventory_alias__inventory_id ON project__terraform_inventory_alias(inventory_id); CREATE INDEX project__terraform_inventory_alias__project_id ON project__terraform_inventory_alias(project_id); CREATE TABLE project__user ( project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES user(id) ON DELETE CASCADE, role VARCHAR(50) NOT NULL DEFAULT 'manager', UNIQUE(project_id, user_id) ); CREATE INDEX project__user__user_id ON project__user(user_id); CREATE TABLE task ( id INTEGER PRIMARY KEY AUTOINCREMENT, template_id INTEGER NOT NULL REFERENCES project__template(id) ON DELETE CASCADE, status VARCHAR(255) NOT NULL, playbook VARCHAR(255) NOT NULL, environment TEXT NULL, created DATETIME NULL, start DATETIME NULL, end DATETIME NULL, user_id INTEGER REFERENCES user(id), project_id INTEGER REFERENCES project(id), message VARCHAR(250) NOT NULL DEFAULT '', version VARCHAR(20) NULL, commit_hash VARCHAR(64) NULL, commit_message VARCHAR(100) NOT NULL DEFAULT '', build_task_id INTEGER REFERENCES task(id) ON DELETE SET NULL, arguments TEXT NULL, inventory_id INTEGER REFERENCES project__inventory(id) ON DELETE SET NULL, integration_id INTEGER REFERENCES project__integration(id) ON DELETE SET NULL, schedule_id INTEGER REFERENCES project__schedule(id) ON DELETE SET NULL, git_branch VARCHAR(255) NULL, params TEXT NULL ); CREATE INDEX task__integration_id ON task(integration_id); CREATE INDEX task__inventory_id ON task(inventory_id); CREATE INDEX task__project_id ON task(project_id); CREATE INDEX task__schedule_id ON task(schedule_id); CREATE INDEX task__template_id ON task(template_id); CREATE TABLE project__terraform_inventory_state ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE, inventory_id INTEGER NOT NULL REFERENCES project__inventory(id) ON DELETE CASCADE, state TEXT NOT NULL, created DATETIME NOT NULL, task_id INTEGER REFERENCES task(id) ON DELETE SET NULL ); CREATE INDEX project__terraform_inventory_state__inventory_id ON project__terraform_inventory_state(inventory_id); CREATE INDEX project__terraform_inventory_state__project_id ON project__terraform_inventory_state(project_id); CREATE INDEX project__terraform_inventory_state__task_id ON project__terraform_inventory_state(task_id); CREATE TABLE task__output ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL REFERENCES task(id) ON DELETE CASCADE, time DATETIME NOT NULL, output TEXT NOT NULL ); CREATE INDEX task__output__task__output_time_idx ON task__output(time); CREATE INDEX task__output__task_id ON task__output(task_id); CREATE TABLE task__stage ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL REFERENCES task(id) ON DELETE CASCADE, start DATETIME NULL, start_output_id INTEGER REFERENCES task__output(id) ON DELETE SET NULL, end DATETIME NULL, end_output_id INTEGER REFERENCES task__output(id) ON DELETE SET NULL, type VARCHAR(100) NULL ); CREATE INDEX task__stage__end_output_id ON task__stage(end_output_id); CREATE INDEX task__stage__start_output_id ON task__stage(start_output_id); CREATE INDEX task__stage__task_id ON task__stage(task_id); CREATE TABLE task__stage_result ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL REFERENCES task(id) ON DELETE CASCADE, stage_id INTEGER NOT NULL REFERENCES task__stage(id) ON DELETE CASCADE, json TEXT NULL ); CREATE INDEX task__stage_result__stage_id ON task__stage_result(stage_id); CREATE INDEX task__stage_result__task_id ON task__stage_result(task_id); CREATE TABLE user__token ( id VARCHAR(44) NOT NULL PRIMARY KEY, created DATETIME NOT NULL, expired INTEGER NOT NULL DEFAULT 0, user_id INTEGER NOT NULL REFERENCES user(id) ON DELETE CASCADE ); CREATE INDEX user__token__user_id ON user__token(user_id); CREATE TABLE user__totp ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE REFERENCES user(id) ON DELETE CASCADE, url VARCHAR(250) NOT NULL, recovery_hash VARCHAR(250) NOT NULL, created DATETIME NOT NULL ); ================================================ FILE: db/sql/migrations/v2.15.2.err.sql ================================================ drop table user__email_otp; ================================================ FILE: db/sql/migrations/v2.15.2.sql ================================================ create table user__email_otp( `id` integer primary key autoincrement, `user_id` int NOT NULL, `code` varchar(250) NOT NULL, `created` datetime NOT NULL, unique (`code`), foreign key (`user_id`) references `user`(`id`) on delete cascade ); ================================================ FILE: db/sql/migrations/v2.15.3.err.sql ================================================ drop table task__ansible_error; drop table task__ansible_host; ================================================ FILE: db/sql/migrations/v2.15.3.sql ================================================ create table task__ansible_error( `id` integer primary key autoincrement, `task_id` int NOT NULL, `project_id` int NOT NULL, `task` varchar(250) NOT NULL, `error` varchar(1000) NOT NULL, foreign key (`project_id`) references `project`(`id`) on delete cascade, foreign key (`task_id`) references `task`(`id`) on delete cascade ); create table task__ansible_host( `id` integer primary key autoincrement, `task_id` int NOT NULL, `project_id` int NOT NULL, `host` varchar(250) NOT NULL, `failed` int NOT NULL, `ignored` int NOT NULL, `changed` int NOT NULL, `ok` int NOT NULL, `rescued` int NOT NULL, `skipped` int NOT NULL, `unreachable` int NOT NULL, foreign key (`project_id`) references `project`(`id`) on delete cascade, foreign key (`task_id`) references `task`(`id`) on delete cascade ); ================================================ FILE: db/sql/migrations/v2.15.4.err.sql ================================================ alter table `task__ansible_error` drop `host`; ================================================ FILE: db/sql/migrations/v2.15.4.sql ================================================ alter table `task__ansible_error` add `host` varchar(250); ================================================ FILE: db/sql/migrations/v2.16.0.err.sql ================================================ alter table `project` drop `default_secret_storage_id`; alter table `project__environment` drop `secret_storage_id`; alter table `project__environment` drop `secret_storage_key_prefix`; alter table `access_key` drop `storage_id`; alter table `access_key` drop `source_storage_id`; alter table `access_key` drop `source_storage_key`; drop table project__secret_storage; alter table `access_key` drop `owner`; alter table `access_key` drop `plain`; ================================================ FILE: db/sql/migrations/v2.16.0.sql ================================================ alter table `access_key` add `owner` varchar(20) default '' not null; alter table `access_key` add `plain` text; update access_key set `owner` = 'variable' where environment_id is not null and name like 'var.%'; update access_key set `owner` = 'environment' where environment_id is not null and name like 'env.%'; create table project__secret_storage ( id integer primary key autoincrement, project_id int not null, name varchar(100) not null, type varchar(20) not null, params text, readonly boolean not null default false, foreign key (`project_id`) references project(`id`) on delete cascade ); alter table `access_key` add `storage_id` int null references `project__secret_storage`(`id`) on delete cascade; alter table `access_key` add `source_storage_id` int null references `project__secret_storage`(`id`); alter table `access_key` add `source_storage_key` varchar(1000); alter table `project__environment` add `secret_storage_id` int null references `project__secret_storage`(`id`); alter table `project__environment` add `secret_storage_key_prefix` varchar(1000); alter table `project` add `default_secret_storage_id` int null references `project__secret_storage`(`id`); ================================================ FILE: db/sql/migrations/v2.16.1.err.sql ================================================ alter table project__template drop column allow_parallel_tasks; ================================================ FILE: db/sql/migrations/v2.16.1.sql ================================================ alter table project__template add allow_parallel_tasks boolean not null default false; ================================================ FILE: db/sql/migrations/v2.16.2.err.sql ================================================ alter table project__schedule drop task_params_id; alter table project__integration drop task_params_id; alter table project__integration add task_params TEXT; drop table project__task_params; ================================================ FILE: db/sql/migrations/v2.16.2.sql ================================================ create table project__task_params ( id integer primary key autoincrement, environment TEXT, project_id int not null, arguments TEXT, inventory_id int, git_branch varchar(255), params TEXT, version varchar(20), message varchar(250), foreign key (`project_id`) references project (`id`) on delete cascade, foreign key (`inventory_id`) references project__inventory (`id`) on delete cascade ); alter table project__integration drop task_params; alter table project__schedule add task_params_id int references `project__task_params`(`id`); alter table project__integration add task_params_id int references `project__task_params`(`id`); ================================================ FILE: db/sql/migrations/v2.16.3.err.sql ================================================ drop table project__invite; ================================================ FILE: db/sql/migrations/v2.16.3.sql ================================================ create table project__invite ( `id` integer primary key autoincrement, `project_id` int not null, `user_id` int null, `email` varchar(255) null, `role` varchar(50) not null, `status` varchar(50) not null default 'pending', `token` varchar(255) not null, `inviter_user_id` int not null, `created` datetime not null, `expires_at` datetime null, `accepted_at` datetime null, foreign key (`project_id`) references project (`id`) on delete cascade, foreign key (`user_id`) references `user` (`id`) on delete cascade, foreign key (`inviter_user_id`) references `user` (`id`) on delete cascade, unique (`token`), unique (`project_id`, `user_id`), unique (`project_id`, `email`) ); ================================================ FILE: db/sql/migrations/v2.16.50.err.sql ================================================ {{if .Sqlite}} {{else}} alter table `project__terraform_inventory_state` change `state` `state` text not null; {{end}} ================================================ FILE: db/sql/migrations/v2.16.50.sql ================================================ {{if .Sqlite}} {{else}} alter table `project__terraform_inventory_state` change `state` `state` longtext not null; {{end}} ================================================ FILE: db/sql/migrations/v2.16.8.err.sql ================================================ alter table `task__stage` add `start_output_id` bigint null references `task__output`(`id`); alter table `task__stage` add `end_output_id` bigint null references `task__output`(`id`); {{if .Sqlite}} create index if not exists task__stage__start_output_id on `task__stage`(`start_output_id`); create index if not exists task__stage__end_output_id on `task__stage`(`end_output_id`); {{else if .Mysql}} alter table `task__output` drop foreign key if exists `task__output_ibfk_2`; {{end}} alter table `task__output` drop column `stage_id`; ================================================ FILE: db/sql/migrations/v2.16.8.sql ================================================ alter table `task__output` add `stage_id` int null references `task__stage`(`id`); {{if .Sqlite}} drop index if exists task__stage__start_output_id; drop index if exists task__stage__end_output_id; {{else if .Mysql}} alter table `task__stage` drop foreign key if exists `task__stage_ibfk_2`; alter table `task__stage` drop foreign key if exists `task__stage_ibfk_3`; {{end}} alter table `task__stage` drop column `start_output_id`; alter table `task__stage` drop column `end_output_id`; ================================================ FILE: db/sql/migrations/v2.17.0.err.sql ================================================ alter table project__view drop column `type`; alter table project__view drop column `hidden`; alter table project__view drop column `filter`; alter table project__view drop column `sort_column`; alter table project__view drop column `sort_reverse`; ================================================ FILE: db/sql/migrations/v2.17.0.sql ================================================ -- Add hidden and type fields to project__view table alter table project__view add column `hidden` boolean not null default false; alter table project__view add column `type` varchar(20) not null default ''; alter table project__view add column `filter` varchar(1000); alter table project__view add column `sort_column` varchar(100); alter table project__view add column `sort_reverse` boolean not null default false; -- Create All view with position -1 for each existing project insert into project__view (project_id, title, position, hidden, type) select p.id as project_id, 'All' as title, -1 as position, false as hidden, 'all' as type from project p where not exists ( select 1 from project__view pv where pv.project_id = p.id and pv.type = 'all' ); ================================================ FILE: db/sql/migrations/v2.17.1.err.sql ================================================ drop table project__template_role; drop table role; ================================================ FILE: db/sql/migrations/v2.17.1.sql ================================================ create table role ( `slug` varchar(100) primary key not null, `name` varchar(100) not null, `permissions` bigint not null default 0, `project_id` int, foreign key (`project_id`) references project (`id`) on delete cascade ); create table project__template_role ( `id` integer primary key autoincrement, `template_id` int not null, `role_slug` varchar(100) not null, `project_id` int not null, `permissions` bigint not null default 0, foreign key (`template_id`) references project__template (`id`) on delete cascade, foreign key (`role_slug`) references role (`slug`) on delete cascade, foreign key (`project_id`) references project (`id`) on delete cascade, unique (`template_id`, `role_slug`) ); ================================================ FILE: db/sql/migrations/v2.17.15.err.sql ================================================ {{if .Postgresql}} drop index if exists task__output_task_id_idx; drop index if exists task_template_id_idx; drop index if exists task_project_id_idx; {{end}} alter table access_key drop column `source_storage_type`; ================================================ FILE: db/sql/migrations/v2.17.15.sql ================================================ alter table `access_key` add column `source_storage_type` varchar(10); update `access_key` set source_storage_type = 'vault' where source_storage_id is not null; update `access_key` set source_storage_type = 'env' where source_storage_id is null and source_storage_key is not null; {{if .Postgresql}} create index if not exists task__output_task_id_idx on task__output (task_id); create index if not exists task_template_id_idx on task (template_id); create index if not exists task_project_id_idx on task (project_id); {{end}} ================================================ FILE: db/sql/migrations/v2.17.2.err.sql ================================================ alter table project__schedule drop column run_at; alter table project__schedule drop column `type`; alter table project__schedule drop column `delete_after_run`; ================================================ FILE: db/sql/migrations/v2.17.2.sql ================================================ alter table `project__schedule` add column `run_at` datetime null; alter table `project__schedule` add column `type` varchar(20) not null default ''; alter table `project__schedule` add column `delete_after_run` boolean not null default false; ================================================ FILE: db/sql/migrations/v2.2.1.sql ================================================ alter table task__output rename to task__output_backup; create table task__output ( id integer primary key autoincrement, task_id int not null, task varchar(255) not null, time datetime not null, output longtext not null, foreign key (`task_id`) references task(`id`) on delete cascade ); insert into task__output(task_id, task, time, output) select * from task__output_backup; drop table task__output_backup; ================================================ FILE: db/sql/migrations/v2.3.0.sql ================================================ ALTER TABLE `user` ADD `alert` BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE `project` ADD `alert` BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE `user` ADD `external` BOOLEAN NOT NULL DEFAULT FALSE; ================================================ FILE: db/sql/migrations/v2.3.1.sql ================================================ alter table session rename to session_backup; create table session ( id integer primary key autoincrement, user_id int not null, created datetime not null, last_active datetime not null, ip varchar(39) default '' not null, user_agent text not null, expired boolean default false not null ); insert into session select * from session_backup; drop table session_backup; create index expired on session (expired); create index user_id on session (user_id); ================================================ FILE: db/sql/migrations/v2.3.2.sql ================================================ -- ALTER TABLE `user__token` CHANGE COLUMN `id` `id` VARCHAR(44) NOT NULL; alter table user__token rename to user__token_backup; create table user__token ( id varchar(44) not null primary key, created datetime not null, expired boolean default false not null, user_id int not null, foreign key (`user_id`) references `user`(`id`) on delete cascade ); insert into user__token select * from user__token_backup; drop table user__token_backup; ================================================ FILE: db/sql/migrations/v2.4.0.sql ================================================ ALTER TABLE project ADD alert_chat varchar(10) DEFAULT ''; ================================================ FILE: db/sql/migrations/v2.5.0.sql ================================================ ALTER TABLE `user` ADD `admin` BOOLEAN NOT NULL DEFAULT TRUE; ================================================ FILE: db/sql/migrations/v2.5.2.sql ================================================ alter table `task` add `arguments` text null; ================================================ FILE: db/sql/migrations/v2.7.1.sql ================================================ alter table `task` add `project_id` int null references project(`id`); ================================================ FILE: db/sql/migrations/v2.7.10.sql ================================================ alter table `access_key` drop column `key`; ================================================ FILE: db/sql/migrations/v2.7.12.sql ================================================ alter table `project__inventory` add `become_key_id` int references access_key(`id`); alter table `project__template` add `vault_key_id` int references access_key(`id`); ================================================ FILE: db/sql/migrations/v2.7.13.sql ================================================ drop table project__template_schedule; create table `project__schedule` ( `id` integer primary key autoincrement, `template_id` int references project__template (`id`) on delete cascade, `project_id` int not null references project (`id`) on delete cascade, `cron_format` varchar(255) not null ); ================================================ FILE: db/sql/migrations/v2.7.4.sql ================================================ alter table `event` add `user_id` int null references `user`(`id`); ================================================ FILE: db/sql/migrations/v2.7.6.sql ================================================ update `task` set project_id = (select project_id from project__template where project__template.id = `task`.template_id) where project_id is null; ================================================ FILE: db/sql/migrations/v2.7.8.sql ================================================ ALTER TABLE project__inventory DROP FOREIGN KEY IF EXISTS project__inventory_ibfk_2; alter table `project__inventory` drop column `key_id`; ALTER TABLE project__template DROP FOREIGN KEY IF EXISTS project__template_ibfk_2; alter table `project__template` drop column `ssh_key_id`; ================================================ FILE: db/sql/migrations/v2.7.9.sql ================================================ alter table `project__template` add `description` longtext; alter table `project__template` add `removed` boolean not null default false; ================================================ FILE: db/sql/migrations/v2.8.0.sql ================================================ alter table project__template add `type` varchar(10) not null default ''; alter table `task` add `message` varchar(250) not null default ''; alter table project__template add start_version varchar(20); alter table project__template add build_template_id int references project__template(id); alter table `task` add `version` varchar(20); alter table `task` add commit_hash varchar(40); alter table `task` add commit_message varchar(100) not null default ''; ================================================ FILE: db/sql/migrations/v2.8.1.sql ================================================ alter table `task` add build_task_id int references `task`(id); ================================================ FILE: db/sql/migrations/v2.8.20.sql ================================================ alter table `event` rename to `event_backup_5784568`; create table `event` ( `id` integer primary key autoincrement, `project_id` int, `object_id` int, `object_type` varchar(20) DEFAULT '', `description` text, `created` datetime NOT NULL, `user_id` int, foreign key (`project_id`) references `project` (`id`) on delete cascade, foreign key (`user_id`) references `user` (`id`) on delete set null ); ================================================ FILE: db/sql/migrations/v2.8.25.sql ================================================ alter table `project__template` add survey_vars longtext; alter table `project__template` add autorun boolean default false; alter table `project__schedule` add repository_id int null references project__repository(`id`) on delete set null; alter table `project__schedule` add last_commit_hash varchar(40); ================================================ FILE: db/sql/migrations/v2.8.26.sql ================================================ alter table `project__repository` add git_branch varchar(255) not null default ''; ================================================ FILE: db/sql/migrations/v2.8.36.sql ================================================ alter table `project__template` add allow_override_args_in_task bool not null default false; alter table `task` add arguments text; alter table `project__template` drop column `override_args`; ================================================ FILE: db/sql/migrations/v2.8.38.sql ================================================ delete from project__schedule where template_id is null; delete from project__schedule where (select count(*) from project__template where project__template.id = project__schedule.template_id) = 0; delete from project__schedule where (select count(*) from project where project.id = project__schedule.project_id) = 0; update project__schedule set repository_id = null where repository_id is not null and (select count(*) from project__repository where project__repository.id = project__schedule.repository_id) = 0; alter table `project__schedule` rename to `project__schedule_backup_8436583`; create table project__schedule ( id integer primary key autoincrement, template_id int not null, project_id int not null, cron_format varchar(255) not null, repository_id int null, last_commit_hash varchar(40) null, foreign key (`template_id`) references project__template(`id`) on delete cascade, foreign key (`project_id`) references project(`id`) on delete cascade, foreign key (`repository_id`) references project__repository(`id`) ); insert into project__schedule select * from project__schedule_backup_8436583; drop table project__schedule_backup_8436583; ================================================ FILE: db/sql/migrations/v2.8.39.sql ================================================ alter table `task` add constraint `task_build_task_id_fk_y38rt` foreign key (`build_task_id`) references `task` (`id`) on delete set null; create table `project__template_backup_385025846` ( id int primary key, removed boolean default false ); insert into `project__template_backup_385025846` select `id`, `removed` from `project__template`; update `project__template` set build_template_id = null where (select t.`removed` from `project__template_backup_385025846` t where t.`id` = `build_template_id`) = true; drop table `project__template_backup_385025846`; delete from `project__template` where `removed` = true; alter table `project__template` drop column `removed`; ================================================ FILE: db/sql/migrations/v2.8.40.sql ================================================ alter table `project` change `alert_chat` `alert_chat` varchar(30); alter table `project__template` change `alias` `name` varchar(100) not null; alter table `project__inventory` drop column `removed`; alter table `project__environment` drop column `removed`; alter table `access_key` drop column `removed`; alter table `project__repository` drop column `removed`; ================================================ FILE: db/sql/migrations/v2.8.42.sql ================================================ -- see migration_2_8_42.go ================================================ FILE: db/sql/migrations/v2.8.51.sql ================================================ alter table `project` add column `max_parallel_tasks` int not null default 0; alter table `project__template` add column `suppress_success_alerts` bool not null default false; ================================================ FILE: db/sql/migrations/v2.8.57.sql ================================================ alter table `project__environment` add column `env` longtext; alter table `task` add column `hosts_limit` varchar(255) not null default ''; ================================================ FILE: db/sql/migrations/v2.8.58.sql ================================================ ALTER TABLE task ADD diff boolean NOT NULL DEFAULT false; ================================================ FILE: db/sql/migrations/v2.8.7.sql ================================================ alter table `task` drop column `arguments`; ================================================ FILE: db/sql/migrations/v2.8.8.sql ================================================ create table `project__view` ( `id` integer primary key autoincrement, `title` varchar(100) not null, `project_id` int not null, `position` int not null, foreign key (`project_id`) references project(`id`) on delete cascade ); alter table `project__template` add view_id int references `project__view`(id) on delete set null; ================================================ FILE: db/sql/migrations/v2.8.91.sql ================================================ ALTER TABLE project__user ADD `role` varchar(50) NOT NULL DEFAULT 'manager'; UPDATE project__user SET `role` = 'owner' WHERE `admin`; ALTER TABLE project__user DROP COLUMN `admin`; ================================================ FILE: db/sql/migrations/v2.9.100.sql ================================================ alter table `project__inventory` add `repository_id` int null references project__repository(`id`) on delete set null; ================================================ FILE: db/sql/migrations/v2.9.46.sql ================================================ ALTER TABLE project__template ADD `app` varchar(50) NOT NULL DEFAULT ''; ================================================ FILE: db/sql/migrations/v2.9.6.sql ================================================ create table runner ( id integer primary key autoincrement, project_id int, token varchar(255) not null, webhook varchar(1000) not null default '', max_parallel_tasks int not null default 0, foreign key (`project_id`) references project(`id`) on delete cascade ); ================================================ FILE: db/sql/migrations/v2.9.60.sql ================================================ create table project__integration ( `id` integer primary key autoincrement, `name` varchar(255) not null, `project_id` int not null, `template_id` int not null, `auth_method` varchar(15) not null default 'none', `auth_secret_id` int, `auth_header` varchar(255), foreign key (`project_id`) references project(`id`) on delete cascade, foreign key (`template_id`) references project__template(`id`) on delete cascade, foreign key (`auth_secret_id`) references access_key(`id`) on delete set null ); create table project__integration_extractor ( `id` integer primary key autoincrement, `name` varchar(255) not null, `integration_id` int not null, foreign key (`integration_id`) references project__integration(`id`) on delete cascade ); create table project__integration_extract_value ( `id` integer primary key autoincrement, `name` varchar(255) not null, `extractor_id` int not null, `value_source` varchar(255) not null, `body_data_type` varchar(255) null, `key` varchar(255) null, `variable` varchar(255) null, foreign key (`extractor_id`) references project__integration_extractor(`id`) on delete cascade ); create table project__integration_matcher ( `id` integer primary key autoincrement, `name` varchar(255) not null, `extractor_id` int not null, `match_type` varchar(255) null, `method` varchar(255) null, `body_data_type` varchar(255) null, `key` varchar(510) null, `value` varchar(510) null, foreign key (`extractor_id`) references project__integration_extractor(`id`) on delete cascade ); ================================================ FILE: db/sql/migrations/v2.9.61.sql ================================================ drop table project__integration_matcher; drop table project__integration_extract_value; drop table project__integration_extractor; drop table project__integration; create table project__integration ( `id` integer primary key autoincrement, `name` varchar(255) not null, `project_id` int not null, `template_id` int not null, `auth_method` varchar(15) not null default 'none', `auth_secret_id` int, `auth_header` varchar(255), `searchable` bool not null default false, foreign key (`project_id`) references project(`id`) on delete cascade, foreign key (`template_id`) references project__template(`id`) on delete cascade, foreign key (`auth_secret_id`) references access_key(`id`) on delete set null ); create table project__integration_extract_value ( `id` integer primary key autoincrement, `name` varchar(255) not null, `integration_id` int not null, `value_source` varchar(255) not null, `body_data_type` varchar(255) null, `key` varchar(255) null, `variable` varchar(255) null, foreign key (`integration_id`) references project__integration(`id`) on delete cascade ); create table project__integration_matcher ( `id` integer primary key autoincrement, `name` varchar(255) not null, `integration_id` int not null, `match_type` varchar(255) null, `method` varchar(255) null, `body_data_type` varchar(255) null, `key` varchar(510) null, `value` varchar(510) null, foreign key (`integration_id`) references project__integration(`id`) on delete cascade ); create table project__integration_alias ( `id` integer primary key autoincrement, `alias` varchar(50) not null, `project_id` int not null, `integration_id` int, foreign key (`project_id`) references project(`id`) on delete cascade, foreign key (`integration_id`) references project__integration(`id`) on delete cascade, unique (`alias`) ); ================================================ FILE: db/sql/migrations/v2.9.62.sql ================================================ alter table project add `type` varchar(20) default ''; alter table task add `inventory_id` int null references project__inventory(`id`) on delete set null; alter table project__inventory add `holder_id` int null references project__template(`id`) on delete set null; create table `option` ( `key` varchar(255) primary key not null, `value` varchar(255) not null ); ================================================ FILE: db/sql/migrations/v2.9.70.sql ================================================ alter table `project__template` change `inventory_id` `inventory_id` int; alter table `option` change `value` `value` varchar(1000); ================================================ FILE: db/sql/migrations/v2.9.97.sql ================================================ alter table `task` add `integration_id` int null references project__integration(`id`) on delete set null; ================================================ FILE: db/sql/option.go ================================================ package sql import ( "errors" "github.com/Masterminds/squirrel" "github.com/semaphoreui/semaphore/db" ) func (d *SqlDb) SetOption(key string, value string) error { _, err := d.getOption(key) if errors.Is(err, db.ErrNotFound) { _, err = d.insert( "", // don't provide because it is not auto-generated "insert into `option` (`key`, `value`) values (?, ?)", key, value) } else if err == nil { _, err = d.exec("update `option` set `value`=? where `key`=?", value, key) } return err } func (d *SqlDb) GetOptions(params db.RetrieveQueryParams) (res map[string]string, err error) { var options []db.Option res = make(map[string]string) if params.Filter != "" { err = db.ValidateOptionKey(params.Filter) if err != nil { return } } err = d.getObjects(0, db.OptionProps, params, func(q squirrel.SelectBuilder) squirrel.SelectBuilder { if params.Filter == "" { return q } return q.Where("`key` = ? OR `key` LIKE ?", params.Filter, params.Filter+".%") }, &options) if err != nil { return } for _, opt := range options { res[opt.Key] = opt.Value } return } func (d *SqlDb) getOption(key string) (value string, err error) { q := squirrel.Select("*"). From("`"+db.OptionProps.TableName+"`"). Where("`key`=?", key) query, args, err := q.ToSql() if err != nil { return } var opt db.Option err = d.selectOne(&opt, query, args...) value = opt.Value return } func (d *SqlDb) GetOption(key string) (value string, err error) { value, err = d.getOption(key) if errors.Is(err, db.ErrNotFound) { err = nil } return } func (d *SqlDb) DeleteOption(key string) (err error) { err = db.ValidateOptionKey(key) if err != nil { return } err = d.deleteObject(0, db.OptionProps, key) return } func (d *SqlDb) DeleteOptions(filter string) (err error) { err = db.ValidateOptionKey(filter) if err != nil { return } _, err = d.exec("DELETE FROM `option` WHERE `key` = ? OR `key` LIKE ?", filter, filter+".%") return } ================================================ FILE: db/sql/project.go ================================================ package sql import ( "github.com/Masterminds/squirrel" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/tz" ) func (d *SqlDb) CreateProject(project db.Project) (newProject db.Project, err error) { project.Created = tz.Now() insertId, err := d.insert( "id", "insert into project(name, created, type, alert, alert_chat, max_parallel_tasks) values (?, ?, ?, ?, ?, ?)", project.Name, project.Created, project.Type, project.Alert, project.AlertChat, project.MaxParallelTasks) if err != nil { return } newProject = project newProject.ID = insertId return } func (d *SqlDb) GetAllProjects() (projects []db.Project, err error) { query, args, err := squirrel.Select("p.*"). From("project as p"). OrderBy("p.name"). Limit(200). ToSql() if err != nil { return } _, err = d.selectAll(&projects, query, args...) return } func (d *SqlDb) GetProjects(userID int) (projects []db.Project, err error) { query, args, err := squirrel.Select("p.*"). From("project as p"). Join("project__user as pu on pu.project_id=p.id"). Where("pu.user_id=?", userID). OrderBy("p.name"). Limit(200). ToSql() if err != nil { return } _, err = d.selectAll(&projects, query, args...) return } func (d *SqlDb) GetProject(projectID int) (project db.Project, err error) { query, args, err := squirrel.Select("p.*"). From("project as p"). Where("p.id=?", projectID). ToSql() if err != nil { return } err = d.selectOne(&project, query, args...) return } func (d *SqlDb) DeleteProject(projectID int) error { //tpls, err := d.GetTemplates(projectID, db.TemplateFilter{}, db.RetrieveQueryParams{}) // //if err != nil { // return err //} // TODO: sort projects tx, err := d.Sql().Begin() if err != nil { return err } statements := []string{ "update project__template set build_template_id = null where project_id=?", "delete from project__template where project_id=?", "delete from project__user where project_id=?", "delete from project__repository where project_id=?", "delete from project__inventory where project_id=?", "delete from access_key where project_id=?", "delete from project where id=?", } for _, statement := range statements { _, err = tx.Exec(d.PrepareQuery(statement), projectID) if err != nil { _ = tx.Rollback() return err } } return tx.Commit() } func (d *SqlDb) UpdateProject(project db.Project) error { _, err := d.exec( "update project set name=?, alert=?, alert_chat=?, max_parallel_tasks=? where id=?", project.Name, project.Alert, project.AlertChat, project.MaxParallelTasks, project.ID) return err } ================================================ FILE: db/sql/project_invite.go ================================================ package sql import ( "database/sql" "github.com/Masterminds/squirrel" "github.com/semaphoreui/semaphore/db" ) func (d *SqlDb) GetProjectInvites(projectID int, params db.RetrieveQueryParams) (invites []db.ProjectInviteWithUser, err error) { pp, err := params.Validate(db.ProjectInviteProps) if err != nil { return } invites = make([]db.ProjectInviteWithUser, 0) q := squirrel.Select("pi.*"). Column("ib.name as inviter_user_id_name"). Column("ib.username as inviter_username"). Column("ib.email as inviter_user_id_email"). Column("u.name as user_name"). Column("u.username as user_username"). Column("u.email as user_email"). From("project__invite as pi"). LeftJoin("`user` as ib on pi.inviter_user_id=ib.id"). LeftJoin("`user` as u on pi.user_id=u.id"). Where("pi.project_id=?", projectID) sortDirection := "ASC" if pp.SortInverted { sortDirection = "DESC" } switch pp.SortBy { case "created", "status", "role": q = q.OrderBy("pi." + pp.SortBy + " " + sortDirection) default: q = q.OrderBy("pi.created " + sortDirection) } query, args, err := q.ToSql() if err != nil { return } rows, err := d.Sql().Query(d.PrepareQuery(query), args...) if err != nil { return } defer rows.Close() for rows.Next() { var invite db.ProjectInviteWithUser var invitedByName, invitedByUsername, invitedByEmail sql.NullString var userName, userUsername, userEmail sql.NullString err = rows.Scan( &invite.ID, &invite.ProjectID, &invite.UserID, &invite.Email, &invite.Role, &invite.Status, &invite.Token, &invite.InviterUserID, &invite.Created, &invite.ExpiresAt, &invite.AcceptedAt, &invitedByName, &invitedByUsername, &invitedByEmail, &userName, &userUsername, &userEmail, ) if err != nil { return } // Set invited by user info invite.InvitedByUser = &db.User{ ID: invite.InviterUserID, Name: invitedByName.String, Username: invitedByUsername.String, Email: invitedByEmail.String, } // Set user info if user exists if invite.UserID != nil { invite.User = &db.User{ ID: *invite.UserID, Name: userName.String, Username: userUsername.String, Email: userEmail.String, } } invites = append(invites, invite) } return } func (d *SqlDb) CreateProjectInvite(invite db.ProjectInvite) (newInvite db.ProjectInvite, err error) { insertID, err := d.insert( "id", "insert into project__invite (project_id, user_id, email, role, status, token, inviter_user_id, created, expires_at) values (?, ?, ?, ?, ?, ?, ?, ?, ?)", invite.ProjectID, invite.UserID, invite.Email, invite.Role, invite.Status, invite.Token, invite.InviterUserID, invite.Created, invite.ExpiresAt) if err != nil { return } newInvite = invite newInvite.ID = insertID return } func (d *SqlDb) GetProjectInvite(projectID int, inviteID int) (invite db.ProjectInvite, err error) { err = d.selectOne(&invite, "select * from project__invite where project_id=? and id=?", projectID, inviteID) return } func (d *SqlDb) GetProjectInviteByToken(token string) (invite db.ProjectInvite, err error) { err = d.selectOne(&invite, "select * from project__invite where token=?", token) return } func (d *SqlDb) UpdateProjectInvite(invite db.ProjectInvite) error { _, err := d.exec( "update project__invite set status=?, accepted_at=? where id=?", invite.Status, invite.AcceptedAt, invite.ID) return err } func (d *SqlDb) DeleteProjectInvite(projectID int, inviteID int) error { _, err := d.exec( "delete from project__invite where project_id=? and id=?", projectID, inviteID) return err } ================================================ FILE: db/sql/repository.go ================================================ package sql import ( "github.com/Masterminds/squirrel" "github.com/semaphoreui/semaphore/db" ) func (d *SqlDb) GetRepository(projectID int, repositoryID int) (db.Repository, error) { var repository db.Repository err := d.getObject(projectID, db.RepositoryProps, repositoryID, &repository) if err != nil { return repository, err } repository.SSHKey, err = d.GetAccessKey(projectID, repository.SSHKeyID) return repository, err } func (d *SqlDb) GetRepositoryRefs(projectID int, repositoryID int) (db.ObjectReferrers, error) { return d.getObjectRefs(projectID, db.RepositoryProps, repositoryID) } func (d *SqlDb) GetRepositories(projectID int, params db.RetrieveQueryParams) (repositories []db.Repository, err error) { q := squirrel.Select("*"). From("project__repository pr") order := "ASC" if params.SortInverted { order = "DESC" } switch params.SortBy { case "name", "git_url": q = q.Where("pr.project_id=?", projectID). OrderBy("pr." + params.SortBy + " " + order) case "ssh_key": q = q.LeftJoin("access_key ak ON (pr.ssh_key_id = ak.id)"). Where("pr.project_id=?", projectID). OrderBy("ak.name " + order) default: q = q.Where("pr.project_id=?", projectID). OrderBy("pr.name " + order) } query, args, err := q.ToSql() if err != nil { return } _, err = d.selectAll(&repositories, query, args...) return } func (d *SqlDb) UpdateRepository(repository db.Repository) error { err := repository.Validate() if err != nil { return err } _, err = d.exec( "update project__repository set name=?, git_url=?, git_branch=?, ssh_key_id=? where id=?", repository.Name, repository.GitURL, repository.GitBranch, repository.SSHKeyID, repository.ID) return err } func (d *SqlDb) CreateRepository(repository db.Repository) (newRepo db.Repository, err error) { err = repository.Validate() if err != nil { return } insertID, err := d.insert( "id", "insert into project__repository(project_id, git_url, git_branch, ssh_key_id, name) values (?, ?, ?, ?, ?)", repository.ProjectID, repository.GitURL, repository.GitBranch, repository.SSHKeyID, repository.Name) if err != nil { return } newRepo = repository newRepo.ID = insertID return } func (d *SqlDb) DeleteRepository(projectID int, repositoryId int) error { return d.deleteObject(projectID, db.RepositoryProps, repositoryId) } ================================================ FILE: db/sql/role.go ================================================ package sql import "github.com/semaphoreui/semaphore/db" func (d *SqlDb) GetGlobalRoleBySlug(slug string) (db.Role, error) { var role db.Role err := d.selectOne(&role, "select * from `role` where slug=? and project_id is null", slug) return role, err } func (d *SqlDb) GetProjectRoles(projectID int) ([]db.Role, error) { var roles []db.Role _, err := d.selectAll(&roles, "select * from `role` where project_id=? order by name", projectID) return roles, err } func (d *SqlDb) GetGlobalRoles() ([]db.Role, error) { var roles []db.Role _, err := d.selectAll(&roles, "select * from `role` where project_id is null order by name") return roles, err } func (d *SqlDb) UpdateRole(role db.Role) error { _, err := d.exec( "update `role` set name=?, permissions=? where slug=?", role.Name, role.Permissions, role.Slug) return err } func (d *SqlDb) CreateRole(role db.Role) (db.Role, error) { _, err := d.insert( "", "insert into `role` (slug, name, permissions, project_id) values (?, ?, ?, ?)", role.Slug, role.Name, role.Permissions, role.ProjectID) if err != nil { return role, err } return role, nil } func (d *SqlDb) DeleteRole(slug string) error { res, err := d.exec("delete from `role` where slug=?", slug) return validateMutationResult(res, err) } func (d *SqlDb) GetProjectRole(projectID int, slug string) (db.Role, error) { var role db.Role err := d.selectOne(&role, "select * from `role` where slug=? and project_id=?", slug, projectID) return role, err } func (d *SqlDb) GetProjectOrGlobalRoleBySlug(projectID int, slug string) (db.Role, error) { var role db.Role err := d.selectOne(&role, "select * from `role` where slug=?", slug) return role, err } ================================================ FILE: db/sql/runner.go ================================================ package sql import ( "fmt" "github.com/Masterminds/squirrel" "github.com/semaphoreui/semaphore/db" ) func validateTag(tag string) error { if tag == "" { return fmt.Errorf("Tag cannot be empty") } return nil } func makePropsNonGlobal(props db.ObjectProps) (res db.ObjectProps) { res = props res.IsGlobal = false return } var runnerProps = makePropsNonGlobal(db.GlobalRunnerProps) func (d *SqlDb) GetRunner(projectID int, runnerID int) (runner db.Runner, err error) { err = d.getObject(projectID, runnerProps, runnerID, &runner) return } func (d *SqlDb) GetRunners(projectID int, activeOnly bool, tag *string) (runners []db.Runner, err error) { if tag != nil { err = validateTag(*tag) if err != nil { return } } err = d.getObjects(projectID, runnerProps, db.RetrieveQueryParams{}, func(builder squirrel.SelectBuilder) squirrel.SelectBuilder { if tag != nil { builder = builder.Where("tag=?", *tag) } if activeOnly { builder = builder.Where("active=?", activeOnly) } return builder }, &runners) return } func (d *SqlDb) DeleteRunner(projectID int, runnerID int) (err error) { err = d.deleteObject(projectID, runnerProps, runnerID) return } func (d *SqlDb) GetRunnerCount() (res int, err error) { query, args, err := squirrel.Select("count(*)"). From("runner"). Where(squirrel.NotEq{"project_id": nil}). ToSql() if err != nil { return } cnt, err := d.Sql().SelectInt(query, args...) res = int(cnt) return } func (d *SqlDb) GetRunnerTags(projectID int) (res []db.RunnerTag, err error) { query, args, err := squirrel.Select("tag"). From("runner as r"). Where(squirrel.Eq{"r.project_id": projectID}). Where(squirrel.NotEq{"r.tag": ""}). ToSql() if err != nil { return } runners := make([]db.Runner, 0) _, err = d.selectAll(&runners, query, args...) res = make([]db.RunnerTag, 0) for _, r := range runners { res = append(res, db.RunnerTag{ Tag: r.Tag, }) } return } ================================================ FILE: db/sql/schedule.go ================================================ package sql import ( "github.com/Masterminds/squirrel" "github.com/semaphoreui/semaphore/db" ) func (d *SqlDb) CreateSchedule(schedule db.Schedule) (newSchedule db.Schedule, err error) { if schedule.TaskParams != nil { params := schedule.TaskParams params.ProjectID = schedule.ProjectID err = d.Sql().Insert(params) if err != nil { return } schedule.TaskParamsID = ¶ms.ID } if schedule.Type == "" { schedule.Type = db.ScheduleTypeCron } insertID, err := d.insert( "id", "insert into project__schedule (project_id, template_id, cron_format, repository_id, `name`, `active`, run_at, `type`, task_params_id, delete_after_run)"+ "values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", schedule.ProjectID, schedule.TemplateID, schedule.CronFormat, schedule.RepositoryID, schedule.Name, schedule.Active, schedule.RunAt, schedule.Type, schedule.TaskParamsID, schedule.DeleteAfterRun) if err != nil { return } newSchedule = schedule newSchedule.ID = insertID return } func (d *SqlDb) SetScheduleLastCommitHash(projectID int, scheduleID int, lastCommentHash string) error { _, err := d.exec("update project__schedule set "+ "last_commit_hash=? "+ "where project_id=? and id=?", lastCommentHash, projectID, scheduleID) return err } func (d *SqlDb) UpdateSchedule(schedule db.Schedule) (err error) { if schedule.TaskParams != nil { var curr db.Schedule err = d.getObject(schedule.ProjectID, db.ScheduleProps, schedule.ID, &curr) if err != nil { return } params := schedule.TaskParams params.ProjectID = schedule.ProjectID if curr.TaskParamsID == nil { err = d.Sql().Insert(params) } else { params.ID = *curr.TaskParamsID _, err = d.Sql().Update(params) } if err != nil { return } schedule.TaskParamsID = ¶ms.ID } if schedule.Type == "" { schedule.Type = db.ScheduleTypeCron } _, err = d.exec("update project__schedule set "+ "cron_format=?, "+ "repository_id=?, "+ "template_id=?, "+ "`name`=?, "+ "`active`=?, "+ "run_at=?, "+ "`type`=?, "+ "last_commit_hash = NULL, "+ "task_params_id=?, "+ "delete_after_run=? "+ "where project_id=? and id=?", schedule.CronFormat, schedule.RepositoryID, schedule.TemplateID, schedule.Name, schedule.Active, schedule.RunAt, schedule.Type, schedule.TaskParamsID, schedule.DeleteAfterRun, schedule.ProjectID, schedule.ID) return } func (d *SqlDb) GetSchedule(projectID int, scheduleID int) (schedule db.Schedule, err error) { err = d.selectOne( &schedule, "select * from project__schedule where project_id=? and id=?", projectID, scheduleID) if err != nil { return } if schedule.TaskParamsID != nil { var taskParams db.TaskParams err = d.getObject(projectID, db.TaskParamsProps, *schedule.TaskParamsID, &taskParams) if err != nil { return } schedule.TaskParams = &taskParams } return } func (d *SqlDb) DeleteSchedule(projectID int, scheduleID int) (err error) { var schedule db.Schedule err = d.getObject(projectID, db.ScheduleProps, scheduleID, &schedule) if err != nil { return } err = d.deleteObject(projectID, db.ScheduleProps, scheduleID) if err != nil { return } if schedule.TaskParamsID != nil { err = d.deleteObject(projectID, db.TaskParamsProps, *schedule.TaskParamsID) } return err } func (d *SqlDb) GetSchedules() (schedules []db.Schedule, err error) { _, err = d.selectAll(&schedules, "select * from project__schedule where cron_format != '' or run_at is not null") return } func (d *SqlDb) GetProjectSchedules(projectID int, includeTaskParams bool, includeCommitCheckers bool) (schedules []db.ScheduleWithTpl, err error) { repoFilter := "" if !includeCommitCheckers { repoFilter = "ps.repository_id IS NULL AND " } _, err = d.selectAll(&schedules, "SELECT ps.*, pt.name as tpl_name FROM project__schedule ps "+ "JOIN project__template pt ON pt.id = ps.template_id "+ "WHERE "+ repoFilter+ "ps.project_id=?", projectID) if includeTaskParams { for i := range schedules { if schedules[i].TaskParamsID == nil { continue } var taskParams db.TaskParams err = d.getObject(projectID, db.TaskParamsProps, *schedules[i].TaskParamsID, &taskParams) if err != nil { return nil, err } schedules[i].TaskParams = &taskParams } } return } func (d *SqlDb) GetTemplateSchedules(projectID int, templateID int, onlyCommitCheckers bool) (schedules []db.Schedule, err error) { q := squirrel.Select("*"). From("project__schedule"). Where("project_id=?", projectID). Where("template_id=?", templateID) if onlyCommitCheckers { q = q.Where("repository_id IS NOT NULL") } query, args, err := q.ToSql() if err != nil { return } _, err = d.selectAll(&schedules, query, args...) return } func (d *SqlDb) SetScheduleActive(projectID int, scheduleID int, active bool) error { _, err := d.exec("update project__schedule set `active`=? where project_id=? and id=?", active, projectID, scheduleID) return err } func (d *SqlDb) SetScheduleCommitHash(projectID int, scheduleID int, hash string) error { _, err := d.exec("update project__schedule set last_commit_hash=? where project_id=? and id=?", hash, projectID, scheduleID) return err } ================================================ FILE: db/sql/secret_storage.go ================================================ package sql import "github.com/semaphoreui/semaphore/db" func (d *SqlDb) GetSecretStorages(projectID int) (storages []db.SecretStorage, err error) { storages = make([]db.SecretStorage, 0) q, err := d.makeObjectsQuery(projectID, db.SecretStorageProps, db.RetrieveQueryParams{}) if err != nil { return } query, args, err := q.ToSql() if err != nil { return } _, err = d.selectAll(&storages, query, args...) return } func (d *SqlDb) CreateSecretStorage(storage db.SecretStorage) (newStorage db.SecretStorage, err error) { insertID, err := d.insert( "id", "insert into project__secret_storage (name, type, project_id, params, readonly) values (?, ?, ?, ?, ?)", storage.Name, storage.Type, storage.ProjectID, storage.Params, storage.ReadOnly, ) if err != nil { return } newStorage = storage newStorage.ID = insertID return } func (d *SqlDb) GetSecretStorage(projectID int, storageID int) (key db.SecretStorage, err error) { err = d.getObject(projectID, db.SecretStorageProps, storageID, &key) return } func (d *SqlDb) DeleteSecretStorage(projectID int, storageID int) error { return d.deleteObject(projectID, db.SecretStorageProps, storageID) } func (d *SqlDb) GetSecretStorageRefs(projectID int, storageID int) (db.ObjectReferrers, error) { return d.getObjectRefs(projectID, db.SecretStorageProps, storageID) } func (d *SqlDb) UpdateSecretStorage(storage db.SecretStorage) error { _, err := d.exec("update project__secret_storage set "+ "name=?, "+ "type=?, "+ "params=?, "+ "readonly=? "+ "where project_id=? and id=?", storage.Name, storage.Type, storage.Params, storage.ReadOnly, storage.ProjectID, storage.ID) return err } ================================================ FILE: db/sql/session.go ================================================ package sql import ( "database/sql" "errors" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/tz" "regexp" ) func (d *SqlDb) SetSessionVerificationMethod(userID int, sessionID int, verificationMethod db.SessionVerificationMethod) error { return nil } func (d *SqlDb) VerifySession(userID int, sessionID int) error { _, err := d.exec("update session set verified = true where id=? and user_id=?", sessionID, userID) return err } func (d *SqlDb) CreateSession(session db.Session) (db.Session, error) { err := d.Sql().Insert(&session) return session, err } func (d *SqlDb) CreateAPIToken(token db.APIToken) (db.APIToken, error) { token.Created = db.GetParsedTime(tz.Now()) err := d.Sql().Insert(&token) return token, err } func (d *SqlDb) GetAPIToken(tokenID string) (token db.APIToken, err error) { err = d.selectOne(&token, d.PrepareQuery("select * from user__token where id=? and expired=false"), tokenID) return } func (d *SqlDb) ExpireAPIToken(userID int, tokenID string) error { return validateMutationResult(d.exec("update user__token set expired=true where id=? and user_id=?", tokenID, userID)) } func validateAPIToken(token string) error { if matched, _ := regexp.MatchString(`^[a-zA-Z0-9-_=]{8,}$`, token); !matched { return errors.New("invalid token format") } return nil } func (d *SqlDb) DeleteAPIToken(userID int, tokenPrefix string) (err error) { err = validateAPIToken(tokenPrefix) if err != nil { return } _, err = d.exec("DELETE FROM user__token WHERE id LIKE ? AND user_id=?", tokenPrefix+"%", userID) return } func (d *SqlDb) GetSession(userID int, sessionID int) (session db.Session, err error) { err = d.selectOne(&session, "select * from session where id=? and user_id=? and expired=false", sessionID, userID) return } func (d *SqlDb) ExpireSession(userID int, sessionID int) error { res, err := d.exec("update session set expired=true where id=? and user_id=?", sessionID, userID) return validateMutationResult(res, err) } func (d *SqlDb) TouchSession(userID int, sessionID int) error { _, err := d.exec("update session set last_active=? where id=? and user_id=?", tz.Now(), sessionID, userID) return err } func (d *SqlDb) GetAPITokens(userID int) (tokens []db.APIToken, err error) { _, err = d.selectAll(&tokens, d.PrepareQuery("select * from user__token where user_id=? order by created desc"), userID) if errors.Is(err, sql.ErrNoRows) { err = db.ErrNotFound } return } ================================================ FILE: db/sql/task.go ================================================ package sql import ( "encoding/json" "math/rand" "time" "github.com/Masterminds/squirrel" "github.com/semaphoreui/semaphore/db" ) func (d *SqlDb) CreateTaskStage(stage db.TaskStage) (res db.TaskStage, err error) { insertID, err := d.insert( "id", "insert into task__stage "+ "(task_id, `start`, `end`, `type`) VALUES "+ "(?, ?, ?, ?)", stage.TaskID, stage.Start, stage.End, stage.Type) if err != nil { return } res = stage res.ID = insertID return } func (d *SqlDb) EndTaskStage(taskID int, stageID int, end time.Time) (err error) { _, err = d.exec( "update task__stage set `end`=? where task_id=? and id=?", end, taskID, stageID) return } func (d *SqlDb) CreateTaskStageResult(taskID int, stageID int, result map[string]any) (err error) { jsn, err := json.Marshal(result) if err != nil { return } _, err = d.insert( "id", "insert into task__stage_result "+ "(task_id, stage_id, `json`) VALUES "+ "(?, ?, ?)", taskID, stageID, string(jsn)) return } func (d *SqlDb) getTaskStage(taskID int, stageID int) (res db.TaskStage, err error) { err = d.selectOne( &res, "select * from task__stage where task_id=? and id=?", taskID, stageID) return } func (d *SqlDb) validateTask(projectID int, taskID int) error { _, err := d.GetTask(projectID, taskID) return err } func (d *SqlDb) GetTaskStageResult(projectID int, taskID int, stageID int) (res db.TaskStageResult, err error) { if err = d.validateTask(projectID, taskID); err != nil { return } err = d.selectOne( &res, "select * from task__stage_result where task_id=? and stage_id=?", taskID, stageID) return } func (d *SqlDb) getTaskStages(projectID int, taskID int, stageType *db.TaskStageType) (res []db.TaskStageWithResult, err error) { if err = d.validateTask(projectID, taskID); err != nil { return } q := squirrel.Select("p.*, pu.json"). From("task__stage as p"). Join("task__stage_result as pu on pu.stage_id=p.id"). Where("pu.task_id=?", taskID) if stageType != nil { q = q.Where(squirrel.Eq{"type": *stageType}) } query, args, err := q.ToSql() if err != nil { return } _, err = d.selectAll(&res, query, args...) return } func (d *SqlDb) GetTaskStages(projectID int, taskID int) ([]db.TaskStageWithResult, error) { return d.getTaskStages(projectID, taskID, nil) } func (d *SqlDb) clearTasks(projectID int, templateID int, maxTasks int) { tpl, err := d.GetTemplate(projectID, templateID) if err != nil { return } nTasks := tpl.Tasks if rand.Intn(10) == 0 { // randomly recalculate number of tasks for the template var n int64 n, err = d.Sql().SelectInt("SELECT count(*) FROM task WHERE template_id=?", templateID) if err != nil { return } if n != int64(nTasks) { _, err = d.exec("UPDATE `project__template` SET `tasks`=? WHERE project_id=? and id=?", maxTasks, projectID, templateID) if err != nil { return } } nTasks = int(n) } if nTasks < maxTasks+maxTasks/10 { // deadzone of 10% for clearing of old tasks return } var oldestTask db.Task err = d.selectOne(&oldestTask, "SELECT created FROM task WHERE template_id=? ORDER BY created DESC LIMIT 1 OFFSET ?", templateID, maxTasks-1) if err != nil { return } _, err = d.exec("DELETE FROM task WHERE template_id=? AND created 0 { d.clearTasks(task.ProjectID, task.TemplateID, maxTasks) } return } func (d *SqlDb) UpdateTask(task db.Task) error { err := task.PreUpdate(d.Sql()) if err != nil { return err } if task.CommitHash != nil { _, err = d.exec( "update task set status=?, start=?, `end`=?, commit_hash=?, commit_message=? where id=?", task.Status, task.Start, task.End, task.CommitHash, task.CommitMessage, task.ID) } else { _, err = d.exec( "update task set status=?, start=?, `end`=? where id=?", task.Status, task.Start, task.End, task.ID) } return err } func (d *SqlDb) CreateTaskOutput(output db.TaskOutput) (db.TaskOutput, error) { insertID, err := d.insert( "id", "insert into task__output (task_id, output, time) VALUES (?, ?, ?)", output.TaskID, output.Output, output.Time.UTC()) output.ID = insertID return output, err } func (d *SqlDb) InsertTaskOutputBatch(output []db.TaskOutput) error { if len(output) == 0 { return nil } q := squirrel.Insert("task__output"). Columns("task_id", "output", "time", "stage_id") for _, item := range output { q = q.Values(item.TaskID, item.Output, item.Time.UTC(), item.StageID) } query, args, err := q.ToSql() if err != nil { return err } _, err = d.exec(query, args...) return err } // getTasks retrieves tasks for a given project, optionally filtered by template and/or task IDs. // The taskIDs parameter has three-way semantics: nil means no filtering by ID, // and a non-nil non-empty slice restricts the query to only those task IDs. func (d *SqlDb) getTasks(projectID int, templateID *int, taskIDs []int, params db.RetrieveQueryParams, tasks *[]db.TaskWithTpl) (err error) { if taskIDs != nil && len(taskIDs) == 0 { *tasks = []db.TaskWithTpl{} return nil } fields := "task.*" fields += ", tpl.playbook as tpl_playbook" + ", `user`.name as user_name" + ", tpl.name as tpl_alias" + ", tpl.type as tpl_type" + ", tpl.app as tpl_app" q := squirrel.Select(fields). From("task"). Join("project__template as tpl on task.template_id=tpl.id"). LeftJoin("`user` on task.user_id=`user`.id"). OrderBy("id desc") if params.TaskFilter != nil && len(params.TaskFilter.Status) > 0 { q = q.Where(squirrel.Eq{"status": params.TaskFilter.Status}) } if templateID == nil { q = q.Where("tpl.project_id=?", projectID) } else { q = q.Where("tpl.project_id=? AND task.template_id=?", projectID, templateID) } if taskIDs != nil { q = q.Where(squirrel.Eq{"task.id": taskIDs}) } if params.Count > 0 { q = q.Limit(uint64(params.Count)) } query, args, _ := q.ToSql() _, err = d.selectAll(tasks, query, args...) for i := range *tasks { err = (*tasks)[i].Fill(d) if err != nil { return } } return } func (d *SqlDb) GetTask(projectID int, taskID int) (task db.Task, err error) { q := squirrel.Select("task.*"). From("task"). Join("project__template as tpl on task.template_id=tpl.id"). Where("tpl.project_id=? AND task.id=?", projectID, taskID) query, args, err := q.ToSql() if err != nil { return } err = d.selectOne(&task, query, args...) return } func (d *SqlDb) GetTemplateTasks(projectID int, templateID int, params db.RetrieveQueryParams) (tasks []db.TaskWithTpl, err error) { err = d.getTasks(projectID, &templateID, nil, params, &tasks) return } func (d *SqlDb) GetProjectTasks(projectID int, params db.RetrieveQueryParams) (tasks []db.TaskWithTpl, err error) { tasks = make([]db.TaskWithTpl, 0) err = d.getTasks(projectID, nil, nil, params, &tasks) return } func (d *SqlDb) DeleteTaskWithOutputs(projectID int, taskID int) (err error) { // check if task exists in the project _, err = d.GetTask(projectID, taskID) if err != nil { return } _, err = d.exec("delete from task__output where task_id=?", taskID) if err != nil { return } _, err = d.exec("delete from task where id=?", taskID) return } func (d *SqlDb) GetTaskOutputs(projectID int, taskID int, params db.RetrieveQueryParams) (output []db.TaskOutput, err error) { if err = d.validateTask(projectID, taskID); err != nil { return } q := squirrel.Select("task_id", "time", "output"). From("task__output"). Where("task_id=?", taskID). OrderBy("time, id") if params.Count > 0 { q = q.Limit(uint64(params.Count)).Offset(uint64(params.Offset)) } query, args, err := q.ToSql() if err != nil { return } _, err = d.selectAll(&output, query, args...) return } func (d *SqlDb) GetTaskStageOutputs(projectID int, taskID int, stageID int) (output []db.TaskOutput, err error) { if err = d.validateTask(projectID, taskID); err != nil { return } q := squirrel.Select("id", "task_id", "time", "output"). From("task__output"). Where("task_id=?", taskID). Where("stage_id=?", stageID) query, args, err := q.ToSql() if err != nil { return } _, err = d.selectAll(&output, query, args...) return } func (d *SqlDb) GetNodeCount() (int, error) { return 0, nil } func (d *SqlDb) GetUiCount() (int, error) { return 1, nil } ================================================ FILE: db/sql/template.go ================================================ package sql import ( "encoding/json" "errors" "github.com/Masterminds/squirrel" "github.com/semaphoreui/semaphore/db" common_errors "github.com/semaphoreui/semaphore/pkg/common_errors" log "github.com/sirupsen/logrus" ) func (d *SqlDb) CreateTemplate(template db.Template) (newTemplate db.Template, err error) { err = template.Validate() if err != nil { return } insertID, err := d.insert( "id", "insert into project__template ("+ "project_id, inventory_id, repository_id, environment_id, name, "+ "playbook, arguments, allow_override_args_in_task, description, `type`, "+ "start_version, build_template_id, view_id, autorun, survey_vars, "+ "suppress_success_alerts, app, git_branch, runner_tag, task_params, "+ "allow_override_branch_in_task, allow_parallel_tasks)"+ "values ("+ "?, ?, ?, ?, ?, "+ "?, ?, ?, ?, ?, "+ "?, ?, ?, ?, ?, "+ "?, ?, ?, ?, ?,"+ "?, ?)", template.ProjectID, template.InventoryID, template.RepositoryID, template.EnvironmentID, template.Name, template.Playbook, template.Arguments, template.AllowOverrideArgsInTask, template.Description, template.Type, template.StartVersion, template.BuildTemplateID, template.ViewID, template.Autorun, db.ObjectToJSON(template.SurveyVars), template.SuppressSuccessAlerts, template.App, template.GitBranch, template.RunnerTag, template.TaskParams, template.AllowOverrideBranchInTask, template.AllowParallelTasks, ) if err != nil { return } err = d.UpdateTemplateVaults(template.ProjectID, insertID, template.Vaults) if err != nil { return } err = db.FillTemplate(d, &newTemplate) if err != nil { return } newTemplate = template newTemplate.ID = insertID return } func (d *SqlDb) UpdateTemplate(template db.Template) error { err := template.Validate() if err != nil { return err } _, err = d.exec("update project__template set "+ "inventory_id=?, "+ "repository_id=?, "+ "environment_id=?, "+ "name=?, "+ "playbook=?, "+ "arguments=?, "+ "allow_override_args_in_task=?, "+ "description=?, "+ "`type`=?, "+ "start_version=?,"+ "build_template_id=?, "+ "view_id=?, "+ "autorun=?, "+ "survey_vars=?, "+ "suppress_success_alerts=?, "+ "app=?, "+ "`git_branch`=?, "+ "task_params=?, "+ "runner_tag=?, "+ "allow_override_branch_in_task=?, "+ "allow_parallel_tasks=? "+ "where id=? and project_id=?", template.InventoryID, template.RepositoryID, template.EnvironmentID, template.Name, template.Playbook, template.Arguments, template.AllowOverrideArgsInTask, template.Description, template.Type, template.StartVersion, template.BuildTemplateID, template.ViewID, template.Autorun, db.ObjectToJSON(template.SurveyVars), template.SuppressSuccessAlerts, template.App, template.GitBranch, template.TaskParams, template.RunnerTag, template.AllowOverrideBranchInTask, template.AllowParallelTasks, template.ID, template.ProjectID, ) if err != nil { return err } err = d.UpdateTemplateVaults(template.ProjectID, template.ID, template.Vaults) return err } func (d *SqlDb) SetTemplateDescription(projectID int, templateID int, description string) (err error) { _, err = d.exec("update project__template set "+ "description=? "+ "where id=? and project_id=?", description, templateID, projectID, ) return } func (d *SqlDb) getTemplates( projectID int, userID *int, filter db.TemplateFilter, params db.RetrieveQueryParams, loadVaults bool, ) (templates []db.TemplateWithPerms, err error) { pp, err := params.Validate(db.TemplateProps) if err != nil { return } templates = make([]db.TemplateWithPerms, 0) type templateWithLastTask struct { db.TemplateWithPerms LastTaskID *int `db:"last_task_id"` } var view db.View if filter.ViewID != nil { view, err = d.GetView(projectID, *filter.ViewID) if err != nil { return } } fields := []string{ "pt.id", "pt.project_id", "pt.inventory_id", "pt.repository_id", "pt.environment_id", "pt.name", "pt.description", "pt.playbook", "pt.arguments", "pt.allow_override_args_in_task", "pt.build_template_id", "pt.start_version", "pt.view_id", "pt.`app`", "pt.`git_branch`", "pt.survey_vars", "pt.`type`", "pt.`tasks`", "pt.runner_tag", "pt.task_params", "pt.allow_override_branch_in_task", "pt.allow_parallel_tasks", "(SELECT `id` FROM `task` WHERE template_id = pt.id ORDER BY `id` DESC LIMIT 1) last_task_id", } if userID != nil { fields = append(fields, "ptr.permissions permissions") } q := squirrel.Select(fields...).From("project__template pt") if userID != nil { q = q.LeftJoin("project__user pu ON (pu.project_id = pt.project_id AND pu.user_id = ?)", *userID). LeftJoin("project__template_role ptr ON (ptr.template_id = pt.id AND ptr.role_slug = pu.`role`)") } if filter.App != nil { q = q.Where("pt.app=?", *filter.App) } if filter.ViewID != nil { switch view.Type { case db.ViewTypeCustom: q = q.Where("pt.view_id=?", *filter.ViewID) case db.ViewTypeAll: if view.Filter != nil { // TODO: implement filter } } } if filter.BuildTemplateID != nil { q = q.Where("pt.build_template_id=?", *filter.BuildTemplateID) if filter.AutorunOnly { q = q.Where("pt.autorun=true") } } order := "ASC" var sortBy string if pp.SortBy != "" { // order by query param has priority sortBy = pp.SortBy if pp.SortInverted { order = "DESC" } } else if filter.ViewID != nil && view.SortColumn != nil { sortBy = *view.SortColumn if view.SortReverse { order = "DESC" } } switch sortBy { case "name", "playbook": q = q.Where("pt.project_id=?", projectID). OrderBy("pt." + sortBy + " " + order) case "inventory": q = q.LeftJoin("project__inventory pi ON (pt.inventory_id = pi.id)"). Where("pt.project_id=?", projectID). OrderBy("pi.name " + order) case "environment": q = q.LeftJoin("project__environment pe ON (pt.environment_id = pe.id)"). Where("pt.project_id=?", projectID). OrderBy("pe.name " + order) case "repository": q = q.LeftJoin("project__repository pr ON (pt.repository_id = pr.id)"). Where("pt.project_id=?", projectID). OrderBy("pr.name " + order) default: q = q.Where("pt.project_id=?", projectID). OrderBy("pt.name " + order) } query, args, err := q.ToSql() if err != nil { return } var tpls []templateWithLastTask _, err = d.selectAll(&tpls, query, args...) if err != nil { return } taskIDs := make([]int, 0) for _, tpl := range tpls { if tpl.LastTaskID != nil { taskIDs = append(taskIDs, *tpl.LastTaskID) } } var tasks []db.TaskWithTpl err = d.getTasks(projectID, nil, taskIDs, db.RetrieveQueryParams{}, &tasks) if err != nil { return } for _, tpl := range tpls { template := tpl.TemplateWithPerms if tpl.LastTaskID != nil { for _, tsk := range tasks { if tsk.ID == *tpl.LastTaskID { // err = tsk.Fill(d) // if err != nil { // return // } template.LastTask = &tsk break } } } if tpl.SurveyVarsJSON != nil { if err2 := json.Unmarshal([]byte(*tpl.SurveyVarsJSON), &template.SurveyVars); err2 != nil { log.WithFields(log.Fields{ "context": common_errors.GetErrorContext(), "project_id": projectID, "template_id": template.ID, "hint": "validate JSON array in project__template.survey_vars", }).Error("failed to unmarshal template survey vars") } } if loadVaults { template.Vaults, err = d.GetTemplateVaults(projectID, template.ID) if err != nil { return } } templates = append(templates, template) } return } func (d *SqlDb) GetTemplatesWithPermissions(projectID int, userID int, filter db.TemplateFilter, params db.RetrieveQueryParams) (templates []db.TemplateWithPerms, err error) { return d.getTemplates(projectID, &userID, filter, params, false) } func (d *SqlDb) GetTemplates(projectID int, filter db.TemplateFilter, params db.RetrieveQueryParams) (templates []db.Template, err error) { res, err := d.getTemplates(projectID, nil, filter, params, true) if err != nil { return } templates = make([]db.Template, 0, len(res)) for _, tpl := range res { templates = append(templates, tpl.Template) } return } func (d *SqlDb) GetTemplate(projectID int, templateID int) (template db.Template, err error) { err = d.selectOne( &template, "select * from project__template where project_id=? and id=?", projectID, templateID) if err != nil { return } err = db.FillTemplate(d, &template) return } func (d *SqlDb) DeleteTemplate(projectID int, templateID int) error { _, err := d.exec("delete from project__template where project_id=? and id=?", projectID, templateID) return err } func (d *SqlDb) GetTemplateRefs(projectID int, templateID int) (db.ObjectReferrers, error) { return d.getObjectRefs(projectID, db.TemplateProps, templateID) } func (d *SqlDb) GetTemplateRole(projectID int, templateID int, id int) (templateRole db.TemplateRolePerm, err error) { query, args, err := squirrel.Select("*"). From("project__template_role"). Where("project_id = ?", projectID). Where("template_id = ?", templateID). Where("id = ?", id). ToSql() if err != nil { return } err = d.selectOne(&templateRole, query, args...) return } func (d *SqlDb) GetTemplatePermission(projectID int, templateID int, userID int) (perm db.ProjectUserPermission, err error) { var projectUser db.ProjectUser projectUser, err = d.GetProjectUser(projectID, userID) if err != nil { if errors.Is(err, db.ErrNotFound) { err = nil // user not in project, no permissions } return } perm = projectUser.Role.GetPermissions() role, err := d.GetProjectOrGlobalRoleBySlug(projectUser.ProjectID, string(projectUser.Role)) if errors.Is(err, db.ErrNotFound) { err = nil return } if err != nil { return } query, args, err := squirrel.Select("permissions"). From("project__template_role"). Where("project_id = ?", projectID). Where("template_id = ?", templateID). Where("role_slug = ?", role.Slug). ToSql() if err != nil { return } var templateRole db.TemplateRolePerm err = d.selectOne(&templateRole, query, args...) if errors.Is(err, db.ErrNotFound) { err = nil return } if err != nil { return } perm |= templateRole.Permissions return } func (d *SqlDb) GetTemplateRoles(projectID int, templateID int) (roles []db.TemplateRolePerm, err error) { query, args, err := squirrel.Select("*"). From("project__template_role"). Where("project_id = ?", projectID). Where("template_id = ?", templateID). ToSql() if err != nil { return } _, err = d.selectAll(&roles, query, args...) return } func (d *SqlDb) CreateTemplateRole(role db.TemplateRolePerm) (newRole db.TemplateRolePerm, err error) { insertID, err := d.insert( "id", "insert into project__template_role (project_id, template_id, role_slug, permissions) values (?, ?, ?, ?)", role.ProjectID, role.TemplateID, role.RoleSlug, role.Permissions) if err != nil { return } newRole = role newRole.ID = insertID return } func (d *SqlDb) DeleteTemplateRole(projectID int, templateID int, id int) error { _, err := d.exec("delete from project__template_role where project_id=? and template_id=? and id=?", projectID, templateID, id) return err } func (d *SqlDb) UpdateTemplateRole(role db.TemplateRolePerm) error { _, err := d.exec( "update project__template_role set permissions=? "+ "where project_id=? and template_id=? and id=?", role.Permissions, role.ProjectID, role.TemplateID, role.ID) return err } ================================================ FILE: db/sql/template_vault.go ================================================ package sql import ( "github.com/semaphoreui/semaphore/db" "strconv" "strings" ) func (d *SqlDb) GetTemplateVaults(projectID int, templateID int) (vaults []db.TemplateVault, err error) { vaults = []db.TemplateVault{} _, err = d.selectAll(&vaults, "select * from project__template_vault where project_id=? and template_id=?", projectID, templateID) if err != nil { return } for i := range vaults { err = db.FillTemplateVault(d, projectID, &vaults[i]) if err != nil { return } } return } func (d *SqlDb) CreateTemplateVault(vault db.TemplateVault) (newVault db.TemplateVault, err error) { insertID, err := d.insert( "id", "insert into project__template_vault (project_id, template_id, vault_key_id, name, type, script) values (?, ?, ?, ?, ?, ?)", vault.ProjectID, vault.TemplateID, vault.VaultKeyID, vault.Name, vault.Type, vault.Script) if err != nil { return } newVault = vault newVault.ID = insertID return } func (d *SqlDb) UpdateTemplateVaults(projectID int, templateID int, vaults []db.TemplateVault) (err error) { if vaults == nil { vaults = []db.TemplateVault{} } var vaultIDs []string for _, vault := range vaults { switch vault.Type { case "password": vault.Script = nil case "script": vault.VaultKeyID = nil } if vault.ID == 0 { // Insert new vaults var vaultId int vaultId, err = d.insert("id", "insert into project__template_vault (project_id, template_id, vault_key_id, name, type, script) values (?, ?, ?, ?, ?, ?)", projectID, templateID, vault.VaultKeyID, vault.Name, vault.Type, vault.Script) if err != nil { return } vaultIDs = append(vaultIDs, strconv.Itoa(vaultId)) } else { // Update existing vaults _, err = d.exec("update project__template_vault set project_id=?, template_id=?, vault_key_id=?, name=?, type=?, script=? where id=?", projectID, templateID, vault.VaultKeyID, vault.Name, vault.Type, vault.Script, vault.ID) vaultIDs = append(vaultIDs, strconv.Itoa(vault.ID)) } if err != nil { return } } // Delete removed vaults if len(vaultIDs) == 0 { _, err = d.exec("delete from project__template_vault where project_id=? and template_id=?", projectID, templateID) } else { _, err = d.exec("delete from project__template_vault where project_id=? and template_id=? and id not in ("+strings.Join(vaultIDs, ",")+")", projectID, templateID) } return } ================================================ FILE: db/sql/user.go ================================================ package sql import ( "errors" "strings" "github.com/Masterminds/squirrel" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/tz" "golang.org/x/crypto/bcrypt" ) func (d *SqlDb) CreateUserWithoutPassword(user db.User) (newUser db.User, err error) { err = db.ValidateUser(user) if err != nil { return } user.Password = "" user.Created = db.GetParsedTime(tz.Now()) err = d.Sql().Insert(&user) if err != nil { return } newUser = user return } func (d *SqlDb) CreateUser(user db.UserWithPwd) (newUser db.User, err error) { err = db.ValidateUser(user.User) if err != nil { return } pwdHash, err := bcrypt.GenerateFromPassword([]byte(user.Pwd), 11) if err != nil { return } user.Password = string(pwdHash) user.Created = db.GetParsedTime(tz.Now()) err = d.Sql().Insert(&user.User) if err != nil { return } newUser = user.User return } func (d *SqlDb) ImportUser(user db.UserWithPwd) (newUser db.User, err error) { err = db.ValidateUser(user.User) if err != nil { return } user.Created = db.GetParsedTime(tz.Now()) err = d.Sql().Insert(&user.User) if err != nil { return } newUser = user.User return } func (d *SqlDb) DeleteUser(userID int) error { res, err := d.exec("delete from `user` where id=?", userID) return validateMutationResult(res, err) } func (d *SqlDb) UpdateUser(user db.UserWithPwd) error { var err error if user.Pwd != "" { var pwdHash []byte pwdHash, err = bcrypt.GenerateFromPassword([]byte(user.Pwd), 11) if err != nil { return err } _, err = d.exec( "update `user` set name=?, username=?, email=?, alert=?, admin=?, pro=?, password=? where id=?", user.Name, user.Username, user.Email, user.Alert, user.Admin, user.Pro, string(pwdHash), user.ID) } else { _, err = d.exec( "update `user` set name=?, username=?, email=?, alert=?, admin=?, pro=? where id=?", user.Name, user.Username, user.Email, user.Alert, user.Admin, user.Pro, user.ID) } return err } func (d *SqlDb) SetUserPassword(userID int, password string) error { hash, err := bcrypt.GenerateFromPassword([]byte(password), 11) if err != nil { return err } _, err = d.exec( "update `user` set password=? where id=?", string(hash), userID) return err } func (d *SqlDb) CreateProjectUser(projectUser db.ProjectUser) (newProjectUser db.ProjectUser, err error) { _, err = d.exec( "insert into project__user (project_id, user_id, `role`) values (?, ?, ?)", projectUser.ProjectID, projectUser.UserID, projectUser.Role) if err != nil { return } newProjectUser = projectUser return } func (d *SqlDb) GetProjectUser(projectID, userID int) (db.ProjectUser, error) { var user db.ProjectUser err := d.selectOne(&user, "select * from project__user where project_id=? and user_id=?", projectID, userID) return user, err } func (d *SqlDb) GetProjectUsers(projectID int, params db.RetrieveQueryParams) (users []db.UserWithProjectRole, err error) { pp, err := params.Validate(db.UserProps) if err != nil { return } q := squirrel.Select("u.*"). Column("pu.role"). From("project__user as pu"). LeftJoin("`user` as u on pu.user_id=u.id"). Where("pu.project_id=?", projectID) sortDirection := "ASC" if pp.SortInverted { sortDirection = "DESC" } switch pp.SortBy { case "name", "username", "email": q = q.OrderBy("u." + pp.SortBy + " " + sortDirection) case "role": q = q.OrderBy("pu.role " + sortDirection) default: q = q.OrderBy("u.name " + sortDirection) } query, args, err := q.ToSql() if err != nil { return } _, err = d.selectAll(&users, query, args...) return } func (d *SqlDb) UpdateProjectUser(projectUser db.ProjectUser) error { _, err := d.exec( "update `project__user` set role=? where user_id=? and project_id = ?", projectUser.Role, projectUser.UserID, projectUser.ProjectID) return err } func (d *SqlDb) DeleteProjectUser(projectID, userID int) error { _, err := d.exec("delete from project__user where user_id=? and project_id=?", userID, projectID) return err } // GetUser retrieves a user from the database by ID func (d *SqlDb) GetUser(userID int) (user db.User, err error) { err = d.selectOne(&user, "select * from `user` where id=?", userID) if err != nil { return } var totp db.UserTotp err = d.selectOne(&totp, "select * from `user__totp` where user_id=?", user.ID) if err == nil { user.Totp = &totp } if errors.Is(err, db.ErrNotFound) { err = nil } var emailOtp db.UserEmailOtp err = d.selectOne(&emailOtp, "select * from `user__email_otp` where user_id=?", user.ID) if err == nil { user.EmailOtp = &emailOtp } if errors.Is(err, db.ErrNotFound) { err = nil } return } func (d *SqlDb) GetProUserCount() (count int, err error) { cnt, err := d.Sql().SelectInt(d.PrepareQuery("select count(*) from `user` where pro")) count = int(cnt) return } func (d *SqlDb) GetUserCount() (count int, err error) { cnt, err := d.Sql().SelectInt(d.PrepareQuery("select count(*) from `user`")) count = int(cnt) return } func escapeLike(s string) string { // Order matters: escape \ first s = strings.ReplaceAll(s, `\`, `\\`) s = strings.ReplaceAll(s, `%`, `\%`) s = strings.ReplaceAll(s, `_`, `\_`) return s } func (d *SqlDb) GetUsers(params db.RetrieveQueryParams) (users []db.User, err error) { q := squirrel.Select("*").From("`user`") q, err = getQueryForParams(q, "", db.UserProps, params) if err != nil { return } if params.Filter != "" { q = q.Where(squirrel.Like{"username": escapeLike(params.Filter) + "%"}) } query, args, err := q.ToSql() if err != nil { return } _, err = d.selectAll(&users, query, args...) return } func (d *SqlDb) GetUserByLoginOrEmail(login string, email string) (user db.User, err error) { err = d.selectOne( &user, d.PrepareQuery("select * from `user` where email=? or username=?"), email, login) if err != nil { return } var totp db.UserTotp err = d.selectOne(&totp, "select * from `user__totp` where user_id=?", user.ID) if err == nil { user.Totp = &totp } if errors.Is(err, db.ErrNotFound) { err = nil } var emailOtp db.UserEmailOtp err = d.selectOne(&emailOtp, "select * from `user__email_otp` where user_id=?", user.ID) if err == nil && !emailOtp.IsExpired() { user.EmailOtp = &emailOtp } if errors.Is(err, db.ErrNotFound) { err = nil } return } func (d *SqlDb) GetAllAdmins() (users []db.User, err error) { _, err = d.selectAll(&users, "select * from `user` where `admin` = true") return } func (d *SqlDb) AddTotpVerification(userID int, url string, recoveryHash string) (totp db.UserTotp, err error) { totp.UserID = userID totp.URL = url totp.RecoveryHash = recoveryHash totp.Created = db.GetParsedTime(tz.Now()) res, err := d.exec( "insert into user__totp (user_id, url, recovery_hash, created) values (?, ?, ?, ?)", totp.UserID, totp.URL, totp.RecoveryHash, totp.Created) if err != nil { return } id, err := res.LastInsertId() if err != nil { return } totp.ID = int(id) return } func (d *SqlDb) DeleteTotpVerification(userID int, totpID int) error { _, err := d.exec("delete from user__totp where user_id=? and id = ?", userID, totpID) return err } func (d *SqlDb) insertEmailOtp(userID int, code string) (totp db.UserEmailOtp, err error) { totp.UserID = userID totp.Code = code totp.Created = db.GetParsedTime(tz.Now()) res, err := d.exec( "insert into user__email_otp (user_id, code, created) values (?, ?, ?)", totp.UserID, totp.Code, totp.Created) if err != nil { return } id, err := res.LastInsertId() if err != nil { return } totp.ID = int(id) return } func (d *SqlDb) AddEmailOtpVerification(userID int, code string) (res db.UserEmailOtp, err error) { var emailOtp db.UserEmailOtp err = d.selectOne(&emailOtp, "select * from `user__email_otp` where user_id=?", userID) if err == nil { now := db.GetParsedTime(tz.Now()) _, err = d.exec("update user__email_otp set code=?, created=? where user_id=?", code, now, userID) } else if errors.Is(err, db.ErrNotFound) { err = nil res, err = d.insertEmailOtp(userID, code) } else { return } return } func (d *SqlDb) DeleteEmailOtpVerification(userID int, totpID int) error { _, err := d.exec("delete from user__email_otp where user_id=? and id = ?", userID, totpID) return err } ================================================ FILE: db/sql/view.go ================================================ package sql import "github.com/semaphoreui/semaphore/db" func (d *SqlDb) GetView(projectID int, viewID int) (view db.View, err error) { err = d.getObject(projectID, db.ViewProps, viewID, &view) return } func (d *SqlDb) GetViews(projectID int) (views []db.View, err error) { err = d.getObjects(projectID, db.ViewProps, db.RetrieveQueryParams{}, nil, &views) return } func (d *SqlDb) UpdateView(view db.View) error { _, err := d.exec( "update project__view set title=?, position=?, project_id=?, `type`=?, sort_column=?, sort_reverse=?, "+ "hidden=? "+ "where id=?", view.Title, view.Position, view.ProjectID, view.Type, view.SortColumn, view.SortReverse, view.Hidden, view.ID) return err } func (d *SqlDb) CreateView(view db.View) (newView db.View, err error) { insertID, err := d.insert( "id", "insert into project__view (project_id, title, position, `type`, sort_column, sort_reverse, "+ "hidden) values (?, ?, ?, ?, ?, ?, ?)", view.ProjectID, view.Title, view.Position, view.Type, view.SortColumn, view.SortReverse, view.Hidden, ) if err != nil { return } newView = view newView.ID = insertID return } func (d *SqlDb) DeleteView(projectID int, viewID int) error { return d.deleteObject(projectID, db.ViewProps, viewID) } func (d *SqlDb) SetViewPositions(projectID int, positions map[int]int) error { for id, position := range positions { _, err := d.exec("update project__view set position=? where project_id=? and id=?", position, projectID, id) if err != nil { return err } } return nil } ================================================ FILE: db_lib/AccessKeyInstaller.go ================================================ package db_lib import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/ssh" "github.com/semaphoreui/semaphore/pkg/task_logger" ) type AccessKeyInstaller interface { Install(key db.AccessKey, usage db.AccessKeyRole, logger task_logger.Logger) (installation ssh.AccessKeyInstallation, err error) } ================================================ FILE: db_lib/AnsibleApp.go ================================================ package db_lib import ( "crypto/md5" "fmt" "io" "os" "path" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/task_logger" ) func getMD5Hash(filepath string) (string, error) { file, err := os.Open(filepath) if err != nil { return "", err } defer file.Close() //nolint:errcheck hash := md5.New() if _, err := io.Copy(hash, file); err != nil { return "", err } return fmt.Sprintf("%x", hash.Sum(nil)), nil } func hasRequirementsChanges(requirementsFilePath string, requirementsHashFilePath string) bool { oldFileMD5HashBytes, err := os.ReadFile(requirementsHashFilePath) if err != nil { return true } newFileMD5Hash, err := getMD5Hash(requirementsFilePath) if err != nil { return true } return string(oldFileMD5HashBytes) != newFileMD5Hash } func writeMD5Hash(requirementsFile string, requirementsHashFile string) error { newFileMD5Hash, err := getMD5Hash(requirementsFile) if err != nil { return err } return os.WriteFile(requirementsHashFile, []byte(newFileMD5Hash), 0o644) } type AnsibleApp struct { Logger task_logger.Logger Playbook *AnsiblePlaybook Template db.Template Repository db.Repository } func (t *AnsibleApp) SetLogger(logger task_logger.Logger) task_logger.Logger { t.Logger = logger t.Playbook.Logger = logger return logger } func (t *AnsibleApp) Run(args LocalAppRunningArgs) error { // Use "default" key for backward compatibility cliArgs := args.CliArgs["default"] return t.Playbook.RunPlaybook(cliArgs, args.EnvironmentVars, args.Inputs, args.Callback) } func (t *AnsibleApp) Log(msg string) { t.Logger.Log(msg) } func (t *AnsibleApp) Clear() { } func (t *AnsibleApp) InstallRequirements(args LocalAppInstallingArgs) error { if err := t.installCollectionsRequirements(args.EnvironmentVars); err != nil { return err } if err := t.installRolesRequirements(args.EnvironmentVars); err != nil { return err } return nil } func (t *AnsibleApp) getRepoPath() string { return t.Repository.GetFullPath(t.Template.ID) } func (t *AnsibleApp) installGalaxyRequirementsFile(requirementsType GalaxyRequirementsType, requirementsFilePath string, environmentVars []string) error { requirementsHashFilePath := fmt.Sprintf("%s_%s.md5", requirementsFilePath, requirementsType) if _, err := os.Stat(requirementsFilePath); err != nil { t.Log("No " + requirementsFilePath + " file found. Skip galaxy install process.\n") return nil } if hasRequirementsChanges(requirementsFilePath, requirementsHashFilePath) { if err := t.runGalaxy([]string{ string(requirementsType), "install", "-r", requirementsFilePath, "--force", }, environmentVars); err != nil { return err } if err := writeMD5Hash(requirementsFilePath, requirementsHashFilePath); err != nil { return err } } else { t.Log(requirementsFilePath + " has no changes. Skip galaxy install process.\n") } return nil } func (t *AnsibleApp) GetPlaybookDir() string { playbookPath := path.Join(t.getRepoPath(), t.Template.Playbook) return path.Dir(playbookPath) } type GalaxyRequirementsType string const ( GalaxyRole GalaxyRequirementsType = "role" GalaxyCollection GalaxyRequirementsType = "collection" ) func (t *AnsibleApp) installRolesRequirements(environmentVars []string) (err error) { // default roles path err = t.installGalaxyRequirementsFile(GalaxyRole, path.Join(t.GetPlaybookDir(), "roles", "requirements.yml"), environmentVars) if err != nil { return } err = t.installGalaxyRequirementsFile(GalaxyRole, path.Join(t.GetPlaybookDir(), "requirements.yml"), environmentVars) if err != nil { return } // alternative roles path err = t.installGalaxyRequirementsFile(GalaxyRole, path.Join(t.getRepoPath(), "roles", "requirements.yml"), environmentVars) if err != nil { return } err = t.installGalaxyRequirementsFile(GalaxyRole, path.Join(t.getRepoPath(), "requirements.yml"), environmentVars) return } func (t *AnsibleApp) installCollectionsRequirements(environmentVars []string) (err error) { // default collections path err = t.installGalaxyRequirementsFile(GalaxyCollection, path.Join(t.GetPlaybookDir(), "collections", "requirements.yml"), environmentVars) if err != nil { return } err = t.installGalaxyRequirementsFile(GalaxyCollection, path.Join(t.GetPlaybookDir(), "requirements.yml"), environmentVars) if err != nil { return } // alternative collections path err = t.installGalaxyRequirementsFile(GalaxyCollection, path.Join(t.getRepoPath(), "collections", "requirements.yml"), environmentVars) if err != nil { return } err = t.installGalaxyRequirementsFile(GalaxyCollection, path.Join(t.getRepoPath(), "requirements.yml"), environmentVars) return } func (t *AnsibleApp) runGalaxy(args []string, environmentVars []string) error { return t.Playbook.RunGalaxy(args, environmentVars) } ================================================ FILE: db_lib/AnsiblePlaybook.go ================================================ package db_lib import ( "fmt" "os" "os/exec" "path" "strings" "github.com/creack/pty" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/util" ) type AnsiblePlaybook struct { TemplateID int Repository db.Repository Logger task_logger.Logger } func (p AnsiblePlaybook) makeCmd(command string, args []string, environmentVars []string) *exec.Cmd { cmd := exec.Command(command, args...) //nolint: gas cmd.Dir = p.GetFullPath() cmd.Env = append(cmd.Env, "PYTHONUNBUFFERED=1") cmd.Env = append(cmd.Env, "ANSIBLE_FORCE_COLOR=True") cmd.Env = append(cmd.Env, "ANSIBLE_HOST_KEY_CHECKING=False") //cmd.Env = append(cmd.Env, "ANSIBLE_SSH_ARGS=-o UserKnownHostsFile=/dev/null") cmd.Env = append(cmd.Env, getEnvironmentVars()...) cmd.Env = append(cmd.Env, fmt.Sprintf("HOME=%s", getHomeDir(p.Repository, p.TemplateID))) cmd.Env = append(cmd.Env, fmt.Sprintf("PWD=%s", cmd.Dir)) if util.Config.HomeDirMode == util.HomeDirModeTemplateDir { cmd.Env = append(cmd.Env, fmt.Sprintf("ANSIBLE_HOME=%s", path.Join(p.Repository.GetHomePath(p.TemplateID), ".ansible"))) } cmd.Env = append(cmd.Env, environmentVars...) cmd.SysProcAttr = util.Config.GetSysProcAttr() return cmd } func (p AnsiblePlaybook) runCmd(command string, args []string, environmentVars []string) error { cmd := p.makeCmd(command, args, environmentVars) p.Logger.LogCmd(cmd) err := cmd.Run() // Wait for all log processing to complete before returning p.Logger.WaitLog() return err } func (p AnsiblePlaybook) RunPlaybook(args []string, environmentVars []string, inputs map[string]string, cb func(*os.Process)) error { cmd := p.makeCmd("ansible-playbook", args, environmentVars) p.Logger.LogCmd(cmd) ptmx, err := pty.Start(cmd) if err != nil { return err } go func() { b := make([]byte, 100) var e error for { var n int n, e = ptmx.Read(b) if e != nil { break } s := strings.TrimSpace(string(b[0:n])) for k, v := range inputs { if strings.HasPrefix(s, k) { _, _ = ptmx.WriteString(v + "\n") } } } }() defer func() { _ = ptmx.Close() }() cb(cmd.Process) err = cmd.Wait() // Wait for all log processing to complete before returning p.Logger.WaitLog() return err } func (p AnsiblePlaybook) RunGalaxy(args []string, environmentVars []string) error { return p.runCmd("ansible-galaxy", args, environmentVars) } func (p AnsiblePlaybook) GetFullPath() (path string) { path = p.Repository.GetFullPath(p.TemplateID) return } ================================================ FILE: db_lib/AppFactory.go ================================================ package db_lib import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/task_logger" ) func CreateApp(template db.Template, repository db.Repository, inventory db.Inventory, logger task_logger.Logger) LocalApp { switch template.App { case db.AppAnsible: return &AnsibleApp{ Template: template, Repository: repository, Logger: logger, Playbook: &AnsiblePlaybook{ TemplateID: template.ID, Repository: repository, Logger: logger, }, } case db.AppTerraform, db.AppTofu, db.AppTerragrunt: return &TerraformApp{ Template: template, Repository: repository, Logger: logger, Name: string(template.App), Inventory: inventory, } default: return &ShellApp{ Template: template, Repository: repository, Logger: logger, App: template.App, } } } ================================================ FILE: db_lib/CmdGitClient.go ================================================ package db_lib import ( "fmt" "os" "os/exec" "strings" "github.com/semaphoreui/semaphore/pkg/ssh" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" ) type CmdGitClient struct { keyInstaller AccessKeyInstaller } func (c CmdGitClient) makeCmd( r GitRepository, targetDir GitRepositoryDirType, installation ssh.AccessKeyInstallation, args ...string, ) *exec.Cmd { cmd := exec.Command("git") //nolint: gas cmd.Env = append(getEnvironmentVars(), installation.GetGitEnv()...) switch targetDir { case GitRepositoryTmpPath: cmd.Dir = util.Config.GetProjectTmpDir(r.Repository.ProjectID) _, err := os.Stat(cmd.Dir) if err != nil { if os.IsNotExist(err) { err = os.MkdirAll(cmd.Dir, 0755) if err != nil { log.WithError(err).WithFields(log.Fields{ "context": "git", }).Error("failed to create project temp directory") } } else { log.WithError(err).WithFields(log.Fields{ "context": "git", }).Error("failed to check existing project temp directory") } } case GitRepositoryFullPath: cmd.Dir = r.GetFullPath() default: panic("unknown Repository directory type") } cmd.Args = append(cmd.Args, args...) return cmd } func (c CmdGitClient) run(r GitRepository, targetDir GitRepositoryDirType, args ...string) error { var err error keyInstallation, err := c.keyInstaller.Install(r.Repository.SSHKey, db.AccessKeyRoleGit, r.Logger) if err != nil { return err } defer keyInstallation.Destroy() //nolint: errcheck cmd := c.makeCmd(r, targetDir, keyInstallation, args...) r.Logger.LogCmd(cmd) return cmd.Run() } func (c CmdGitClient) output(r GitRepository, targetDir GitRepositoryDirType, args ...string) (out string, err error) { keyInstallation, err := c.keyInstaller.Install(r.Repository.SSHKey, db.AccessKeyRoleGit, r.Logger) if err != nil { return } defer keyInstallation.Destroy() //nolint: errcheck bytes, err := c.makeCmd(r, targetDir, keyInstallation, args...).Output() if err != nil { return } out = strings.Trim(string(bytes), " \n") return } func (c CmdGitClient) Clone(r GitRepository) error { r.Logger.Log("Cloning Repository " + r.Repository.GitURL) var dirName string if r.TmpDirName == "" { dirName = r.Repository.GetDirName(r.TemplateID) } else { dirName = r.TmpDirName } return c.run(r, GitRepositoryTmpPath, "clone", "--recursive", "--branch", r.Repository.GitBranch, r.Repository.GetGitURL(false), dirName) } func (c CmdGitClient) Pull(r GitRepository) error { r.Logger.Log("Updating Repository " + r.Repository.GitURL) err := c.run(r, GitRepositoryFullPath, "pull", "origin", r.Repository.GitBranch) if err != nil { return err } return c.run(r, GitRepositoryFullPath, "submodule", "update", "--init", "--recursive") } func (c CmdGitClient) Checkout(r GitRepository, target string) error { r.Logger.Log("Checkout repository to " + target) return c.run(r, GitRepositoryFullPath, "checkout", target) } func (c CmdGitClient) CanBePulled(r GitRepository) bool { err := c.run(r, GitRepositoryFullPath, "fetch") if err != nil { return false } err = c.run(r, GitRepositoryFullPath, "merge-base", "--is-ancestor", "HEAD", "origin/"+r.Repository.GitBranch) return err == nil } func (c CmdGitClient) GetLastCommitMessage(r GitRepository) (msg string, err error) { r.Logger.Log("Get current commit message") msg, err = c.output(r, GitRepositoryFullPath, "show-branch", "--no-name", "HEAD") if err != nil { return } if len(msg) > 100 { msg = msg[0:100] } return } func (c CmdGitClient) GetLastCommitHash(r GitRepository) (hash string, err error) { r.Logger.Log("Get current commit hash") hash, err = c.output(r, GitRepositoryFullPath, "rev-parse", "HEAD") return } func (c CmdGitClient) GetLastRemoteCommitHash(r GitRepository) (hash string, err error) { out, err := c.output(r, GitRepositoryTmpPath, "ls-remote", r.Repository.GetGitURL(false), r.Repository.GitBranch) if err != nil { return } firstSpaceIndex := strings.IndexAny(out, "\t ") if firstSpaceIndex == -1 { err = fmt.Errorf("can't retreave remote commit hash") } if err != nil { return } hash = out[0:firstSpaceIndex] return } func (c CmdGitClient) GetRemoteBranches(r GitRepository) ([]string, error) { out, err := c.output(r, GitRepositoryTmpPath, "ls-remote", "--heads", r.Repository.GetGitURL(false)) if err != nil { return nil, err } if len(out) == 0 { return []string{}, nil } branches := strings.Split(out, "\n") branchNames := getRepositoryBranchNames(branches) return branchNames, nil } func getRepositoryBranchNames(branches []string) []string { branchNames := make([]string, 0, len(branches)) for _, branch := range branches { parts := strings.Split(branch, "\t") if len(parts) < 2 { continue } refPath := parts[1] if idx := strings.LastIndex(refPath, "/"); idx != -1 { branchName := refPath[idx+1:] branchNames = append(branchNames, branchName) } } return branchNames } ================================================ FILE: db_lib/GitClientFactory.go ================================================ package db_lib import "github.com/semaphoreui/semaphore/util" func CreateDefaultGitClient(keyInstaller AccessKeyInstaller) GitClient { switch util.Config.GitClientId { case util.GoGitClientId: return CreateGoGitClient(keyInstaller) case util.CmdGitClientId: return CreateCmdGitClient(keyInstaller) default: return CreateCmdGitClient(keyInstaller) } } func CreateGoGitClient(keyInstaller AccessKeyInstaller) GitClient { return GoGitClient{ keyInstaller: keyInstaller, } } func CreateCmdGitClient(keyInstaller AccessKeyInstaller) GitClient { return CmdGitClient{ keyInstaller: keyInstaller, } } ================================================ FILE: db_lib/GitRepository.go ================================================ package db_lib import ( "github.com/semaphoreui/semaphore/util" "os" "path" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/task_logger" ) type GitRepositoryDirType int const ( GitRepositoryTmpPath GitRepositoryDirType = iota GitRepositoryFullPath ) type GitClient interface { Clone(r GitRepository) error Pull(r GitRepository) error Checkout(r GitRepository, target string) error CanBePulled(r GitRepository) bool GetLastCommitMessage(r GitRepository) (msg string, err error) GetLastCommitHash(r GitRepository) (hash string, err error) GetLastRemoteCommitHash(r GitRepository) (hash string, err error) GetRemoteBranches(r GitRepository) ([]string, error) } type GitRepository struct { TmpDirName string TemplateID int Repository db.Repository Logger task_logger.Logger Client GitClient } func (r GitRepository) GetFullPath() string { if r.TmpDirName != "" { return path.Join(util.Config.GetProjectTmpDir(r.Repository.ProjectID), r.TmpDirName) } return r.Repository.GetFullPath(r.TemplateID) } func (r GitRepository) ValidateRepo() error { _, err := os.Stat(r.GetFullPath()) return err } func (r GitRepository) Clone() error { return r.Client.Clone(r) } func (r GitRepository) Pull() error { return r.Client.Pull(r) } func (r GitRepository) Checkout(target string) error { return r.Client.Checkout(r, target) } func (r GitRepository) CanBePulled() bool { return r.Client.CanBePulled(r) } func (r GitRepository) GetLastCommitMessage() (msg string, err error) { return r.Client.GetLastCommitMessage(r) } func (r GitRepository) GetLastCommitHash() (hash string, err error) { return r.Client.GetLastCommitHash(r) } func (r GitRepository) GetLastRemoteCommitHash() (hash string, err error) { return r.Client.GetLastRemoteCommitHash(r) } func (r GitRepository) GetRemoteBranches() ([]string, error) { return r.Client.GetRemoteBranches(r) } ================================================ FILE: db_lib/GoGitClient.go ================================================ package db_lib import ( "errors" "fmt" "strings" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/plumbing/transport/ssh" "github.com/go-git/go-git/v5/storage/memory" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/util" ssh2 "golang.org/x/crypto/ssh" ) type GoGitClient struct { keyInstaller AccessKeyInstaller } type ProgressWrapper struct { Logger task_logger.Logger } func (t ProgressWrapper) Write(p []byte) (n int, err error) { s := string(p) if strings.HasPrefix(s, "Counting objects:") || strings.HasPrefix(s, "Compressing objects:") { return len(p), nil } t.Logger.Log(string(p)) return len(p), nil } func (c GoGitClient) getAuthMethod(r GitRepository) (transport.AuthMethod, error) { switch r.Repository.SSHKey.Type { case db.AccessKeySSH: install, err := c.keyInstaller.Install(r.Repository.SSHKey, db.AccessKeyRoleGit, r.Logger) if err != nil { return nil, err } defer install.Destroy() var sshKeyBuff = r.Repository.SSHKey.SshKey.PrivateKey if r.Repository.SSHKey.SshKey.Login == "" { r.Repository.SSHKey.SshKey.Login = "git" } publicKey, sshErr := ssh.NewPublicKeys(r.Repository.SSHKey.SshKey.Login, []byte(sshKeyBuff), r.Repository.SSHKey.SshKey.Passphrase) if sshErr != nil { return nil, sshErr } publicKey.HostKeyCallback = ssh2.InsecureIgnoreHostKey() return publicKey, sshErr case db.AccessKeyLoginPassword: password := &http.BasicAuth{ Username: r.Repository.SSHKey.LoginPassword.Login, Password: r.Repository.SSHKey.LoginPassword.Password, } return password, nil case db.AccessKeyNone: return nil, nil default: return nil, errors.New("unsupported auth method") } } func openRepository(r GitRepository, targetDir GitRepositoryDirType) (*git.Repository, error) { var dir string switch targetDir { case GitRepositoryTmpPath: dir = util.Config.GetProjectTmpDir(r.Repository.ProjectID) case GitRepositoryFullPath: dir = r.GetFullPath() default: panic("unknown Repository directory type") } return git.PlainOpen(dir) } func (c GoGitClient) Clone(r GitRepository) error { r.Logger.Log("Cloning Repository " + r.Repository.GitURL) authMethod, authErr := c.getAuthMethod(r) if authErr != nil { return authErr } cloneOpt := &git.CloneOptions{ URL: r.Repository.GetGitURL(true), Progress: ProgressWrapper{r.Logger}, RecurseSubmodules: git.DefaultSubmoduleRecursionDepth, ReferenceName: plumbing.NewBranchReferenceName(r.Repository.GitBranch), Auth: authMethod, } _, err := git.PlainClone(r.GetFullPath(), false, cloneOpt) if err != nil { r.Logger.Log("Unable to clone repository: " + err.Error()) } return err } func (c GoGitClient) Pull(r GitRepository) error { r.Logger.Log("Updating Repository " + r.Repository.GitURL) rep, err := openRepository(r, GitRepositoryFullPath) if err != nil { return err } wt, err := rep.Worktree() if err != nil { return err } authMethod, authErr := c.getAuthMethod(r) if authErr != nil { return authErr } // Pull the latest changes from the origin remote and merge into the current branch err = wt.Pull(&git.PullOptions{RemoteName: "origin", Auth: authMethod, RecurseSubmodules: git.DefaultSubmoduleRecursionDepth}) if err != nil && err != git.NoErrAlreadyUpToDate { r.Logger.Log("Unable to pull latest changes") return err } return nil } func (c GoGitClient) Checkout(r GitRepository, target string) error { r.Logger.Log("Checkout repository to " + target) rep, err := openRepository(r, GitRepositoryFullPath) if err != nil { return err } wt, err := rep.Worktree() if err != nil { return err } err = wt.Checkout(&git.CheckoutOptions{ Hash: plumbing.NewHash(target), }) return err } func (c GoGitClient) CanBePulled(r GitRepository) bool { rep, err := openRepository(r, GitRepositoryFullPath) if err != nil { return false } authMethod, err := c.getAuthMethod(r) if err != nil { return false } err = rep.Fetch(&git.FetchOptions{ Auth: authMethod, }) if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { return false } head, err := rep.Head() if err != nil { return false } headCommit, err := rep.CommitObject(head.Hash()) if err != nil { return false } hash, err := rep.ResolveRevision(plumbing.Revision("origin/" + r.Repository.GitBranch)) if err != nil { return false } lastCommit, err := rep.CommitObject(*hash) if err != nil { return false } isAncestor, err := headCommit.IsAncestor(lastCommit) return isAncestor && err == nil } func (c GoGitClient) GetLastCommitMessage(r GitRepository) (msg string, err error) { r.Logger.Log("Get current commit message") rep, err := openRepository(r, GitRepositoryFullPath) if err != nil { return } headRef, err := rep.Head() if err != nil { return } headCommit, err := rep.CommitObject(headRef.Hash()) if err != nil { return } msg = headCommit.Message if len(msg) > 100 { msg = msg[0:100] } r.Logger.Log("Message: " + msg) return } func (c GoGitClient) GetLastCommitHash(r GitRepository) (hash string, err error) { r.Logger.Log("Get current commit hash") rep, err := openRepository(r, GitRepositoryFullPath) if err != nil { return } headRef, err := rep.Head() if err != nil { return } hash = headRef.Hash().String() return } func (c GoGitClient) GetLastRemoteCommitHash(r GitRepository) (hash string, err error) { rem := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{ Name: "origin", URLs: []string{r.Repository.GitURL}, }) auth, err := c.getAuthMethod(r) if err != nil { return } refs, err := rem.List(&git.ListOptions{ Auth: auth, }) if err != nil { return } var lastRemoteRef *plumbing.Reference for _, rf := range refs { if rf.Name().Short() == r.Repository.GitBranch { lastRemoteRef = rf } } if lastRemoteRef != nil { hash = lastRemoteRef.Hash().String() } return } func (c GoGitClient) GetRemoteBranches(r GitRepository) ([]string, error) { remote := git.NewRemote(memory.NewStorage(), &config.RemoteConfig{ Name: "origin", URLs: []string{r.Repository.GitURL}, }) auth, err := c.getAuthMethod(r) if err != nil { return nil, fmt.Errorf("failed to create SSH auth method: %w", err) } listOptions := &git.ListOptions{} if auth != nil { listOptions.Auth = auth } refs, err := remote.List(listOptions) if err != nil { return nil, fmt.Errorf("failed to list remote references: %w", err) } branches := make([]string, 0, len(refs)) for _, ref := range refs { if ref.Name().IsBranch() { branches = append(branches, ref.Name().Short()) } } return branches, nil } ================================================ FILE: db_lib/LocalApp.go ================================================ package db_lib import ( "fmt" "os" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/util" ) // getHomeDir returns the HOME directory value for a task based on the configured // HomeDirMode. For "project_home" it returns the project tmp directory. // For "template_dir" and "user_home" it returns the real user HOME (no override). func getHomeDir(repo db.Repository, templateID int) string { switch util.Config.HomeDirMode { case util.HomeDirModeProjectHome: return util.Config.GetProjectTmpDir(repo.ProjectID) case util.HomeDirModeTemplateDir, util.HomeDirModeUserHome: return os.Getenv("HOME") default: return "" } } func getEnvironmentVars() []string { res := []string{ fmt.Sprintf("PATH=%s", os.Getenv("PATH")), } for _, e := range util.Config.ForwardedEnvVars { v := os.Getenv(e) if v != "" { res = append(res, fmt.Sprintf("%s=%s", e, v)) } } for k, v := range util.Config.EnvVars { res = append(res, fmt.Sprintf("%s=%s", k, v)) } return res } type LocalAppRunningArgs struct { CliArgs map[string][]string // Stage-specific args (e.g., "init", "apply", "default") EnvironmentVars []string Inputs map[string]string TaskParams any TemplateParams any Callback func(*os.Process) } type LocalAppInstallingArgs struct { EnvironmentVars []string TplParams any Params any Installer AccessKeyInstaller } type LocalApp interface { SetLogger(logger task_logger.Logger) task_logger.Logger InstallRequirements(args LocalAppInstallingArgs) error Run(args LocalAppRunningArgs) error Clear() } ================================================ FILE: db_lib/LocalApp_test.go ================================================ package db_lib import ( "os" "strings" "testing" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/util" ) // contains checks if a slice contains a specific string func contains(slice []string, item string) bool { for _, s := range slice { if strings.HasPrefix(s, item) { return true } } return false } func TestGetEnvironmentVars(t *testing.T) { os.Setenv("SEMAPHORE_TEST", "test123") //nolint:errcheck os.Setenv("SEMAPHORE_TEST2", "test222") //nolint:errcheck os.Setenv("PASSWORD", "test222") //nolint:errcheck util.Config = &util.ConfigType{ ForwardedEnvVars: []string{"SEMAPHORE_TEST"}, EnvVars: map[string]string{ "ANSIBLE_FORCE_COLOR": "False", }, } res := getEnvironmentVars() expected := []string{ "SEMAPHORE_TEST=test123", "ANSIBLE_FORCE_COLOR=False", "PATH=", } if len(res) != len(expected) { t.Errorf("Expected %v, got %v", expected, res) } for _, e := range expected { if !contains(res, e) { t.Errorf("Expected %v, got %v", expected, res) } } } func TestGetHomeDir(t *testing.T) { repo := db.Repository{ ProjectID: 42, } templateID := 114 // Set a known HOME value for testing originalHome := os.Getenv("HOME") testHome := "/home/testuser" os.Setenv("HOME", testHome) //nolint:errcheck defer os.Setenv("HOME", originalHome) //nolint:errcheck // Save original config and restore after all tests originalConfig := util.Config defer func() { util.Config = originalConfig }() tests := []struct { name string homeDirMode string tmpPath string expectedHome string description string }{ { name: "ProjectHome mode", homeDirMode: util.HomeDirModeProjectHome, tmpPath: "/tmp/semaphore", expectedHome: "/tmp/semaphore/project_42", description: "Should return project temp directory", }, { name: "TemplateDir mode", homeDirMode: util.HomeDirModeTemplateDir, tmpPath: "/tmp/semaphore", expectedHome: testHome, description: "Should return real user HOME", }, { name: "UserHome mode", homeDirMode: util.HomeDirModeUserHome, tmpPath: "/tmp/semaphore", expectedHome: testHome, description: "Should return real user HOME", }, { name: "Empty/default mode", homeDirMode: "", tmpPath: "/tmp/semaphore", expectedHome: "", description: "Should return empty string for unknown mode", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Setup config for this test case util.Config = &util.ConfigType{ HomeDirMode: tt.homeDirMode, TmpPath: tt.tmpPath, } // Call getHomeDir result := getHomeDir(repo, templateID) // Verify the result if result != tt.expectedHome { t.Errorf("%s: expected HOME=%s, got HOME=%s", tt.description, tt.expectedHome, result) } }) } } ================================================ FILE: db_lib/ShellApp.go ================================================ package db_lib import ( "fmt" "os/exec" "strings" "time" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/util" ) type ShellApp struct { Logger task_logger.Logger Template db.Template Repository db.Repository App db.TemplateApp reader bashReader } type bashReader struct { input *string logger task_logger.Logger } func (r *bashReader) Read(p []byte) (n int, err error) { r.logger.SetStatus(task_logger.TaskWaitingConfirmation) for { time.Sleep(time.Second * 3) if r.input != nil { break } } copy(p, *r.input+"\n") r.logger.SetStatus(task_logger.TaskRunningStatus) return len(*r.input) + 1, nil } func (t *ShellApp) makeCmd(command string, args []string, environmentVars []string) *exec.Cmd { cmd := exec.Command(command, args...) //nolint: gas cmd.Dir = t.GetFullPath() cmd.Env = getEnvironmentVars() cmd.Env = append(cmd.Env, fmt.Sprintf("HOME=%s", getHomeDir(t.Repository, t.Template.ID))) cmd.Env = append(cmd.Env, fmt.Sprintf("PWD=%s", cmd.Dir)) cmd.Env = append(cmd.Env, environmentVars...) cmd.SysProcAttr = util.Config.GetSysProcAttr() return cmd } func (t *ShellApp) runCmd(command string, args []string) error { cmd := t.makeCmd(command, args, nil) t.Logger.LogCmd(cmd) return cmd.Run() } func (t *ShellApp) GetFullPath() (path string) { path = t.Repository.GetFullPath(t.Template.ID) return } func (t *ShellApp) SetLogger(logger task_logger.Logger) task_logger.Logger { t.Logger = logger t.Logger.AddStatusListener(func(status task_logger.TaskStatus) { }) t.reader.logger = logger return logger } func (t *ShellApp) Clear() { } func (t *ShellApp) InstallRequirements(args LocalAppInstallingArgs) error { return nil } func (t *ShellApp) makeShellCmd(args []string, environmentVars []string) *exec.Cmd { var command string var appArgs []string switch t.App { case db.AppBash: command = "bash" case db.AppPython: command = "python3" case db.AppPowerShell: command = "powershell" appArgs = []string{"-File"} default: command = string(t.App) } if app, ok := util.Config.Apps[string(t.App)]; ok { if app.AppPath != "" { command = app.AppPath } if app.AppArgs != nil { appArgs = app.AppArgs } } return t.makeCmd(command, append(appArgs, args...), environmentVars) } func (t *ShellApp) Run(args LocalAppRunningArgs) error { // Use "default" key for backward compatibility cliArgs := args.CliArgs["default"] cmd := t.makeShellCmd(cliArgs, args.EnvironmentVars) t.Logger.LogCmd(cmd) //cmd.Stdin = &t.reader cmd.Stdin = strings.NewReader("") err := cmd.Start() if err != nil { return err } args.Callback(cmd.Process) err = cmd.Wait() // Wait for all log processing to complete before returning t.Logger.WaitLog() return err } ================================================ FILE: db_lib/TerraformApp.go ================================================ package db_lib import ( "fmt" "io" "os" "os/exec" "path" "strings" "time" log "github.com/sirupsen/logrus" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/util" ) type TerraformApp struct { Logger task_logger.Logger Template db.Template Repository db.Repository Inventory db.Inventory reader terraformReader // reader Name string // Name is the name of the terraform binary PlanHasNoChanges bool // PlanHasNoChanges is true if terraform plan has no changes backendFilename string // backendFilename is the name of the backend file } type terraformReader struct { EOF bool status task_logger.TaskStatus logger task_logger.Logger } func (r *terraformReader) Read(p []byte) (n int, err error) { if r.EOF { return 0, io.EOF } if r.status != task_logger.TaskWaitingConfirmation { time.Sleep(time.Second * 3) return 0, nil } for { time.Sleep(time.Second * 3) if r.status.IsFinished() || r.status == task_logger.TaskConfirmed || r.status == task_logger.TaskRejected { break } } r.EOF = true switch r.status { case task_logger.TaskConfirmed: copy(p, "yes\n") r.logger.SetStatus(task_logger.TaskRunningStatus) return 4, nil case task_logger.TaskRejected: copy(p, "no\n") r.logger.SetStatus(task_logger.TaskRunningStatus) return 3, nil default: copy(p, "\n") return 1, nil } } func (t *TerraformApp) makeCmd(command string, args []string, environmentVars []string) *exec.Cmd { if app, ok := util.Config.Apps[t.Name]; ok { if app.AppPath != "" { command = app.AppPath } if app.AppArgs != nil { args = append(app.AppArgs, args...) } } if t.Name == string(db.AppTerragrunt) { hasTfPath := false for i := 0; i < len(args); i++ { a := args[i] if a == "--tf-path" || strings.HasPrefix(a, "--tf-path=") { hasTfPath = true break } } if !hasTfPath { args = append(args, "--tf-path=terraform") } } cmd := exec.Command(command, args...) //nolint: gas cmd.Dir = t.GetFullPath() cmd.Env = getEnvironmentVars() cmd.Env = append(cmd.Env, fmt.Sprintf("HOME=%s", getHomeDir(t.Repository, t.Template.ID))) cmd.Env = append(cmd.Env, fmt.Sprintf("PWD=%s", cmd.Dir)) if environmentVars != nil { cmd.Env = append(cmd.Env, environmentVars...) } cmd.SysProcAttr = util.Config.GetSysProcAttr() 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 { logger.AddStatusListener(func(status task_logger.TaskStatus) { t.reader.status = status }) t.reader.logger = logger t.Logger = logger return logger } func (t *TerraformApp) init(environmentVars []string, keyInstaller AccessKeyInstaller, params *db.TerraformTaskParams, extraArgs []string) error { keyInstallation, err := keyInstaller.Install(t.Inventory.SSHKey, db.AccessKeyRoleGit, t.Logger) if err != nil { return err } defer keyInstallation.Destroy() //nolint: errcheck args := []string{"init", "-lock=false"} if params.Upgrade { args = append(args, "-upgrade") } if params.Reconfigure { args = append(args, "-reconfigure") } else { args = append(args, "-migrate-state") } // Add extra args specific to init stage if extraArgs != nil { args = append(args, extraArgs...) } cmd := t.makeCmd(t.Name, args, environmentVars) cmd.Env = append(cmd.Env, keyInstallation.GetGitEnv()...) t.Logger.LogCmd(cmd) t.Logger.AddLogListener(func(new time.Time, msg string) { s := strings.TrimSpace(msg) if strings.Contains(s, "Do you want to copy ") { t.Logger.SetStatus(task_logger.TaskWaitingConfirmation) } else if strings.Contains(msg, "has been successfully initialized!") || strings.Contains(msg, "Error:") { t.reader.EOF = true } }) cmd.Stdin = &t.reader err = cmd.Start() if err != nil { return err } err = cmd.Wait() if err != nil { return err } t.Logger.WaitLog() return nil } func (t *TerraformApp) isWorkspacesSupported(environmentVars []string) bool { args := []string{"workspace", "list"} cmd := t.makeCmd(t.Name, args, environmentVars) err := cmd.Run() if err != nil { return false } return true } func (t *TerraformApp) selectWorkspace(workspace string, environmentVars []string) error { args := []string{"workspace", "select", "-or-create=true", workspace} if t.Name == string(db.AppTerragrunt) { tgArgs := []string{"run"} hasTfPath := false for i := 0; i < len(tgArgs); i++ { a := tgArgs[i] if a == "--tf-path" || strings.HasPrefix(a, "--tf-path=") { hasTfPath = true break } } if !hasTfPath { tgArgs = append(tgArgs, "--tf-path=terraform") } tgArgs = append(tgArgs, "--") args = append(tgArgs, args...) } cmd := t.makeCmd(t.Name, args, environmentVars) t.Logger.LogCmd(cmd) err := cmd.Start() if err != nil { return err } err = cmd.Wait() if err != nil { return err } t.Logger.WaitLog() return nil } func (t *TerraformApp) Clear() { if t.backendFilename == "" { return } err := os.Remove(path.Join(t.GetFullPath(), t.backendFilename)) if os.IsNotExist(err) { err = nil } if err != nil { log.WithError(err).WithFields(log.Fields{ "context": "terraform", "task_id": t.Template.ID, }).Warn("Unable to remove backend file") } } type TerraformInstallRequirementsArgs struct { LocalAppInstallingArgs InitArgs []string // Stage-specific args for init } func (t *TerraformApp) InstallRequirements(args LocalAppInstallingArgs) (err error) { return t.InstallRequirementsWithInitArgs(args, nil) } func (t *TerraformApp) InstallRequirementsWithInitArgs(args LocalAppInstallingArgs, initArgs []string) (err error) { tpl := args.TplParams.(*db.TerraformTemplateParams) p := args.Params.(*db.TerraformTaskParams) if tpl.OverrideBackend { t.backendFilename = "backend.tf" if tpl.BackendFilename != "" { t.backendFilename = tpl.BackendFilename } backendFile := path.Join(t.GetFullPath(), t.backendFilename) err = os.WriteFile(backendFile, []byte("terraform {\n backend \"http\" {\n }\n}\n"), 0644) if err != nil { return } } if err = t.init(args.EnvironmentVars, args.Installer, p, initArgs); err != nil { return } workspace := "default" if t.Inventory.Inventory != "" { workspace = t.Inventory.Inventory } if !t.isWorkspacesSupported(args.EnvironmentVars) { return } err = t.selectWorkspace(workspace, args.EnvironmentVars) return } func (t *TerraformApp) Plan(args []string, environmentVars []string, inputs map[string]string, cb func(*os.Process)) error { planArgs := []string{"plan", "-lock=false"} planArgs = append(planArgs, args...) cmd := t.makeCmd(t.Name, planArgs, environmentVars) t.Logger.LogCmd(cmd) t.reader.logger.AddLogListener(func(new time.Time, msg string) { if strings.Contains(msg, "No changes.") { t.PlanHasNoChanges = true } }) cmd.Stdin = strings.NewReader("") err := cmd.Start() if err != nil { return err } cb(cmd.Process) err = cmd.Wait() if err != nil { return err } t.Logger.WaitLog() return nil } func (t *TerraformApp) Apply(args []string, environmentVars []string, inputs map[string]string, cb func(*os.Process)) error { applyArgs := []string{"apply", "-auto-approve", "-lock=false"} applyArgs = append(applyArgs, args...) cmd := t.makeCmd(t.Name, applyArgs, environmentVars) t.Logger.LogCmd(cmd) cmd.Stdin = strings.NewReader("") err := cmd.Start() if err != nil { return err } cb(cmd.Process) err = cmd.Wait() if err != nil { return err } t.Logger.WaitLog() return nil } func (t *TerraformApp) Run(args LocalAppRunningArgs) error { // Determine which args to use for plan and apply stages var planArgs []string var applyArgs []string // Use stage-specific args from map, with "default" fallback if pArgs, ok := args.CliArgs["plan"]; ok { planArgs = pArgs } else if aArgs, ok := args.CliArgs["apply"]; ok { applyArgs = aArgs } else if defaultArgs, ok := args.CliArgs["default"]; ok { planArgs = defaultArgs } if aArgs, ok := args.CliArgs["apply"]; ok { applyArgs = aArgs } else if defaultArgs, ok := args.CliArgs["default"]; ok { applyArgs = defaultArgs } err := t.Plan(planArgs, args.EnvironmentVars, args.Inputs, args.Callback) if err != nil { return err } params := args.TaskParams.(*db.TerraformTaskParams) tplParams := args.TemplateParams.(*db.TerraformTemplateParams) if t.PlanHasNoChanges || params.Plan { t.Logger.SetStatus(task_logger.TaskSuccessStatus) return nil } if tplParams.AutoApprove || tplParams.AllowAutoApprove && params.AutoApprove { t.Logger.SetStatus(task_logger.TaskRunningStatus) return t.Apply(applyArgs, args.EnvironmentVars, args.Inputs, args.Callback) } t.Logger.SetStatus(task_logger.TaskWaitingConfirmation) for { time.Sleep(time.Second * 3) if t.reader.status.IsFinished() || t.reader.status == task_logger.TaskConfirmed || t.reader.status == task_logger.TaskRejected { break } } switch t.reader.status { case task_logger.TaskRejected: t.Logger.SetStatus(task_logger.TaskFailStatus) case task_logger.TaskConfirmed: t.Logger.SetStatus(task_logger.TaskRunningStatus) return t.Apply(applyArgs, args.EnvironmentVars, args.Inputs, args.Callback) } return nil } ================================================ FILE: deployment/compose/README.md ================================================ # Compose With the `docker-compose` snippets within this directory you are able to plug different setups of Semaphore UI together. Below you can find some example combinations. Some of the snippets define environment variables which could be optionally overwritten if needed. ## Server First of all we need the server definition and we need to decide if we want to build the image dynamically or if we just want to use a released image. ### Build This simply takes the currently cloned source and builds a new image including all local changes. ```console docker-compose -f deployment/compose/server/base.yml -f deployment/compose/server/build.yml up ``` ### Image This simply downloads the defined image from DockerHub and starts/configures it properly based on the integrated bootstrapping scripts. ```console docker-compose -f deployment/compose/server/base.yml -f deployment/compose/server/image.yml up ``` ### Config If you want to provide a custom `config.json` file to add options which are not exposed as environment variables you could add this snippet which sources the file from the current working directory. ```console docker-compose -f deployment/compose/server/config.yml up ``` ## Runner If you want to try the remote runner functionality of Semaphore you could just add this snippet to get a runner up and connected to semaphore. Similar to the examples above for the server you got different options like building the runner from the source or using our prebuilt images. ### Build This simply takes the currently cloned source and builds a new image including all local changes. ```console docker-compose -f deployment/compose/runner/base.yml -f deployment/compose/runner/build.yml up ``` ### Image This simply downloads the defined image from DockerHub and starts/configures it properly based on the integrated bootstrapping scripts. ```console docker-compose -f deployment/compose/runner/base.yml -f deployment/compose/runner/image.yml up ``` ### Config If you want to provide a custom `config.json` file to add options which are not exposed as environment variables you could add this snippet which sources the file from the current working directory. ```console docker-compose -f deployment/compose/runner/config.yml up ``` ## Database After deciding the base of it you should choose one of the supported databases. Here we got currently the following options so far. ### SQLite This simply configures a named volume for the SQLite storage used as a database backend. ```console docker-compose -f deployment/compose/store/sqlite.yml up ``` ### BoltDB This simply configures a named volume for the BoltDB storage used as a database backend. ```console docker-compose -f deployment/compose/store/boltdb.yml up ``` ### MariaDB This simply starts an additional container for a MariaDB instance used as a database backend including the required credentials. ```console docker-compose -f deployment/compose/store/mariadb.yml up ``` ### MySQL This simply starts an additional container for a MySQL instance used as a database backend including the required credentials. ```console docker-compose -f deployment/compose/store/mysql.yml up ``` ### PostgreSQL This simply starts an additional container for a PostgreSQL instance used as a database backend including the required credentials. ```console docker-compose -f deployment/compose/store/postgres.yml up ``` ## Cleanup After playing with the setup you are able to stop the whole setup by just replacing `up` at the end of the command with `down`. ================================================ FILE: deployment/compose/dredd/base.yml ================================================ version: "3.4" volumes: dredd: services: server: environment: SEMAPHORE_ADMIN_PASSWORD: password SEMAPHORE_ADMIN_NAME: Developer SEMAPHORE_ADMIN_EMAIL: admin@localhost SEMAPHORE_ADMIN: admin SEMAPHORE_WEB_ROOT: http://0.0.0.0:3000 dredd: build: context: ../../../ dockerfile: deployment/docker/dredd/Dockerfile command: - --config - .dredd/dredd.docker.yml environment: SEMAPHORE_ACCESS_KEY_ENCRYPTION: ${SEMAPHORE_ACCESS_KEY_ENCRYPTION:-IlRqgrrO5Gp27MlWakDX1xVrPv4jhoUx+ARY+qGyDxQ=} volumes: - dredd:/data ================================================ FILE: deployment/compose/dredd/boltdb.yml ================================================ version: "3.4" services: dredd: environment: SEMAPHORE_DB_DIALECT: bolt SEMAPHORE_DB_CONFIG: '{"host": "/data/database.boltdb"}' depends_on: - server ================================================ FILE: deployment/compose/dredd/mariadb.yml ================================================ version: "3.4" services: dredd: environment: SEMAPHORE_DB_DIALECT: mysql SEMAPHORE_DB_CONFIG: '{"host": "db:3306","user": "semaphore","pass": "semaphore","name": "semaphore"}' depends_on: - server - db ================================================ FILE: deployment/compose/dredd/mysql.yml ================================================ version: "3.4" services: dredd: environment: SEMAPHORE_DB_DIALECT: mysql SEMAPHORE_DB_CONFIG: '{"host": "db:3306","user": "semaphore","pass": "semaphore","name": "semaphore"}' depends_on: - server - db ================================================ FILE: deployment/compose/dredd/postgres.yml ================================================ version: "3.4" services: dredd: environment: SEMAPHORE_DB_DIALECT: postgres SEMAPHORE_DB_CONFIG: '{"host": "db:5432","user": "semaphore","pass": "semaphore","name": "semaphore","options": {"sslmode": "disable"}}' depends_on: - server - db ================================================ FILE: deployment/compose/dredd/sqlite.yml ================================================ version: "3.4" services: dredd: environment: SEMAPHORE_DB_DIALECT: sqlite SEMAPHORE_DB_CONFIG: '{"host": "/data/database.sqlite3"}' depends_on: - server ================================================ FILE: deployment/compose/runner/base.yml ================================================ version: "3.4" services: runner: image: docker.io/semaphoreui/runner:${SEMAPHORE_VERSION:-latest} restart: always environment: SEMAPHORE_WEB_ROOT: ${SEMAPHORE_WEB_ROOT:-http://server:3000} SEMAPHORE_RUNNER_API_URL: ${SEMAPHORE_RUNNER_API_URL:-http://server:3000/internal} SEMAPHORE_RUNNER_REGISTRATION_TOKEN: ${SEMAPHORE_RUNNER_REGISTRATION_TOKEN:-H1wDyorbg6gTSwJlVwle2Fne} server: environment: SEMAPHORE_RUNNER_REGISTRATION_TOKEN: ${SEMAPHORE_RUNNER_REGISTRATION_TOKEN:-H1wDyorbg6gTSwJlVwle2Fne} ================================================ FILE: deployment/compose/runner/build.yml ================================================ version: "3.4" services: runner: build: context: ../../../ dockerfile: deployment/docker/runner/Dockerfile ================================================ FILE: deployment/compose/runner/config.yml ================================================ version: "3.4" services: runner: volumes: - ${SEMAPHORE_RUNNER_LOCAL_CONFIG:-runner.json}:/etc/semaphore/config.json:Z ================================================ FILE: deployment/compose/server/base.yml ================================================ version: "3.4" volumes: server: services: server: image: docker.io/semaphoreui/semaphore:${SEMAPHORE_VERSION:-latest} restart: always environment: SEMAPHORE_ADMIN_NAME: ${SEMAPHORE_ADMIN_NAME:-Admin} SEMAPHORE_ADMIN: ${SEMAPHORE_ADMIN_USERNAME:-admin} SEMAPHORE_ADMIN_PASSWORD: ${SEMAPHORE_ADMIN_PASSWORD:-p455w0rd} SEMAPHORE_ADMIN_EMAIL: ${SEMAPHORE_ADMIN_EMAIL:-admin@localhost} SEMAPHORE_WEB_ROOT: ${SEMAPHORE_WEB_ROOT:-http://0.0.0.0:3000} SEMAPHORE_ACCESS_KEY_ENCRYPTION: ${SEMAPHORE_ACCESS_KEY_ENCRYPTION:-IlRqgrrO5Gp27MlWakDX1xVrPv4jhoUx+ARY+qGyDxQ=} volumes: - server:/var/lib/semaphore ports: - "3000:3000" ================================================ FILE: deployment/compose/server/build.yml ================================================ version: "3.4" services: server: build: context: ../../../ dockerfile: deployment/docker/server/Dockerfile ================================================ FILE: deployment/compose/server/config.yml ================================================ version: "3.4" services: server: volumes: - ${SEMAPHORE_RUNNER_LOCAL_CONFIG:-config.json}:/etc/semaphore/config.json:Z ================================================ FILE: deployment/compose/store/boltdb.yml ================================================ version: "3.4" volumes: boltdb: services: server: environment: SEMAPHORE_DB_DIALECT: bolt SEMAPHORE_DB_PATH: /var/lib/database volumes: - boltdb:/var/lib/database ================================================ FILE: deployment/compose/store/local.yml ================================================ version: "3.4" volumes: mariadb: postgres: services: mariadb: image: mariadb:10.8 restart: always environment: MARIADB_ROOT_PASSWORD: root MARIADB_USER: semaphore MARIADB_PASSWORD: semaphore MARIADB_DATABASE: semaphore volumes: - mariadb:/var/lib/mysql ports: - 3306:3306 postgres: image: postgres:14.3 restart: always environment: POSTGRES_USER: semaphore POSTGRES_PASSWORD: semaphore POSTGRES_DB: semaphore volumes: - postgres:/var/lib/postgresql ports: - 5432:5432 ================================================ FILE: deployment/compose/store/mariadb.yml ================================================ version: "3.4" volumes: mariadb: services: server: environment: SEMAPHORE_DB_DIALECT: mysql SEMAPHORE_DB_HOST: db SEMAPHORE_DB_PORT: 3306 SEMAPHORE_DB_USER: ${MARIADB_USERNAME:-semaphore} SEMAPHORE_DB_PASS: ${MARIADB_PASSWORD:-semaphore} SEMAPHORE_DB: ${MARIADB_DATABASE:-semaphore} depends_on: - db db: image: mariadb:10.8 restart: always environment: MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT:-root} MARIADB_USER: ${MARIADB_USERNAME:-semaphore} MARIADB_PASSWORD: ${MARIADB_PASSWORD:-semaphore} MARIADB_DATABASE: ${MARIADB_DATABASE:-semaphore} volumes: - mariadb:/var/lib/mysql ================================================ FILE: deployment/compose/store/mysql.yml ================================================ version: "3.4" volumes: mysql: services: server: environment: SEMAPHORE_DB_DIALECT: mysql SEMAPHORE_DB_HOST: db SEMAPHORE_DB_PORT: 3306 SEMAPHORE_DB_USER: ${MYSQL_USERNAME:-semaphore} SEMAPHORE_DB_PASS: ${MYSQL_PASSWORD:-semaphore} SEMAPHORE_DB: ${MYSQL_DATABASE:-semaphore} depends_on: - db db: image: mysql:8.0 restart: always environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT:-root} MYSQL_USER: ${MYSQL_USERNAME:-semaphore} MYSQL_PASSWORD: ${MYSQL_PASSWORD:-semaphore} MYSQL_DATABASE: ${MYSQL_DATABASE:-semaphore} volumes: - mysql:/var/lib/mysql ================================================ FILE: deployment/compose/store/postgres.yml ================================================ version: "3.4" volumes: postgres: services: server: environment: SEMAPHORE_DB_DIALECT: postgres SEMAPHORE_DB_HOST: db SEMAPHORE_DB_PORT: 5432 SEMAPHORE_DB_USER: ${POSTGRES_USERNAME:-semaphore} SEMAPHORE_DB_PASS: ${POSTGRES_PASSWORD:-semaphore} SEMAPHORE_DB: ${POSTGRES_DATABASE:-semaphore} depends_on: - db db: image: postgres:14.3 restart: always environment: POSTGRES_USER: ${POSTGRES_USERNAME:-semaphore} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-semaphore} POSTGRES_DB: ${POSTGRES_DATABASE:-semaphore} volumes: - postgres:/var/lib/postgresql ================================================ FILE: deployment/compose/store/sqlite.yml ================================================ version: "3.4" volumes: sqlite: services: server: environment: SEMAPHORE_DB_DIALECT: sqlite SEMAPHORE_DB_PATH: /var/lib/database volumes: - sqlite:/var/lib/database ================================================ FILE: deployment/docker/README.md ================================================ # Docker Generally we are building production-grade images for each tag, latest and even for the development branch which will be pushed to [DockerHub][dockerhub]. If you still need to build your own image you can easily do that, you just need install [Docker][docker] and [Task][gotask] on your system. If you just want to use our pre-built images please follow the instructions on our [documentation][documentation]. If you want to use [docker-compose][dockercompose] to start Semaphore you could also read about it on our [documentation][documentation] or take a look at our collection of [snippets][snippets] within this repository. ## Build We have prepared multiple tasks to build an publish container images, including tasks to verify the image contains all required tools: ```console task docker:build task docker:push ``` If you want to customize the image names or if you want to use [Podman][podman] instead of [Docker][docker] you are able to provide some set of environment variables to the [Task][gotask] command: * `DOCKER_ORG`: Define a custom organization for the image, defaults to `semaphoreui` * `DOCKER_SERVER`: Define a different name for the server image, defaults to `semaphore` * `DOCKER_RUNNER`: Define a different name for the runner image, defaults to `runner` * `DOCKER_CMD`: Use another command to build the image, defaults to `docker` ## Test We defined tasks to handle some linting and to verify the images contain the tools and binaries that are required to run Semaphore. Here we are using [Hadolint][hadolint] to ensure we are mostly following best-practices and [Goss][goss] which is using a configuration file to define the requirements. To install the required tools you also need to install [Golang][golang] on your system, the installation of [Golang][golang] is not covered by us. The installation of the dependencies can be customized by providing environment variables for `INSTALL_PATH` (`/usr/local/bin`) and `REQUIRE_SUDO` (true). ```console task docker:test ``` [dockerhub]: https://hub.docker.com/r/semaphoreui/semaphore [docker]: https://docs.docker.com/engine/install/ [podman]: https://podman.io/docs/installation [gotask]: https://taskfile.dev/installation/ [dockercompose]: https://docs.docker.com/compose/ [golang]: https://go.dev/doc/install [hadolint]: https://github.com/hadolint/hadolint [goss]: https://github.com/goss-org/goss [snippets]: ../compose/README.md [documentation]: https://docs.semaphoreui.com/administration-guide/installation ================================================ FILE: deployment/docker/dredd/Dockerfile ================================================ FROM golang:1.24-alpine3.21 as golang RUN apk add --no-cache -U \ curl git WORKDIR /usr/local # hadolint ignore=DL4006 RUN curl -sL https://taskfile.dev/install.sh | sh WORKDIR /go/src/semaphore COPY . /go/src/semaphore RUN --mount=type=cache,target=/go/pkg \ go mod download -x RUN --mount=type=cache,target=/go/pkg --mount=type=cache,target=/root/.cache/go-build \ task deps:tools && \ task deps:be && \ task dredd:goodman && \ task dredd:hooks FROM apiaryio/dredd:14.0.0 RUN apk add --no-cache -U \ bash git go COPY --from=golang /go/bin/goodman /root/go/bin/goodman COPY --from=golang /go/src/semaphore /semaphore WORKDIR /semaphore COPY deployment/docker/dredd/entrypoint /usr/local/bin ENTRYPOINT ["/usr/local/bin/entrypoint"] ================================================ FILE: deployment/docker/dredd/entrypoint ================================================ #!/usr/bin/env bash set -eo pipefail echo "---> Gen semaphore config" cat << EOF > /semaphore/.dredd/config.json { "dialect": "${SEMAPHORE_DB_DIALECT}", "${SEMAPHORE_DB_DIALECT}": ${SEMAPHORE_DB_CONFIG}, "access_key_encryption": "${SEMAPHORE_ACCESS_KEY_ENCRYPTION}" } EOF echo "---> Waiting for semaphore" while ! nc -z server 3000; do sleep 1 done echo "---> Start dredd server" sleep 5 dredd $@ ================================================ FILE: deployment/docker/runner/Dockerfile ================================================ FROM --platform=$BUILDPLATFORM golang:1.24-alpine3.21 as builder RUN apk add --no-cache -U \ libc-dev curl nodejs npm git gcc zip unzip tar WORKDIR /usr/local # hadolint ignore=DL4006 RUN curl -sL https://taskfile.dev/install.sh | sh WORKDIR /go/src/semaphore COPY . /go/src/semaphore RUN --mount=type=cache,target=/go/pkg \ go mod download -x ARG APP_BUILD_TYPE ARG TARGETOS ARG TARGETARCH ARG GH_TOKEN RUN if [ -n "$APP_BUILD_TYPE" ]; then \ git clone https://${GH_TOKEN}@github.com/semaphoreui/semaphorepro-module.git pro_impl && \ go work init . ./pro_impl; \ fi RUN --mount=type=cache,target=/go/pkg \ --mount=type=cache,target=/root/.cache/go-build \ task deps APP_BUILD_TYPE=${APP_BUILD_TYPE} && \ task build GOOS=${TARGETOS} GOARCH=${TARGETARCH} APP_BUILD_TYPE=${APP_BUILD_TYPE} ENV OPENTOFU_VERSION="1.11.0" ENV TERRAFORM_VERSION="1.10.3" ENV TERRAGRUNT_VERSION="0.78.0" #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 wget -O /tmp/terragrunt https://github.com/gruntwork-io/terragrunt/releases/download/v${TERRAGRUNT_VERSION}/terragrunt_linux_${TARGETARCH} && \ chmod +x /tmp/terragrunt FROM alpine:3.21 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 && \ rm -rf /var/cache/apk/* && \ adduser -D -u 1001 -G root semaphore && \ mkdir -p /tmp/semaphore && \ mkdir -p /etc/semaphore && \ mkdir -p /var/lib/semaphore && \ mkdir -p /opt/semaphore && \ chown -R semaphore:0 /tmp/semaphore && \ chown -R semaphore:0 /etc/semaphore && \ chown -R semaphore:0 /var/lib/semaphore && \ chown -R semaphore:0 /opt/semaphore && \ find /usr/lib/python* -iname __pycache__ | xargs rm -rf COPY --chown=1001:0 ./deployment/docker/runner/ansible.cfg /etc/ansible/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/ COPY --from=builder /tmp/terraform /usr/local/bin/ COPY --from=builder /tmp/terragrunt /usr/local/bin/ RUN chown -R semaphore:0 /usr/local/bin/runner-wrapper && \ chmod +x /usr/local/bin/runner-wrapper && \ chown -R semaphore:0 /usr/local/bin/semaphore && \ chmod +x /usr/local/bin/semaphore WORKDIR /home/semaphore # renovate: datasource=pypi depName=ansible ARG ANSIBLE_VERSION=11.1.0 ENV ANSIBLE_VERSION=${ANSIBLE_VERSION} 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 pywinrm passlib && \ 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 RUN echo 'Host *\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null' > /etc/ssh/ssh_config.d/semaphore.conf USER 1001 ENV VIRTUAL_ENV="$ANSIBLE_VENV_PATH" ENV PATH="$ANSIBLE_VENV_PATH/bin:$PATH" # Preventing ansible zombie processes. Tini kills zombies. ENTRYPOINT ["/sbin/tini", "--"] CMD [ "/usr/local/bin/runner-wrapper"] ================================================ FILE: deployment/docker/runner/ansible.cfg ================================================ [defaults] host_key_checking = False bin_ansible_callbacks = True stdout_callback = default callback_result_format = yaml ================================================ FILE: deployment/docker/runner/goss.yaml ================================================ file: /usr/local/bin/runner-wrapper: exists: true owner: semaphore group: root filetype: file /usr/local/bin/semaphore: exists: true owner: semaphore group: root filetype: file package: go: installed: false libc-dev: installed: false nodejs: installed: false curl: installed: true git: installed: true mysql-client: installed: true openssh-client-default: installed: true python3: installed: true py3-pip: installed: true rsync: installed: true sshpass: installed: true tar: installed: true tini: installed: true tzdata: installed: true unzip: installed: true wget: installed: true zip: installed: true user: semaphore: exists: true uid: 1001 gid: 0 home: /home/semaphore command: semaphore: exit-status: 0 timeout: 10000 ================================================ FILE: deployment/docker/runner/runner-wrapper ================================================ #!/bin/sh set -e echoerr() { printf "%s\n" "$*" >&2; } export SEMAPHORE_CONFIG_PATH="${SEMAPHORE_CONFIG_PATH:-/etc/semaphore}" export SEMAPHORE_TMP_PATH="${SEMAPHORE_TMP_PATH:-/tmp/semaphore}" if test -f "${SEMAPHORE_CONFIG_PATH}/requirements.txt"; then echoerr "Installing additional python dependencies" pip3 install --upgrade \ -r "${SEMAPHORE_CONFIG_PATH}/requirements.txt" else echoerr "No additional python dependencies to install" fi echoerr "Starting semaphore runner" if test "$#" -ne 1; then if [ -n "${SEMAPHORE_RUNNER_REGISTRATION_TOKEN:-}" ]; then if [ -z "${SEMAPHORE_RUNNER_TOKEN:-}" ] && [ -z "${SEMAPHORE_RUNNER_TOKEN_FILE:-}" ]; then export SEMAPHORE_RUNNER_TOKEN_FILE="${SEMAPHORE_TMP_PATH}/runner_token.txt" fi exec /usr/local/bin/semaphore runner start --no-config --register else exec /usr/local/bin/semaphore runner start --no-config fi else exec "$@" fi ================================================ FILE: deployment/docker/server/Dockerfile ================================================ FROM --platform=$BUILDPLATFORM golang:1.24-alpine3.21 as builder RUN apk add --no-cache -U \ libc-dev curl nodejs npm git gcc zip unzip tar WORKDIR /usr/local # hadolint ignore=DL4006 RUN curl -sL https://taskfile.dev/install.sh | sh WORKDIR /go/src/semaphore COPY . /go/src/semaphore RUN --mount=type=cache,target=/go/pkg \ go mod download -x ARG APP_BUILD_TYPE ARG TARGETOS ARG TARGETARCH ARG GH_TOKEN RUN if [ -n "$APP_BUILD_TYPE" ]; then \ git clone https://${GH_TOKEN}@github.com/semaphoreui/semaphorepro-module.git pro_impl && \ go work init . ./pro_impl; \ fi RUN --mount=type=cache,target=/go/pkg \ --mount=type=cache,target=/root/.cache/go-build \ task deps APP_BUILD_TYPE=${APP_BUILD_TYPE} && \ task build GOOS=${TARGETOS} GOARCH=${TARGETARCH} APP_BUILD_TYPE=${APP_BUILD_TYPE} ENV OPENTOFU_VERSION="1.11.0" ENV TERRAFORM_VERSION="1.11.3" ENV TERRAGRUNT_VERSION="0.78.0" #ENV PULUMI_VERSION="3.116.1" #ENV POWERSHELL_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 wget -O /tmp/terragrunt https://github.com/gruntwork-io/terragrunt/releases/download/v${TERRAGRUNT_VERSION}/terragrunt_linux_${TARGETARCH} && \ chmod +x /tmp/terragrunt FROM alpine:3.21 ARG TARGETARCH="amd64" # renovate: datasource=pypi depName=ansible ARG ANSIBLE_VERSION=11.1.0 ENV ANSIBLE_VERSION=${ANSIBLE_VERSION} 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 py3-pip rsync sshpass tar tini tzdata unzip wget zip jq && \ rm -rf /var/cache/apk/* && \ adduser -D -u 1001 -G root semaphore && \ mkdir -p /tmp/semaphore && \ mkdir -p /etc/semaphore && \ mkdir -p /var/lib/semaphore && \ mkdir -p /opt/semaphore && \ chown -R semaphore:0 /tmp/semaphore && \ chown -R semaphore:0 /etc/semaphore && \ chown -R semaphore:0 /var/lib/semaphore && \ chown -R semaphore:0 /opt/semaphore && \ find /usr/lib/python* -iname __pycache__ | xargs rm -rf RUN echo $'Host *\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null' > /etc/ssh/ssh_config.d/semaphore.conf COPY --chown=1001:0 ./deployment/docker/server/ansible.cfg /etc/ansible/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/ COPY --from=builder /tmp/terraform /usr/local/bin/ COPY --from=builder /tmp/terragrunt /usr/local/bin/ RUN chown -R semaphore:0 /usr/local/bin/server-wrapper && \ chmod +x /usr/local/bin/server-wrapper && \ chown -R semaphore:0 /usr/local/bin/semaphore && \ chmod +x /usr/local/bin/semaphore WORKDIR /home/semaphore 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 pywinrm passlib && \ 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 EXPOSE 3000 ENV VIRTUAL_ENV="$ANSIBLE_VENV_PATH" ENV PATH="$ANSIBLE_VENV_PATH/bin:$PATH" # Preventing ansible zombie processes. Tini kills zombies. ENTRYPOINT ["/sbin/tini", "--"] CMD [ "/usr/local/bin/server-wrapper"] ================================================ FILE: deployment/docker/server/ansible.cfg ================================================ [defaults] host_key_checking = False bin_ansible_callbacks = True stdout_callback = default callback_result_format = yaml ================================================ FILE: deployment/docker/server/goss.yaml ================================================ file: /usr/local/bin/server-wrapper: exists: true owner: semaphore group: root filetype: file /usr/local/bin/semaphore: exists: true owner: semaphore group: root filetype: file package: go: installed: false # libc-dev: # installed: false nodejs: installed: false curl: installed: true git: installed: true mysql-client: installed: true openssh-client-default: installed: true python3: installed: true py3-pip: installed: true rsync: installed: true sshpass: installed: true tar: installed: true tini: installed: true tzdata: installed: true unzip: installed: true wget: installed: true zip: installed: true user: semaphore: exists: true uid: 1001 gid: 0 home: /home/semaphore command: semaphore: exit-status: 0 timeout: 10000 ================================================ FILE: deployment/docker/server/powershell/Dockerfile ================================================ ARG SEMAPHORE_IMAGE ARG SEMAPHORE_VERSION FROM ${SEMAPHORE_IMAGE}:${SEMAPHORE_VERSION} ARG TARGETARCH ARG POWERSHELL_VERSION="7.5.0" USER root # Install dependencies RUN apk add --no-cache \ ca-certificates \ less \ ncurses-terminfo-base \ krb5-libs \ libgcc \ libintl \ libssl3 \ libstdc++ \ tzdata \ userspace-rcu \ zlib \ icu-libs \ curl RUN apk -X https://dl-cdn.alpinelinux.org/alpine/edge/main add --no-cache \ lttng-ust \ openssh-client RUN wget -O /tmp/powershell.tar.gz https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/powershell-${POWERSHELL_VERSION}-linux-musl-${TARGETARCH/amd/x}.tar.gz RUN mkdir -p /opt/microsoft/powershell/${POWERSHELL_VERSION} \ && tar zxf /tmp/powershell.tar.gz -C /opt/microsoft/powershell/${POWERSHELL_VERSION} \ && rm /tmp/powershell.tar.gz \ && chmod +x /opt/microsoft/powershell/${POWERSHELL_VERSION}/pwsh \ && ln -s /opt/microsoft/powershell/${POWERSHELL_VERSION}/pwsh /usr/local/bin/pwsh \ && ln -s /opt/microsoft/powershell/${POWERSHELL_VERSION}/pwsh /usr/local/bin/powershell USER 1001 ================================================ FILE: deployment/docker/server/server-wrapper ================================================ #!/bin/sh set -e echoerr() { printf "%s\n" "$*" >&2; } # # Read environment variables from file if envrionment variable ${1}_FILE is set # file_env() { local var="" local fileVar="" eval var="\$${1}" eval fileVar="\$${1}_FILE" local def="${2:-}" if [ -n "${var:-}" ] && [ -n "${fileVar:-}" ]; then echo >&2 "error: both ${1} and ${1}_FILE are set (but are exclusive)" exit 1 fi local val="$def" if [ -n "${var:-}" ]; then val="${var}" elif [ -n "${fileVar:-}" ]; then val="$(cat "${fileVar}")" fi if [ -n "${val:-}" ]; then export "${1}"="$val" fi unset "${1}_FILE" } export SEMAPHORE_CONFIG_PATH="${SEMAPHORE_CONFIG_PATH:-/etc/semaphore}" export SEMAPHORE_DB_PATH="${SEMAPHORE_DB_PATH:-/var/lib/semaphore}" export SEMAPHORE_DB_PORT="${SEMAPHORE_DB_PORT:-}" file_env 'SEMAPHORE_DB_USER' file_env 'SEMAPHORE_DB_PASS' file_env 'SEMAPHORE_ADMIN' export SEMAPHORE_ADMIN_EMAIL="${SEMAPHORE_ADMIN_EMAIL:-admin@localhost}" export SEMAPHORE_ADMIN_NAME="${SEMAPHORE_ADMIN_NAME:-Semaphore Admin}" file_env 'SEMAPHORE_ADMIN_PASSWORD' export SEMAPHORE_LDAP_ACTIVATED="${SEMAPHORE_LDAP_ACTIVATED:-no}" export SEMAPHORE_LDAP_HOST="${SEMAPHORE_LDAP_HOST:-}" export SEMAPHORE_LDAP_PORT="${SEMAPHORE_LDAP_PORT:-}" export SEMAPHORE_LDAP_DN_BIND="${SEMAPHORE_LDAP_DN_BIND:-}" file_env 'SEMAPHORE_LDAP_PASSWORD' export SEMAPHORE_LDAP_DN_SEARCH="${SEMAPHORE_LDAP_DN_SEARCH:-}" export SEMAPHORE_LDAP_MAPPING_USERNAME="${SEMAPHORE_LDAP_MAPPING_USERNAME:-uid}" export SEMAPHORE_LDAP_MAPPING_FULLNAME="${SEMAPHORE_LDAP_MAPPING_FULLNAME:-cn}" export SEMAPHORE_LDAP_MAPPING_EMAIL="${SEMAPHORE_LDAP_MAPPING_EMAIL:-mail}" file_env 'SEMAPHORE_ACCESS_KEY_ENCRYPTION' [ -d "${SEMAPHORE_CONFIG_PATH}" ] || mkdir -p "${SEMAPHORE_CONFIG_PATH}" || { echo "Can't create Semaphore config path ${SEMAPHORE_CONFIG_PATH}." exit 1 } [ -d "${SEMAPHORE_DB_PATH}" ] || mkdir -p "${SEMAPHORE_DB_PATH}" || { echo "Can't create Semaphore data path ${SEMAPHORE_DB_PATH}." exit 1 } # # Extract database host and port from config.json if they are not set. # Set default SEMAPHORE_DB_DIALECT and SEMAPHORE_DB_HOST if empty. # if [ -z "${SEMAPHORE_DB_DIALECT}" ]; then if [ -f "${SEMAPHORE_CONFIG_PATH}/config.json" ]; then SEMAPHORE_DB_DIALECT=$(cat "${SEMAPHORE_CONFIG_PATH}/config.json" | jq '.dialect // ""' -r) fi fi export SEMAPHORE_DB_DIALECT="${SEMAPHORE_DB_DIALECT:-mysql}" if [ -z "${SEMAPHORE_DB_HOST}" ]; then if [ -f "${SEMAPHORE_CONFIG_PATH}/config.json" ]; then SEMAPHORE_DB_HOST=$(cat "${SEMAPHORE_CONFIG_PATH}/config.json" | jq ".${SEMAPHORE_DB_DIALECT}.host // \"\"" -r) fi fi if [ -z "${SEMAPHORE_DB_HOST}" ]; then if [ "${SEMAPHORE_DB_DIALECT}" = 'bolt' ]; then export SEMAPHORE_DB_HOST=${SEMAPHORE_DB_PATH}/database.boltdb elif [ "${SEMAPHORE_DB_DIALECT}" = 'sqlite' ]; then export SEMAPHORE_DB_HOST=${SEMAPHORE_DB_PATH}/database.sqlite else export SEMAPHORE_DB_HOST="${SEMAPHORE_DB_HOST:-0.0.0.0}" fi fi # # Remove port number from SEMAPHORE_DB_HOST and put it to SEMAPHORE_DB_PORT # case "$SEMAPHORE_DB_HOST" in *:*) SEMAPHORE_DB_PORT=$(echo "$SEMAPHORE_DB_HOST" | cut -d ':' -f 2) SEMAPHORE_DB_HOST=$(echo "$SEMAPHORE_DB_HOST" | cut -d ':' -f 1) ;; *) esac # # Set SEMAPHORE_DB_PORT if it is not set # if [ -z "${SEMAPHORE_DB_PORT}" ]; then case ${SEMAPHORE_DB_DIALECT} in mysql) SEMAPHORE_DB_PORT=3306 ;; postgres) SEMAPHORE_DB_PORT=5432 ;; bolt) ;; sqlite) ;; *) echoerr "Unknown database dialect: ${SEMAPHORE_DB_DIALECT}" exit 1 ;; esac fi # # Ping database if it is not BoltDB # if [ "${SEMAPHORE_DB_DIALECT}" != 'bolt' ] && [ "${SEMAPHORE_DB_DIALECT}" != 'sqlite' ]; then echoerr "Pinging database on ${SEMAPHORE_DB_HOST} port ${SEMAPHORE_DB_PORT}..." TIMEOUT=30 while ! $(nc -z "$SEMAPHORE_DB_HOST" "$SEMAPHORE_DB_PORT") >/dev/null 2>&1; do TIMEOUT=$(expr $TIMEOUT - 1) if [ $TIMEOUT -eq 0 ]; then echoerr "Could not connect to database server. Exiting." exit 1 fi echo -n "." sleep 1 done export SEMAPHORE_DB_HOST="${SEMAPHORE_DB_HOST}:${SEMAPHORE_DB_PORT}" fi # # Generate new config.json if it does not exist # SEMAPHORE_FIRST_RUN=no if [ ! -f "${SEMAPHORE_CONFIG_PATH}/config.json" ]; then SEMAPHORE_FIRST_RUN=yes echoerr "Generating setup file ${TMP_STDIN_CONFIG_FILE} ..." TMP_STDIN_CONFIG_FILE=$(mktemp) SEMAPHORE_TMP_PATH=${SEMAPHORE_TMP_PATH:-/tmp/semaphore} [ -d "${SEMAPHORE_TMP_PATH}" ] || mkdir -p "${SEMAPHORE_TMP_PATH}" || { echo "Can't create Semaphore tmp path ${SEMAPHORE_TMP_PATH}." exit 1 } case ${SEMAPHORE_DB_DIALECT} in mysql) SEMAPHORE_DB_DIALECT_ID=1 ;; bolt) SEMAPHORE_DB_DIALECT_ID=2 ;; postgres) SEMAPHORE_DB_DIALECT_ID=3 ;; sqlite) SEMAPHORE_DB_DIALECT_ID=4 ;; *) echoerr "Unknown database dialect: ${SEMAPHORE_DB_DIALECT}" exit 1 ;; esac cat << EOF > "${TMP_STDIN_CONFIG_FILE}" ${SEMAPHORE_DB_DIALECT_ID} EOF if [ "${SEMAPHORE_DB_DIALECT}" = "bolt" ]; then cat << EOF >> "${TMP_STDIN_CONFIG_FILE}" ${SEMAPHORE_DB_HOST} EOF elif [ "${SEMAPHORE_DB_DIALECT}" = "sqlite" ]; then cat << EOF >> "${TMP_STDIN_CONFIG_FILE}" ${SEMAPHORE_DB_HOST} EOF else cat << EOF >> "${TMP_STDIN_CONFIG_FILE}" ${SEMAPHORE_DB_HOST} ${SEMAPHORE_DB_USER} ${SEMAPHORE_DB_PASS} ${SEMAPHORE_DB:-semaphore} EOF fi cat << EOF >> "${TMP_STDIN_CONFIG_FILE}" ${SEMAPHORE_TMP_PATH} ${SEMAPHORE_WEB_ROOT:-} no no no no no ${SEMAPHORE_LDAP_ACTIVATED} EOF if [ "${SEMAPHORE_LDAP_ACTIVATED}" = "yes" ]; then cat << EOF >> "${TMP_STDIN_CONFIG_FILE}" ${SEMAPHORE_LDAP_HOST}:${SEMAPHORE_LDAP_PORT} ${SEMAPHORE_LDAP_NEEDTLS:-no} ${SEMAPHORE_LDAP_DN_BIND} ${SEMAPHORE_LDAP_PASSWORD} ${SEMAPHORE_LDAP_DN_SEARCH} ${SEMAPHORE_LDAP_SEARCH_FILTER:-(uid=%s)} ${SEMAPHORE_LDAP_MAPPING_DN:-dn} ${SEMAPHORE_LDAP_MAPPING_USERNAME} ${SEMAPHORE_LDAP_MAPPING_FULLNAME} ${SEMAPHORE_LDAP_MAPPING_EMAIL} EOF fi; cat << EOF >> "${TMP_STDIN_CONFIG_FILE}" ${SEMAPHORE_CONFIG_PATH} ${SEMAPHORE_ADMIN} ${SEMAPHORE_ADMIN_EMAIL} ${SEMAPHORE_ADMIN_NAME} ${SEMAPHORE_ADMIN_PASSWORD} EOF echoerr "Executing semaphore setup" if test "$#" -ne 1; then /usr/local/bin/semaphore setup - < "${TMP_STDIN_CONFIG_FILE}" else "$1" setup - < "${TMP_STDIN_CONFIG_FILE}" fi rm -f "${TMP_STDIN_CONFIG_FILE}" fi # # Install additional python dependencies # if test -f "${SEMAPHORE_CONFIG_PATH}/requirements.txt"; then echoerr "Installing additional python dependencies" pip3 install --upgrade \ -r "${SEMAPHORE_CONFIG_PATH}/requirements.txt" else echoerr "No additional python dependencies to install" fi # # Migrate from BoltDB if SEMAPHORE_MIGRATE_FROM_BOLTDB is set (first run only, SQLite only, skipped if DB file already exists) # if [ -n "${SEMAPHORE_MIGRATE_FROM_BOLTDB:-}" ] && [ "${SEMAPHORE_FIRST_RUN}" = "yes" ]; then if [ "${SEMAPHORE_DB_DIALECT}" != "sqlite" ]; then echoerr "SEMAPHORE_MIGRATE_FROM_BOLTDB is only supported with SQLite dialect, ignoring" else echoerr "Migrating from BoltDB: ${SEMAPHORE_MIGRATE_FROM_BOLTDB}" MIGRATE_ARGS="--from-boltdb=${SEMAPHORE_MIGRATE_FROM_BOLTDB} --merge-existing-users" if [ -n "${SEMAPHORE_MIGRATE_SKIP_TASK_OUTPUT:-}" ]; then MIGRATE_ARGS="${MIGRATE_ARGS} --skip-task-output" fi if test "$#" -ne 1; then /usr/local/bin/semaphore migrate ${MIGRATE_ARGS} --config "${SEMAPHORE_CONFIG_PATH}/config.json" else "$1" migrate ${MIGRATE_ARGS} --config "${SEMAPHORE_CONFIG_PATH}/config.json" fi fi fi # Import project if environment variable SEMAPHORE_IMPORT_PROJECT_FILE is defined. # Optionally use SEMAPHORE_IMPORT_PROJECT_NAME to specify the project name. if [ -n "${SEMAPHORE_IMPORT_PROJECT_FILE:-}" ] && [ "${SEMAPHORE_FIRST_RUN}" = "yes" ]; then echoerr "Importing project from ${SEMAPHORE_IMPORT_PROJECT_FILE}" IMPORT_ARGS="--file ${SEMAPHORE_IMPORT_PROJECT_FILE}" if [ -n "${SEMAPHORE_IMPORT_PROJECT_NAME:-}" ]; then echoerr "Using project name: ${SEMAPHORE_IMPORT_PROJECT_NAME}" IMPORT_ARGS="${IMPORT_ARGS} --project-name ${SEMAPHORE_IMPORT_PROJECT_NAME}" fi if test "$#" -ne 1; then /usr/local/bin/semaphore project import ${IMPORT_ARGS} --config "${SEMAPHORE_CONFIG_PATH}/config.json" || echoerr "Project import failed" else "$1" project import ${IMPORT_ARGS} || echoerr "Project import failed" fi fi # # Start Semaphore server # echoerr "Starting semaphore server" if test "$#" -ne 1; then exec /usr/local/bin/semaphore server --config "${SEMAPHORE_CONFIG_PATH}/config.json" else exec "$@" fi ================================================ FILE: deployment/packaging/semaphore.spec ================================================ %global debug_package %{nil} %global _missing_build_ids_terminate_build 0 %global _dwz_low_mem_die_limit 0 Name: semaphore Version: 2.8.90 Release: 1%{?dist} 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/semaphoreui/semaphore Source: https://github.com/semaphoreui/semaphore/archive/refs/tags/v2.8.90.zip BuildRequires: golang BuildRequires: nodejs BuildRequires: nodejs-npm BuildRequires: go-task BuildRequires: git BuildRequires: systemd-rpm-macros Requires: ansible %description 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 %build export SEMAPHORE_VERSION="development" export SEMAPHORE_ARCH="linux_amd64" export SEMAPHORE_CONFIG_PATH="./etc/semaphore" export APP_ROOT="./semaphoreui/" if ! [[ "$PATH" =~ "$HOME/go/bin:" ]] then PATH="$HOME/go/bin:$PATH" fi export PATH go-task all cat > semaphoreui.service < semaphore-setup <> .env echo "AUTHENTIK_SECRET_KEY=$(openssl rand 60 | base64)" >> .env echo AUTHENTIK_TOKEN= >> .env echo SEMAPHORE_LDAP_BIND_PASSWORD= >> .env docker-compose up -d ``` 2. To start the initial setup, navigate to http://localhsot:9000/if/flow/initial-setup/. There you are prompted to set a password for the `akadmin` user (the default user). 3. Set up LDAP in Athentik according to the video tutorial [Authentik - LDAP Generic Setup](https://youtu.be/RtPKMMKRT_E). 4. Set up Athentik LDAP and Semaphore containers: 1. Copy `AUTHENTIK_TOKEN` to clipboard. 2. Open `.env` file and: 1. Paste copied value after `AUTHENTIK_TOKEN=` 2. Enter your `ldapservice` user password after `SEMAPHORE_LDAP_BIND_PASSWORD=` 3. Down and up the stack to apply changes: ``` docker-compose down docker-compose up -d ``` 5. Create new Semaphore project: 1. Open http://localhost:3000 2. Login as `ldapservice` 3. Create demo project ================================================ FILE: examples/authentik_ldap/docker-compose.yml ================================================ version: '3.8' services: postgresql: image: docker.io/library/postgres:16-alpine volumes: - database:/var/lib/postgresql/data environment: POSTGRES_PASSWORD: ${PG_PASS:?database password required} POSTGRES_USER: authentik POSTGRES_DB: authentik env_file: - .env redis: image: docker.io/library/redis:alpine volumes: - redis:/data server: image: ghcr.io/goauthentik/server:latest command: server environment: AUTHENTIK_REDIS__HOST: redis AUTHENTIK_POSTGRESQL__HOST: postgresql AUTHENTIK_POSTGRESQL__USER: authentik AUTHENTIK_POSTGRESQL__NAME: authentik AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} AUTHENTIK_LISTEN__LDAP: "0.0.0.0:3389" volumes: - ./media:/media - ./custom-templates:/templates env_file: - .env ports: - "9000:9000" - "9443:9443" depends_on: - postgresql - redis worker: image: ghcr.io/goauthentik/server:latest command: worker environment: AUTHENTIK_REDIS__HOST: redis AUTHENTIK_POSTGRESQL__HOST: postgresql AUTHENTIK_POSTGRESQL__USER: authentik AUTHENTIK_POSTGRESQL__NAME: authentik AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} user: root volumes: - /var/run/docker.sock:/var/run/docker.sock - ./media:/media - ./certs:/certs - ./custom-templates:/templates env_file: - .env depends_on: - postgresql - redis ldap: image: ghcr.io/goauthentik/ldap ports: - "389:3389" - "636:6636" environment: AUTHENTIK_HOST: http://server:9000 AUTHENTIK_INSECURE: "false" env_file: - .env semaphore: image: semaphoreui/semaphore:latest environment: SEMAPHORE_DB_DIALECT: "bolt" SEMAPHORE_ADMIN_PASSWORD: "changeme" SEMAPHORE_ADMIN_NAME: "admin" SEMAPHORE_ADMIN_EMAIL: "admin@example.org" SEMAPHORE_LDAP_ACTIVATED: "yes" SEMAPHORE_LDAP_SERVER: "ldap:3389" SEMAPHORE_LDAP_SEARCH_DN: "ou=users,dc=ldap,dc=goauthentik,dc=io" SEMAPHORE_LDAP_BIND_DN: "cn=ldapservice,ou=users,dc=ldap,dc=goauthentik,dc=io" SEMAPHORE_LDAP_SEARCH_FILTER: "(&(objectClass=inetOrgPerson)(cn=%s))" SEMAPHORE_NON_ADMIN_CAN_CREATE_PROJECT: "yes" SEMAPHORE_LDAP_MAPPING_DN: "dn" SEMAPHORE_LDAP_MAPPING_MAIL: "mail" SEMAPHORE_LDAP_MAPPING_UID: "uid" SEMAPHORE_LDAP_MAPPING_CN: "cn" env_file: - .env ports: - "3000:3000" volumes: - semaphore_data:/var/lib/semaphore depends_on: - ldap volumes: database: redis: semaphore_data: ================================================ FILE: examples/openldap/README.md ================================================ # Semaphore with OpenLDAP example 1. Start stack by command: ``` docker-compose up -d ``` 2. Create new LDAP user: 1. Open https://localhost:6443 2. Login as `cn=admin,dc=example,dc=org` with password `admin` 3. Create new user `john` 3. Create new Semaphore project: 1. Open http://localhost:3000 2. Login as `john` 3. Create demo project ================================================ FILE: examples/openldap/docker-compose.yml ================================================ version: '3.8' services: ldap: image: osixia/openldap:1.5.0 # container_name: openldap environment: LDAP_ORGANISATION: "Example Inc." LDAP_DOMAIN: "example.org" LDAP_ADMIN_PASSWORD: "admin" ports: - "389:389" - "636:636" volumes: - ldap_data:/var/lib/ldap - ldap_config:/etc/ldap/slapd.d ldap_admin: image: osixia/phpldapadmin:0.9.0 # container_name: phpldapadmin environment: PHPLDAPADMIN_LDAP_HOSTS: ldap ports: - "6443:443" depends_on: - ldap semaphore: image: semaphoreui/semaphore:latest # container_name: semaphore environment: SEMAPHORE_DB_DIALECT: "bolt" SEMAPHORE_ADMIN_PASSWORD: "changeme" SEMAPHORE_ADMIN_NAME: "admin" SEMAPHORE_ADMIN_EMAIL: "admin@example.org" SEMAPHORE_LDAP_ACTIVATED: "yes" SEMAPHORE_LDAP_SERVER: "ldap:389" SEMAPHORE_LDAP_SEARCH_DN: "dc=example,dc=org" SEMAPHORE_LDAP_BIND_DN: "cn=admin,dc=example,dc=org" SEMAPHORE_LDAP_BIND_PASSWORD: "admin" SEMAPHORE_LDAP_SEARCH_FILTER: "(&(objectClass=inetOrgPerson)(uid=%s))" SEMAPHORE_NON_ADMIN_CAN_CREATE_PROJECT: "yes" ports: - "3000:3000" volumes: - semaphore_data:/var/lib/semaphore depends_on: - ldap volumes: ldap_data: ldap_config: semaphore_data: ================================================ FILE: examples/terraform_args_example.json ================================================ { "description": "Example of Terraform multi-stage arguments format", "architecture_note": "Array format is automatically converted to map with 'default' key at runtime during task execution", "examples": [ { "name": "Legacy array format (backward compatible)", "arguments": ["-var", "environment=production"], "internal_representation": { "default": ["-var", "environment=production"] }, "behavior": "All stages (init, plan, apply) use the 'default' key args" }, { "name": "New map format with stage-specific args", "arguments": { "init": ["-upgrade"], "plan": ["-var", "environment=production"], "apply": ["-var", "environment=production", "-parallelism=10"] }, "behavior": "Each stage uses its specific args" }, { "name": "Mix of default and stage-specific", "arguments": { "default": ["-var", "common=value"], "init": ["-upgrade"], "apply": ["-parallelism=20"] }, "behavior": "init uses its args, plan uses 'default', apply uses its args" }, { "name": "Terraform with backend configuration", "arguments": { "init": [ "-backend-config=bucket=my-terraform-state", "-backend-config=key=prod/terraform.tfstate" ], "plan": ["-out=tfplan"], "apply": ["tfplan"] } }, { "name": "Multi-environment setup", "arguments": { "init": ["-reconfigure"], "plan": [ "-var-file=environments/production.tfvars", "-out=tfplan" ], "apply": [ "tfplan", "-auto-approve" ] } }, { "name": "Minimal init-only customization with default fallback", "arguments": { "default": ["-var", "environment=prod"], "init": ["-upgrade"] }, "behavior": "init uses '-upgrade', plan and apply use 'default' args" } ], "notes": [ "Array format is automatically converted to map with 'default' key at runtime (original JSON in database remains unchanged)", "Common arguments like -var, -destroy, and environment secrets are automatically added to all stages", "Stage resolution order: specific stage key -> 'default' key -> empty array", "Template arguments and Task arguments are merged at the stage level", "Ansible and Shell apps always use the 'default' key", "Terraform apps can use stage-specific keys (init, plan, apply) with 'default' fallback" ] } ================================================ FILE: go.mod ================================================ module github.com/semaphoreui/semaphore go 1.24.6 require ( github.com/Masterminds/squirrel v1.5.4 github.com/coreos/go-oidc/v3 v3.17.0 github.com/creack/pty v1.1.24 github.com/go-git/go-git/v5 v5.16.5 github.com/go-gorp/gorp/v3 v3.1.0 github.com/go-ldap/ldap/v3 v3.4.12 github.com/go-sql-driver/mysql v1.9.3 github.com/google/go-github v17.0.0+incompatible github.com/gorilla/handlers v1.5.2 github.com/gorilla/mux v1.8.1 github.com/gorilla/securecookie v1.1.2 github.com/gorilla/websocket v1.5.3 github.com/lib/pq v1.11.2 github.com/mdp/qrterminal/v3 v3.2.1 github.com/pquerna/otp v1.5.0 github.com/robfig/cron/v3 v3.0.1 github.com/semaphoreui/semaphore/pro v0.0.0 github.com/sirupsen/logrus v1.9.4 github.com/snikch/goodman v0.0.0-20171125024755-10e37e294daa github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/thedevsaddam/gojsonq/v2 v2.5.2 go.etcd.io/bbolt v1.4.1 golang.org/x/crypto v0.48.0 golang.org/x/oauth2 v0.35.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 modernc.org/sqlite v1.40.1 ) replace github.com/semaphoreui/semaphore/pro => ./pro require ( dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.1 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/term v0.40.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect rsc.io/qr v0.2.0 // indirect ) ================================================ FILE: go.sum ================================================ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4= github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/snikch/goodman v0.0.0-20171125024755-10e37e294daa h1:YJfZp12Z3AFhSBeXOlv4BO55RMwPn2NoQeDsrdWnBtY= github.com/snikch/goodman v0.0.0-20171125024755-10e37e294daa/go.mod h1:oJyF+mSPHbB5mVY2iO9KV3pTt/QbIkGaO8gQ2WrDbP4= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/thedevsaddam/gojsonq/v2 v2.5.2 h1:CoMVaYyKFsVj6TjU6APqAhAvC07hTI6IQen8PHzHYY0= github.com/thedevsaddam/gojsonq/v2 v2.5.2/go.mod h1:bv6Xa7kWy82uT0LnXPE2SzGqTj33TAEeR560MdJkiXs= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= go.etcd.io/bbolt v1.4.1 h1:5mOV+HWjIPLEAlUGMsveaUvK2+byZMFOzojoi7bh7uI= go.etcd.io/bbolt v1.4.1/go.mod h1:c8zu2BnXWTu2XM4XcICtbGSl9cFwsXtcf9zLt2OncM8= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= ================================================ FILE: hook_helpers/hooks_helpers.go ================================================ package hook_helpers import ( _ "github.com/snikch/goodman/hooks" _ "github.com/snikch/goodman/transaction" ) ================================================ FILE: pkg/common_errors/common_errors.go ================================================ package common_errors import ( "errors" "path" "runtime" ) type UserVisibleError struct { Err error } func (e *UserVisibleError) Error() string { return e.Err.Error() } func (e *UserVisibleError) Unwrap() error { return e.Err } func NewUserError(err error) error { return &UserVisibleError{Err: err} } func NewUserErrorS(err string) error { return &UserVisibleError{Err: errors.New(err)} } var ErrInvalidSubscription = errors.New("has no active subscription") func GetErrorContext() string { pc, file, line, _ := runtime.Caller(1) fn := runtime.FuncForPC(pc) return path.Base(file) + ":" + path.Base(fn.Name()) + ":" + string(rune(line)) } ================================================ FILE: pkg/conv/conv.go ================================================ package conv import ( "reflect" "strings" ) func ConvertFloatToIntIfPossible(v any) (int64, bool) { switch v := v.(type) { case float64: f := v i := int64(f) if float64(i) == f { return i, true } case float32: f := v i := int64(f) if float32(i) == f { return i, true } } return 0, false } func StructToFlatMap(obj any) map[string]any { result := make(map[string]any) val := reflect.ValueOf(obj) typ := reflect.TypeOf(obj) if typ.Kind() == reflect.Ptr { val = val.Elem() typ = typ.Elem() } if typ.Kind() != reflect.Struct { return result } // Iterate over the struct fields for i := 0; i < val.NumField(); i++ { field := val.Field(i) fieldType := typ.Field(i) jsonTag := fieldType.Tag.Get("json") // Use the json tag if it is set, otherwise use the field name fieldName := jsonTag if fieldName == "" || fieldName == "-" { fieldName = fieldType.Name } else { // Handle the case where the json tag might have options like `json:"name,omitempty"` fieldName = strings.Split(fieldName, ",")[0] } // Check if the field is a struct itself if field.Kind() == reflect.Struct { // Convert nested struct to map nestedMap := StructToFlatMap(field.Interface()) // Add nested map to result with a prefixed key for k, v := range nestedMap { result[fieldName+"."+k] = v } } else if (field.Kind() == reflect.Ptr || field.Kind() == reflect.Array || field.Kind() == reflect.Slice || field.Kind() == reflect.Map) && field.IsNil() { result[fieldName] = nil } else { result[fieldName] = field.Interface() } } return result } ================================================ FILE: pkg/random/string.go ================================================ package random import ( "crypto/rand" "math/big" ) const ( digits = "0123456789" chars = "abcdefghijklmnopqrstuvwxyz0123456789" ) func rnd(strlen int, baseStr string) string { result := make([]byte, strlen) charLen := big.NewInt(int64(len(baseStr))) for i := range result { r, err := rand.Int(rand.Reader, charLen) if err != nil { panic(err) } result[i] = baseStr[r.Int64()] } return string(result) } func Number(strlen int) string { return rnd(strlen, digits) } func String(strlen int) string { return rnd(strlen, chars) } ================================================ FILE: pkg/ssh/agent.go ================================================ package ssh import ( "fmt" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/random" "github.com/semaphoreui/semaphore/util" "io" "net" "path" "github.com/semaphoreui/semaphore/pkg/task_logger" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" ) type AgentKey struct { Key []byte Passphrase []byte } type Agent struct { Keys []AgentKey Logger task_logger.Logger listener net.Listener SocketFile string done chan struct{} } func NewAgent() Agent { return Agent{} } func (a *Agent) Listen() error { keyring := agent.NewKeyring() for _, k := range a.Keys { var ( key any err error ) if len(k.Passphrase) == 0 { key, err = ssh.ParseRawPrivateKey(k.Key) } else { key, err = ssh.ParseRawPrivateKeyWithPassphrase(k.Key, k.Passphrase) } if err != nil { return fmt.Errorf("parsing private key: %w", err) } if err := keyring.Add(agent.AddedKey{ PrivateKey: key, }); err != nil { return fmt.Errorf("adding private key: %w", err) } } l, err := net.ListenUnix( "unix", &net.UnixAddr{ Net: "unix", Name: a.SocketFile, }, ) if err != nil { return fmt.Errorf("listening on socket %q: %w", a.SocketFile, err) } l.SetUnlinkOnClose(true) a.listener = l a.done = make(chan struct{}) go func() { for { conn, err := a.listener.Accept() if err != nil { select { case <-a.done: return default: a.Logger.Logf("error accepting socket connection: %w", err) return } } go func(conn net.Conn) { defer conn.Close() //nolint:errcheck if err := agent.ServeAgent(keyring, conn); err != nil && err != io.EOF { a.Logger.Logf("error serving SSH agent listener: %w", err) } }(conn) } }() return nil } func (a *Agent) Close() error { if a.done != nil { close(a.done) } if a.listener != nil { return a.listener.Close() } return nil } func StartSSHAgent(key db.AccessKey, logger task_logger.Logger) (Agent, error) { socketFilename := fmt.Sprintf("ssh-agent-%d-%s.sock", key.ID, random.String(10)) var socketFile string if key.ProjectID == nil { socketFile = path.Join(util.Config.TmpPath, socketFilename) } else { socketFile = path.Join(util.Config.GetProjectTmpDir(*key.ProjectID), socketFilename) } sshAgent := Agent{ Logger: logger, Keys: []AgentKey{ { Key: []byte(key.SshKey.PrivateKey), Passphrase: []byte(key.SshKey.Passphrase), }, }, SocketFile: socketFile, } return sshAgent, sshAgent.Listen() } type AccessKeyInstallation struct { SSHAgent *Agent Login string Password string Script string } func (key *AccessKeyInstallation) GetGitEnv() (env []string) { env = make([]string, 0) env = append(env, "GIT_TERMINAL_PROMPT=0") if key.SSHAgent != nil { env = append(env, fmt.Sprintf("SSH_AUTH_SOCK=%s", key.SSHAgent.SocketFile)) sshCmd := "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" if util.Config.SshConfigPath != "" { sshCmd += " -F " + util.Config.SshConfigPath } env = append(env, fmt.Sprintf("GIT_SSH_COMMAND=%s", sshCmd)) } return env } func (key *AccessKeyInstallation) Destroy() error { if key.SSHAgent != nil { return key.SSHAgent.Close() } return nil } type KeyInstaller struct{} func (KeyInstaller) Install(key db.AccessKey, usage db.AccessKeyRole, logger task_logger.Logger) (installation AccessKeyInstallation, err error) { switch usage { case db.AccessKeyRoleGit: switch key.Type { case db.AccessKeySSH: var agent Agent agent, err = StartSSHAgent(key, logger) installation.SSHAgent = &agent installation.Login = key.SshKey.Login } case db.AccessKeyRoleAnsiblePasswordVault: switch key.Type { case db.AccessKeyLoginPassword: installation.Password = key.LoginPassword.Password default: err = fmt.Errorf("access key type not supported for ansible password vault") } case db.AccessKeyRoleAnsibleBecomeUser: if key.Type != db.AccessKeyLoginPassword { err = fmt.Errorf("access key type not supported for ansible become user") } installation.Login = key.LoginPassword.Login installation.Password = key.LoginPassword.Password case db.AccessKeyRoleAnsibleUser: switch key.Type { case db.AccessKeySSH: var agent Agent agent, err = StartSSHAgent(key, logger) installation.SSHAgent = &agent installation.Login = key.SshKey.Login case db.AccessKeyLoginPassword: installation.Login = key.LoginPassword.Login installation.Password = key.LoginPassword.Password case db.AccessKeyNone: // No SSH agent or password needed for ansible user with no access key. default: err = fmt.Errorf("access key type not supported for ansible user") } } return } ================================================ FILE: pkg/ssh/agent_test.go ================================================ package ssh import ( "testing" ) // TestAgent_Close_WithNilListener tests that Close() doesn't panic when listener is nil func TestAgent_Close_WithNilListener(t *testing.T) { // Create agent with nil listener (simulates failed initialization) agent := Agent{} // This should not panic err := agent.Close() if err != nil { t.Errorf("Expected no error when closing agent with nil listener, got: %v", err) } } // TestAgent_Close_WithNilDone tests that Close() doesn't panic when done channel is nil func TestAgent_Close_WithNilDone(t *testing.T) { // Create agent with nil done channel agent := Agent{ done: nil, } // This should not panic err := agent.Close() if err != nil { t.Errorf("Expected no error when closing agent with nil done channel, got: %v", err) } } // TestAgent_Close_WithAllNil tests that Close() doesn't panic when both fields are nil func TestAgent_Close_WithAllNil(t *testing.T) { // Create completely empty agent (simulates NewAgent() result) agent := NewAgent() // This should not panic err := agent.Close() if err != nil { t.Errorf("Expected no error when closing empty agent, got: %v", err) } } // TestAgent_Close_FailedInitialization simulates the exact scenario from issue #3232 // where agent initialization fails but the agent is still assigned to installation func TestAgent_Close_FailedInitialization(t *testing.T) { // Simulate the scenario described in the issue: // 1. StartSSHAgent() fails during Listen() but returns incomplete agent // 2. Install() method assigns the incomplete agent to installation.SSHAgent // 3. Later, destroyKeys() calls Destroy() which calls Close() on incomplete agent // Create an agent that would be returned by StartSSHAgent() if Listen() failed incompleteAgent := Agent{ Keys: []AgentKey{ { Key: []byte("test-private-key"), Passphrase: []byte(""), }, }, SocketFile: "/tmp/test-socket.sock", // listener and done are nil because Listen() failed } // This simulates the destroyKeys() -> Destroy() -> Close() call chain // that was causing the panic err := incompleteAgent.Close() if err != nil { t.Errorf("Expected no error when closing incomplete agent, got: %v", err) } } ================================================ FILE: pkg/task_logger/task_logger.go ================================================ package task_logger import ( "os/exec" "time" ) type TaskStatus string const ( TaskWaitingStatus TaskStatus = "waiting" TaskStartingStatus TaskStatus = "starting" TaskWaitingConfirmation TaskStatus = "waiting_confirmation" TaskConfirmed TaskStatus = "confirmed" TaskRejected TaskStatus = "rejected" TaskRunningStatus TaskStatus = "running" TaskStoppingStatus TaskStatus = "stopping" TaskStoppedStatus TaskStatus = "stopped" TaskSuccessStatus TaskStatus = "success" TaskFailStatus TaskStatus = "error" ) func UnfinishedTaskStatuses() []TaskStatus { return []TaskStatus{ TaskWaitingStatus, TaskStartingStatus, TaskWaitingConfirmation, TaskConfirmed, TaskRejected, TaskRunningStatus, TaskStoppingStatus, } } func (s TaskStatus) IsValid() bool { switch s { case TaskWaitingStatus, TaskStartingStatus, TaskWaitingConfirmation, TaskConfirmed, TaskRejected, TaskRunningStatus, TaskStoppingStatus, TaskStoppedStatus, TaskSuccessStatus, TaskFailStatus: return true } return false } func (s TaskStatus) IsNotifiable() bool { return s == TaskSuccessStatus || s == TaskFailStatus || s == TaskWaitingConfirmation } func (s TaskStatus) Format() (res string) { switch s { case TaskFailStatus: res += "❌" case TaskSuccessStatus: res += "✅" case TaskStoppedStatus: res += "⏹️" case TaskWaitingConfirmation: res += "⚠️" default: res += "❓" } switch s { case TaskWaitingStatus: res += " WAITING" case TaskStartingStatus: res += " STARTING" case TaskWaitingConfirmation: res += " WAITING_CONFIRMATION" case TaskConfirmed: res += " CONFIRMED" case TaskRejected: res += " REJECTED" case TaskRunningStatus: res += " RUNNING" case TaskStoppingStatus: res += " STOPPING" case TaskStoppedStatus: res += " STOPPED" case TaskSuccessStatus: res += " SUCCESS" case TaskFailStatus: res += " ERROR" default: res += " UNKNOWN" } return } func (s TaskStatus) IsFinished() bool { return s == TaskStoppedStatus || s == TaskSuccessStatus || s == TaskFailStatus } type StatusListener func(status TaskStatus) type LogListener func(new time.Time, msg string) type Logger interface { Log(msg string) Logf(format string, a ...any) LogWithTime(now time.Time, msg string) LogfWithTime(now time.Time, format string, a ...any) LogCmd(cmd *exec.Cmd) SetStatus(status TaskStatus) AddStatusListener(l StatusListener) AddLogListener(l LogListener) SetCommit(hash, message string) WaitLog() } ================================================ FILE: pkg/tz/time.go ================================================ package tz import "time" func Now() time.Time { return time.Now().UTC() } func In(t time.Time) time.Time { return t.In(time.UTC) } ================================================ FILE: pro/api/auth_verify.go ================================================ package api import ( "net/http" "github.com/semaphoreui/semaphore/db" ) func VerifySessionByEmail(session *db.Session, w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusForbidden) return } ================================================ FILE: pro/api/projects/runners.go ================================================ package projects import ( "net/http" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pro_interfaces" ) // NewProjectRunnerController creates a new ProjectRunnerController instance. func NewProjectRunnerController(subscriptionService pro_interfaces.SubscriptionService) pro_interfaces.ProjectRunnerController { return &ProjectRunnerControllerImpl{} } type ProjectRunnerControllerImpl struct { } func (c *ProjectRunnerControllerImpl) GetRunners(w http.ResponseWriter, r *http.Request) { project := helpers.GetFromContext(r, "project").(db.Project) runners, err := helpers.Store(r).GetRunners(project.ID, false, nil) if err != nil { panic(err) } helpers.WriteJSON(w, http.StatusOK, runners) } func (c *ProjectRunnerControllerImpl) AddRunner(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *ProjectRunnerControllerImpl) RunnerMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) }) } func (c *ProjectRunnerControllerImpl) GetRunner(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *ProjectRunnerControllerImpl) UpdateRunner(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *ProjectRunnerControllerImpl) DeleteRunner(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *ProjectRunnerControllerImpl) SetRunnerActive(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *ProjectRunnerControllerImpl) ClearRunnerCache(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *ProjectRunnerControllerImpl) GetRunnerTags(w http.ResponseWriter, r *http.Request) { helpers.WriteJSON(w, http.StatusOK, []any{}) } ================================================ FILE: pro/api/projects/terraform_inventory.go ================================================ package projects import ( "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pro_interfaces" "net/http" ) type terraformInventoryController struct{} func NewTerraformInventoryController(terraformRepo db.TerraformStore) pro_interfaces.TerraformInventoryController { return &terraformInventoryController{} } func (c *terraformInventoryController) GetTerraformInventoryAliases(w http.ResponseWriter, r *http.Request) { helpers.WriteJSON(w, http.StatusOK, []string{}) } func (c *terraformInventoryController) AddTerraformInventoryAlias(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *terraformInventoryController) GetTerraformInventoryAlias(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *terraformInventoryController) DeleteTerraformInventoryAlias(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *terraformInventoryController) SetTerraformInventoryAliasAccessKey(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *terraformInventoryController) GetTerraformInventoryStates(w http.ResponseWriter, r *http.Request) { helpers.WriteJSON(w, http.StatusOK, []string{}) } func (c *terraformInventoryController) GetTerraformInventoryLatestState(w http.ResponseWriter, r *http.Request) { helpers.WriteErrorStatus(w, "No state found", http.StatusNotFound) } func (c *terraformInventoryController) GetTerraformInventoryState(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *terraformInventoryController) DeleteTerraformInventoryState(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } ================================================ FILE: pro/api/roles.go ================================================ package api import ( "net/http" "github.com/semaphoreui/semaphore/api/helpers" "github.com/semaphoreui/semaphore/db" ) type RolesController struct { roleRepo db.RoleRepository } func NewRolesController(roleRepo db.RoleRepository) *RolesController { return &RolesController{ roleRepo: roleRepo, } } func (c *RolesController) GetGlobalRole(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *RolesController) GetRoles(w http.ResponseWriter, r *http.Request) { helpers.WriteJSON(w, http.StatusOK, []string{}) } func (c *RolesController) AddRole(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *RolesController) UpdateRole(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *RolesController) DeleteRole(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } // Project-specific role methods func (c *RolesController) GetProjectRoles(w http.ResponseWriter, r *http.Request) { helpers.WriteJSON(w, http.StatusOK, []string{}) } func (c *RolesController) GetProjectAndGlobalRoles(w http.ResponseWriter, r *http.Request) { helpers.WriteJSON(w, http.StatusOK, []string{}) } func (c *RolesController) AddProjectRole(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *RolesController) GetProjectRole(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *RolesController) UpdateProjectRole(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *RolesController) DeleteProjectRole(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } ================================================ FILE: pro/api/subscriptions.go ================================================ package api import ( "net/http" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pro_interfaces" ) func NewSubscriptionController( optionsRepo db.OptionsManager, userRepo db.UserManager, runnerRepo db.RunnerManager, tfRepo db.TerraformStore, ) pro_interfaces.SubscriptionController { return &subscriptionControllerImpl{} } type subscriptionControllerImpl struct { } func (ctrl *subscriptionControllerImpl) Delete(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (ctrl *subscriptionControllerImpl) Activate(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (ctrl *subscriptionControllerImpl) GetSubscription(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (ctrl *subscriptionControllerImpl) Refresh(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } ================================================ FILE: pro/api/terraform.go ================================================ package api import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/services/server" "net/http" ) type TerraformController struct { encryptionServices server.AccessKeyEncryptionService } func NewTerraformController( encryptionServices server.AccessKeyEncryptionService, terraformRepo db.TerraformStore, keyRepo db.AccessKeyManager, ) *TerraformController { return &TerraformController{ encryptionServices: encryptionServices, } } func (c *TerraformController) TerraformInventoryAliasMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) }) } func (c *TerraformController) GetTerraformState(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *TerraformController) AddTerraformState(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *TerraformController) LockTerraformState(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } func (c *TerraformController) UnlockTerraformState(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) } ================================================ FILE: pro/db/factory/factory.go ================================================ package factory import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pro/db/sql" ) func NewTerraformStore(store db.Store) db.TerraformStore { return &sql.TerraformStoreImpl{} } func NewAnsibleTaskRepository(store db.Store) db.AnsibleTaskRepository { return &sql.AnsibleTaskStoreImpl{} } ================================================ FILE: pro/db/sql/ansible_task.go ================================================ package sql import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/db/sql" ) type AnsibleTaskStoreImpl struct { } func NewAnsibleTask(connection *sql.SqlDbConnection) db.AnsibleTaskRepository { return &AnsibleTaskStoreImpl{} } func (d *AnsibleTaskStoreImpl) CreateAnsibleTaskHost(host db.AnsibleTaskHost) error { return nil } func (d *AnsibleTaskStoreImpl) CreateAnsibleTaskError(error db.AnsibleTaskError) error { return nil } func (d *AnsibleTaskStoreImpl) GetAnsibleTaskHosts(projectID int, taskID int) (res []db.AnsibleTaskHost, err error) { return } func (d *AnsibleTaskStoreImpl) GetAnsibleTaskErrors(projectID int, taskID int) (res []db.AnsibleTaskError, err error) { return } ================================================ FILE: pro/db/sql/terraform_inventory.go ================================================ package sql import ( "github.com/semaphoreui/semaphore/db" ) type TerraformStoreImpl struct { } func (d *TerraformStoreImpl) CreateTerraformInventoryAlias(alias db.TerraformInventoryAlias) (res db.TerraformInventoryAlias, err error) { return } func (d *TerraformStoreImpl) UpdateTerraformInventoryAlias(alias db.TerraformInventoryAlias) (err error) { return } func (d *TerraformStoreImpl) GetTerraformInventoryAliasByAlias(alias string) (res db.TerraformInventoryAlias, err error) { return } func (d *TerraformStoreImpl) GetTerraformInventoryAlias(projectID, inventoryID int, aliasID string) (res db.TerraformInventoryAlias, err error) { return } func (d *TerraformStoreImpl) GetTerraformInventoryAliases(projectID, inventoryID int) (res []db.TerraformInventoryAlias, err error) { return } func (d *TerraformStoreImpl) DeleteTerraformInventoryAlias(projectID int, inventoryID int, aliasID string) (err error) { return } func (d *TerraformStoreImpl) GetTerraformInventoryStates(projectID, inventoryID int, params db.RetrieveQueryParams) (res []db.TerraformInventoryState, err error) { return } func (d *TerraformStoreImpl) CreateTerraformInventoryState(state db.TerraformInventoryState) (res db.TerraformInventoryState, err error) { return } func (d *TerraformStoreImpl) DeleteTerraformInventoryState(projectID int, inventoryID int, stateID int) (err error) { return } func (d *TerraformStoreImpl) GetTerraformInventoryState(projectID int, inventoryId int, stateID int) (res db.TerraformInventoryState, err error) { return } func (d *TerraformStoreImpl) GetTerraformStateCount() (n int, err error) { return } ================================================ FILE: pro/go.mod ================================================ module github.com/semaphoreui/semaphore/pro go 1.24.6 require github.com/semaphoreui/semaphore v0.0.0-20250712180151-72836311c5b9 require ( dario.cat/mergo v1.0.1 // indirect filippo.io/edwards25519 v1.1.1 // indirect github.com/Masterminds/squirrel v1.5.4 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/creack/pty v1.1.24 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect github.com/go-git/go-git/v5 v5.16.5 // indirect github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-github v17.0.0+incompatible // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/lib/pq v1.11.2 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sys v0.41.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect modernc.org/sqlite v1.40.1 // indirect ) replace github.com/semaphoreui/semaphore => ../ ================================================ FILE: pro/go.sum ================================================ dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= go.etcd.io/bbolt v1.4.1 h1:5mOV+HWjIPLEAlUGMsveaUvK2+byZMFOzojoi7bh7uI= go.etcd.io/bbolt v1.4.1/go.mod h1:c8zu2BnXWTu2XM4XcICtbGSl9cFwsXtcf9zLt2OncM8= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= ================================================ FILE: pro/pkg/features/features.go ================================================ package features import ( "github.com/semaphoreui/semaphore/db" ) func GetFeatures(user *db.User, plan string) map[string]bool { return map[string]bool{ "project_runners": false, "terraform_backend": false, "task_summary": false, "secret_storages": false, } } ================================================ FILE: pro/pkg/stage_parsers/next_step.go ================================================ package stage_parsers import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pro_interfaces" ) func MoveToNextStage( store db.Store, ansibleTaskRepo db.AnsibleTaskRepository, logWriter pro_interfaces.LogWriteService, app db.TemplateApp, projectID int, currentState any, currentStage *db.TaskStage, currentOutput *db.TaskOutput, newOutput db.TaskOutput, ) (newStage *db.TaskStage, newState any, err error) { return } ================================================ FILE: pro/services/ha/ha.go ================================================ package ha import ( "github.com/semaphoreui/semaphore/api/sockets" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/services/schedules" ) // NodeRegistry manages node heartbeats and cluster membership tracking // in HA mode. In active-active setups every Semaphore instance registers // itself and periodically refreshes a heartbeat so other nodes can detect // liveness. type NodeRegistry interface { Start() error Stop() NodeCount() int NodeID() string } // OrphanCleaner periodically detects tasks whose owning node has died and // marks them as failed so they do not remain stuck in "running" forever. type OrphanCleaner interface { Start() Stop() } // Stubs – these are replaced by pro_impl via Go workspace. func NewNodeRegistry() NodeRegistry { return nil } func NewScheduleDeduplicator() schedules.ScheduleDeduplicator { return nil } func NewWSBroadcaster() sockets.Broadcaster { return nil } func NewOrphanCleaner(_ db.Store) OrphanCleaner { return nil } ================================================ FILE: pro/services/server/access_key_serializer_dvls.go ================================================ package server import ( "github.com/semaphoreui/semaphore/db" ) type DvlsStorageTokenDeserializer interface { DeserializeSecret(key *db.AccessKey) error } type DvlsAccessKeyDeserializer struct { } func NewDvlsAccessKeyDeserializer( _ db.AccessKeyManager, _ db.SecretStorageRepository, _ VaultStorageTokenDeserializer, ) *DvlsAccessKeyDeserializer { return &DvlsAccessKeyDeserializer{} } func (d *DvlsAccessKeyDeserializer) DeleteSecret(key *db.AccessKey) error { return nil } func (d *DvlsAccessKeyDeserializer) SerializeSecret(key *db.AccessKey) (err error) { return } func (d *DvlsAccessKeyDeserializer) DeserializeSecret(key *db.AccessKey) (res string, err error) { return } ================================================ FILE: pro/services/server/access_key_serializer_vault.go ================================================ package server import ( "github.com/semaphoreui/semaphore/db" ) type VaultStorageTokenDeserializer interface { DeserializeSecret(key *db.AccessKey) error } type VaultAccessKeyDeserializer struct { } func NewVaultAccessKeyDeserializer( _ db.AccessKeyManager, _ db.SecretStorageRepository, _ VaultStorageTokenDeserializer, ) *VaultAccessKeyDeserializer { return &VaultAccessKeyDeserializer{} } func (d *VaultAccessKeyDeserializer) DeleteSecret(key *db.AccessKey) error { return nil } func (d *VaultAccessKeyDeserializer) SerializeSecret(key *db.AccessKey) (err error) { return } func (d *VaultAccessKeyDeserializer) DeserializeSecret(key *db.AccessKey) (res string, err error) { return } ================================================ FILE: pro/services/server/log_write_svc.go ================================================ package server import ( "github.com/semaphoreui/semaphore/pro_interfaces" ) type LogWriteServiceImpl struct { } // NewLogWriteService creates a new instance of LogWriteServiceImpl. func NewLogWriteService() pro_interfaces.LogWriteService { return &LogWriteServiceImpl{} } func (l *LogWriteServiceImpl) WriteEventLog(event pro_interfaces.EventLogRecord) error { return nil } func (l *LogWriteServiceImpl) WriteTaskLog(task pro_interfaces.TaskLogRecord) error { return nil } func (l *LogWriteServiceImpl) WriteResult(task any) error { return nil } ================================================ FILE: pro/services/server/secret_storage_svc.go ================================================ package server import "github.com/semaphoreui/semaphore/db" func GetSecretStorages(repo db.SecretStorageRepository, projectID int) (storages []db.SecretStorage, err error) { storages = make([]db.SecretStorage, 0) return } func SyncDvlsSecrets( storage db.SecretStorage, accessKeyRepo db.AccessKeyManager, decryptor DvlsStorageTokenDeserializer, ) error { return nil } ================================================ FILE: pro/services/server/subscription_svc.go ================================================ package server import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pro_interfaces" ) func NewSubscriptionService(userRepo db.UserManager, optionsRepo db.OptionsManager, runnerRepo db.RunnerManager, tfRepo db.TerraformStore) pro_interfaces.SubscriptionService { return &SubscriptionServiceImpl{} } type SubscriptionServiceImpl struct { } func (s *SubscriptionServiceImpl) GetToken() (res pro_interfaces.SubscriptionToken, err error) { err = db.ErrNotFound return } func (s *SubscriptionServiceImpl) HasActiveSubscription() bool { return false } func (s *SubscriptionServiceImpl) CanAddProUser() (ok bool, err error) { return false, nil } func (s *SubscriptionServiceImpl) StartValidationCron() { } func (s *SubscriptionServiceImpl) CanAddRole() (ok bool, err error) { return } func (s *SubscriptionServiceImpl) CanAddRunner() (ok bool, err error) { return } func (s *SubscriptionServiceImpl) CanAddTerraformHTTPBackend() (ok bool, err error) { return } ================================================ FILE: pro/services/tasks/task_state_store_factory.go ================================================ package tasks import ( "github.com/semaphoreui/semaphore/services/tasks" ) func NewTaskStateStore() tasks.TaskStateStore { return tasks.NewMemoryTaskStateStore() } ================================================ FILE: pro_interfaces/log_write_svc.go ================================================ package pro_interfaces import "github.com/semaphoreui/semaphore/pkg/task_logger" type LogWriteService interface { WriteEventLog(event EventLogRecord) error WriteTaskLog(task TaskLogRecord) error WriteResult(task any) error } type EventLogRecord struct { Action string `json:"action"` UserID *int `json:"user,omitempty"` IntegrationID *int `json:"integration,omitempty"` ProjectID *int `json:"project,omitempty"` Description *string `json:"description,omitempty"` } type TaskLogRecord struct { Username string `json:"username,omitempty"` TaskID int `json:"task"` ProjectID int `json:"project"` TemplateID int `json:"template"` TemplateName string `json:"template_name"` UserID *int `json:"user,omitempty"` Description *string `json:"-"` RunnerID *int `json:"runner,omitempty"` Status task_logger.TaskStatus `json:"status"` } ================================================ FILE: pro_interfaces/project_runner_ctl.go ================================================ package pro_interfaces import "net/http" type ProjectRunnerController interface { GetRunners(w http.ResponseWriter, r *http.Request) AddRunner(w http.ResponseWriter, r *http.Request) RunnerMiddleware(next http.Handler) http.Handler GetRunner(w http.ResponseWriter, r *http.Request) UpdateRunner(w http.ResponseWriter, r *http.Request) DeleteRunner(w http.ResponseWriter, r *http.Request) SetRunnerActive(w http.ResponseWriter, r *http.Request) ClearRunnerCache(w http.ResponseWriter, r *http.Request) GetRunnerTags(w http.ResponseWriter, r *http.Request) } ================================================ FILE: pro_interfaces/subscription_ctl.go ================================================ package pro_interfaces import "net/http" type SubscriptionController interface { GetSubscription(w http.ResponseWriter, r *http.Request) Activate(w http.ResponseWriter, r *http.Request) Refresh(w http.ResponseWriter, r *http.Request) Delete(w http.ResponseWriter, r *http.Request) } ================================================ FILE: pro_interfaces/subscription_svc.go ================================================ package pro_interfaces import "time" type SubscriptionToken struct { Company string `json:"company,omitempty"` State string `json:"state"` Key string `json:"key"` Plan string `json:"plan"` Users int `json:"users"` ExpiresAt time.Time `json:"expiresAt"` Nodes int `json:"nodes,omitempty"` UIs int `json:"uis,omitempty"` } func (t *SubscriptionToken) Validate() error { return nil } type SubscriptionService interface { HasActiveSubscription() bool CanAddProUser() (ok bool, err error) CanAddRunner() (ok bool, err error) CanAddTerraformHTTPBackend() (ok bool, err error) StartValidationCron() GetToken() (res SubscriptionToken, err error) } ================================================ FILE: pro_interfaces/terraform_inventory_ctl.go ================================================ package pro_interfaces import ( "net/http" ) type TerraformInventoryController interface { GetTerraformInventoryAliases(w http.ResponseWriter, r *http.Request) AddTerraformInventoryAlias(w http.ResponseWriter, r *http.Request) GetTerraformInventoryAlias(w http.ResponseWriter, r *http.Request) DeleteTerraformInventoryAlias(w http.ResponseWriter, r *http.Request) SetTerraformInventoryAliasAccessKey(w http.ResponseWriter, r *http.Request) GetTerraformInventoryStates(w http.ResponseWriter, r *http.Request) GetTerraformInventoryLatestState(w http.ResponseWriter, r *http.Request) GetTerraformInventoryState(w http.ResponseWriter, r *http.Request) DeleteTerraformInventoryState(w http.ResponseWriter, r *http.Request) } ================================================ FILE: qodana.yaml ================================================ #-------------------------------------------------------------------------------# # Qodana analysis is configured by qodana.yaml file # # https://www.jetbrains.com/help/qodana/qodana-yaml.html # #-------------------------------------------------------------------------------# version: "1.0" #Specify inspection profile for code analysis profile: name: qodana.starter #Enable inspections #include: # - name: #Disable inspections #exclude: # - name: # paths: # - #Execute shell command before Qodana execution (Applied in CI/CD pipeline) #bootstrap: sh ./prepare-qodana.sh #Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) #plugins: # - id: #(plugin id can be found at https://plugins.jetbrains.com) #Specify Qodana linter for analysis (Applied in CI/CD pipeline) linter: jetbrains/qodana-go:latest ================================================ FILE: renovate.json ================================================ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended" ] } ================================================ FILE: services/export/AccessKey.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type AccessKeyExporter struct { ValueMap[db.AccessKey] } func (e *AccessKeyExporter) load(store db.Store, exporter DataExporter, progress Progress) error { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, proj := range projs { keys, err := store.GetAccessKeys(proj, db.GetAccessKeyOptions{IgnoreOwner: true}, db.RetrieveQueryParams{}) if err != nil { return err } err = e.appendValues(keys, strconv.Itoa(proj)) if err != nil { return err } } return nil } func (e *AccessKeyExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *AccessKeyExporter) restoreValue(val EntityObject[db.AccessKey], store db.Store, exporter DataExporter) (err error) { old := val.value old.EnvironmentID, err = exporter.getNewKeyIntRef(Environment, val.scope, old.EnvironmentID, e) if err != nil { return err } old.StorageID, err = exporter.getNewKeyIntRef(SecretStorage, val.scope, old.StorageID, e) if err != nil { return err } old.UserID, err = exporter.getNewKeyIntRef(User, val.scope, old.UserID, e) if err != nil { return err } old.ProjectID, err = exporter.getNewKeyIntRef(Project, GlobalScope, old.ProjectID, e) if err != nil { return err } old.SourceStorageID, err = exporter.getNewKeyIntRef(SecretStorage, val.scope, old.SourceStorageID, e) if err != nil { return err } newVault, err := store.CreateAccessKey(old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newVault.GetDbKey()) } func (e *AccessKeyExporter) getName() string { return AccessKey } func (e *AccessKeyExporter) exportDependsOn() []string { return []string{Project} } func (e *AccessKeyExporter) importDependsOn() []string { return []string{User, Project, SecretStorage, Environment} } ================================================ FILE: services/export/Environment.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type EnvironmentExporter struct { ValueMap[db.Environment] } func (e *EnvironmentExporter) load(store db.Store, exporter DataExporter, progress Progress) error { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, proj := range projs { envs, err := store.GetEnvironments(proj, db.RetrieveQueryParams{}) if err != nil { return err } err = e.appendValues(envs, strconv.Itoa(proj)) if err != nil { return err } } return nil } func (e *EnvironmentExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *EnvironmentExporter) restoreValue(val EntityObject[db.Environment], store db.Store, exporter DataExporter) (err error) { old := val.value old.ProjectID, err = exporter.getNewKeyInt(Project, GlobalScope, old.ProjectID) if err != nil { return err } old.SecretStorageID, err = exporter.getNewKeyIntRef(SecretStorage, val.scope, old.SecretStorageID, e) if err != nil { return err } newVault, err := store.CreateEnvironment(old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newVault.GetDbKey()) } func (e *EnvironmentExporter) getName() string { return Environment } func (e *EnvironmentExporter) exportDependsOn() []string { return []string{Project} } func (e *EnvironmentExporter) importDependsOn() []string { return []string{Project, SecretStorage} } ================================================ FILE: services/export/Event.go ================================================ package export import ( "fmt" "math" "strconv" "github.com/semaphoreui/semaphore/db" ) type EventExporter struct { ValueMap[db.Event] } func (e *EventExporter) load(store db.Store, exporter DataExporter, progress Progress) error { envs, err := store.GetAllEvents(db.RetrieveQueryParams{Count: math.MaxInt}) if err != nil { return err } eventsByProject := make(map[string][]db.Event) for _, event := range envs { scope := GlobalScope if event.ProjectID != nil { scope = strconv.Itoa(*event.ProjectID) } if eventsByProject[scope] == nil { eventsByProject[scope] = make([]db.Event, 0) } eventsByProject[scope] = append(eventsByProject[scope], event) } for scope, events := range eventsByProject { err = e.appendValues(events, scope) if err != nil { return err } } return nil } func (e *EventExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *EventExporter) restoreValue(val EntityObject[db.Event], store db.Store, exporter DataExporter) (err error) { old := val.value scope := GlobalScope if old.ProjectID != nil { scope = strconv.Itoa(*old.ProjectID) } old.ProjectID, err = exporter.getNewKeyIntRef(Project, GlobalScope, old.ProjectID, e) if err != nil { return err } old.UserID, err = exporter.getNewKeyIntRef(User, GlobalScope, old.UserID, e) if err != nil { return err } old.IntegrationID, err = exporter.getNewKeyIntRef(Integration, scope, old.IntegrationID, e) if err != nil { return err } err = e.restoreEventObject(&old, exporter, scope) if err != nil { return err } _, err = store.CreateEvent(old) return err } func eventObjectTypeToEntityName(t db.EventObjectType) (string, bool) { switch t { case db.EventTask: return Task, true case db.EventRepository: return Repository, true case db.EventEnvironment: return Environment, true case db.EventInventory: return Inventory, true case db.EventKey: return AccessKey, true case db.EventProject: return Project, true case db.EventSchedule: return Schedule, true case db.EventTemplate: return Template, true case db.EventUser: return User, true case db.EventView: return View, true case db.EventIntegration: return Integration, true case db.EventIntegrationExtractValue: return IntegrationExtractValue, true case db.EventIntegrationMatcher: return IntegrationMatcher, true default: return "", false } } func getScope(objectType, scope string) string { switch objectType { case Project: return GlobalScope case User: return GlobalScope } return scope } func (e *EventExporter) restoreEventObject(event *db.Event, exporter DataExporter, scope string) (err error) { if event.ObjectType != nil { entityName, ok := eventObjectTypeToEntityName(*event.ObjectType) if !ok { event.ObjectID = nil e.onError(fmt.Sprintf("Unknown event object type: %s", *event.ObjectType)) } else { event.ObjectID, err = exporter.getNewKeyIntRef(entityName, getScope(entityName, scope), event.ObjectID, e) if err != nil { event.ObjectID = nil e.onError(fmt.Sprintf("Unable to restore event object %s, %s", entityName, err.Error())) } } } return nil } func (e *EventExporter) exportDependsOn() []string { return []string{Project, User} } func (e *EventExporter) importDependsOn() []string { return []string{Project, User, Integration, AccessKey, Schedule, Environment, Template, Task, Inventory, Repository, View} } func (e *EventExporter) getName() string { return Event } ================================================ FILE: services/export/Exporter.go ================================================ package export import ( "errors" "fmt" "strconv" "github.com/semaphoreui/semaphore/db" ) const ( User = "User" Project = "Project" AccessKey = "AccessKey" Environment = "Environment" Template = "Template" TemplateVault = "TemplateVault" TemplateRole = "TemplateRole" SecretStorage = "SecretStorage" Inventory = "Inventory" Repository = "Repository" View = "View" Role = "Role" TaskParams = "TaskParams" Integration = "Integration" IntegrationAlias = "IntegrationAlias" IntegrationExtractValue = "IntegrationExtractValue" IntegrationMatcher = "IntegrationMatcher" Schedule = "Schedule" Task = "Task" TaskStage = "TaskStage" TaskStageResult = "TaskStageResult" TaskOutput = "TaskOutput" ProjectUser = "ProjectUser" Option = "Option" Event = "Event" Runner = "Runner" ) type EntityKey = string func NewKeyFromInt(key int) EntityKey { return strconv.Itoa(key) } type KeyMapper interface { getNewKey(name string, scope string, oldKey EntityKey) (EntityKey, error) getNewKeyInt(name string, scope string, oldKey int) (int, error) getNewKeyIntRef(name string, scope string, oldKey *int, errHandler ErrorHandler) (*int, error) mapKeys(name string, scope string, oldKey EntityKey, newKey EntityKey) error //mapIntKeys(name string, scope string, oldKey int, newKey int) error ignoreKeyNotFound() bool } type DataExporter interface { KeyMapper getTypeExporter(name string) TypeExporter getLoadedKeys(name string, scope string) ([]EntityKey, error) getLoadedKeysInt(name string, scope string) ([]int, error) } type Progress interface { update(progress float32, count int64) } type ErrorHandler interface { onError(err string) } type TypeExporter interface { load(store db.Store, exporter DataExporter, progress Progress) error restore(store db.Store, exporter DataExporter, progress Progress) error getLoadedKeys(scope string) ([]EntityKey, error) getLoadedValues(scope string) ([]EntityType, error) getName() string exportDependsOn() []string importDependsOn() []string getErrors() []string clear() setUniqueKeys(uniqueKeys bool) } var KeyNotFound = -1 var GlobalScope = "" type EntityType interface { GetDbKey() EntityKey } type TypeKeyMapper struct { Keys map[string]map[string]map[EntityKey]EntityKey IgnoreKeyNotFoundErr bool } func (d *TypeKeyMapper) getNewKeyInt(name string, scope string, oldKey int) (int, error) { key, err := d.getNewKey(name, scope, NewKeyFromInt(oldKey)) if err != nil { return KeyNotFound, err } newKey, err := strconv.Atoi(key) if err != nil { return KeyNotFound, err } return newKey, nil } func (d *TypeKeyMapper) getNewKeyIntRef(name string, scope string, oldKey *int, errHandler ErrorHandler) (*int, error) { if oldKey == nil { return nil, nil } key, err := d.getNewKey(name, scope, NewKeyFromInt(*oldKey)) if err != nil { if d.ignoreKeyNotFound() { errHandler.onError(err.Error()) return nil, nil } return nil, err } newKey, err := strconv.Atoi(key) if err != nil { return nil, err } return &newKey, nil } func (d *TypeKeyMapper) getNewKey(name string, scope string, oldKey EntityKey) (EntityKey, error) { newKey, ok := d.Keys[name][scope][oldKey] if !ok { msg := fmt.Sprintf("%s key %s not found", name, oldKey) return "", errors.New(msg) } return newKey, nil } func (d *TypeKeyMapper) mapKeys(name string, scope string, oldKey EntityKey, newKey EntityKey) error { _, ok := d.Keys[name] if !ok { d.Keys[name] = make(map[string]map[EntityKey]EntityKey) } _, ok = d.Keys[name][scope] if !ok { d.Keys[name][scope] = make(map[EntityKey]EntityKey) } d.Keys[name][scope][oldKey] = newKey return nil } //func (d *TypeKeyMapper) mapIntKeys(name string, scope string, oldKey int, newKey int) error { // newStrKey := strconv.Itoa(newKey) // oldStrKey := strconv.Itoa(oldKey) // return d.mapKeys(name, scope, oldStrKey, newStrKey) //} func (d *TypeKeyMapper) ignoreKeyNotFound() bool { return d.IgnoreKeyNotFoundErr } type EntityObject[T EntityType] struct { value T scope string } type ValueExporter[T EntityType] interface { restoreValue(val EntityObject[T], store db.Store, exporter DataExporter) (err error) getName() string } type ValueMap[T EntityType] struct { values []EntityObject[T] keyScopeMap map[string]bool errs []string uniqueKeys bool } func (t *ValueMap[T]) getLoadedKeys(scope string) ([]EntityKey, error) { if t.values == nil { return nil, fmt.Errorf("values not loaded") } keys := make([]EntityKey, 0, len(t.values)) for _, v := range t.values { if v.scope == scope { keys = append(keys, v.value.GetDbKey()) } } return keys, nil } func (t *ValueMap[T]) getLoadedKeysInt(scope string) ([]int, error) { keys, err := t.getLoadedKeys(scope) if err != nil { return nil, err } keysInt := make([]int, 0) for _, k := range keys { intKey, err := strconv.Atoi(k) if err != nil { return nil, err } keysInt = append(keysInt, intKey) } return keysInt, nil } func (t *ValueMap[T]) getLoadedValues(scope string) ([]EntityType, error) { keys := make([]EntityType, 0) for _, v := range t.values { if v.scope == scope { keys = append(keys, v.value) } } return keys, nil } func (t *ValueMap[T]) appendValues(values []T, scope string) error { return t.appendValuesAndCheck(values, scope, t.uniqueKeys) } func (t *ValueMap[T]) appendValuesAndCheck(values []T, scope string, checkDuplicates bool) error { if t.values == nil { t.keyScopeMap = make(map[string]bool) t.values = make([]EntityObject[T], 0) } for _, v := range values { if checkDuplicates { _, ok := t.keyScopeMap[scope+v.GetDbKey()] if ok { return fmt.Errorf("duplicate key %s", v.GetDbKey()) } t.keyScopeMap[scope+v.GetDbKey()] = true } t.values = append(t.values, EntityObject[T]{value: v, scope: scope}) } return nil } func (t *ValueMap[T]) exportDependsOn() []string { return []string{} } func (t *ValueMap[T]) importDependsOn() []string { return []string{} } func (t *ValueMap[T]) onError(err string) { if t.errs == nil { t.errs = []string{err} } else { t.errs = append(t.errs, err) } } func (t *ValueMap[T]) getErrors() []string { return t.errs } func (t *ValueMap[T]) clear() { t.keyScopeMap = nil t.values = nil t.errs = nil } func (t *ValueMap[T]) setUniqueKeys(uniqueKeys bool) { t.uniqueKeys = uniqueKeys } func (t *ValueMap[T]) restoreValues(store db.Store, exporter DataExporter, progress Progress, valueExporter ValueExporter[T]) (err error) { size := len(t.values) for index, val := range t.values { progress.update(float32(index)/float32(size), int64(index)) err := valueExporter.restoreValue(val, store, exporter) if err != nil { t.onError(fmt.Sprintf("Unable to restore %s: %s", valueExporter.getName(), err.Error())) continue } } return nil } type ExporterChain struct { exporters map[string]TypeExporter KeyMapper } func (p *ExporterChain) getTypeExporter(name string) TypeExporter { return p.exporters[name] } func (p *ExporterChain) getLoadedKeys(name string, scope string) ([]EntityKey, error) { exporter, ok := p.exporters[name] if !ok { return nil, fmt.Errorf("type %s not found", name) } return exporter.getLoadedKeys(scope) } func (p *ExporterChain) getLoadedKeysInt(name string, scope string) ([]int, error) { exporter, ok := p.exporters[name] if !ok { return nil, fmt.Errorf("type %s not found", name) } keys, err := exporter.getLoadedKeys(scope) if err != nil { return nil, err } out := make([]int, len(keys)) for i, v := range keys { n, err := strconv.Atoi(v) if err != nil { return nil, err } out[i] = n } return out, nil } func getSortedKeys(exporters map[string]TypeExporter, dependsOn func(t TypeExporter) []string) ([]string, error) { var sorted []string visited := make(map[string]bool) visiting := make(map[string]bool) var visit func(name string) error visit = func(name string) error { if visiting[name] { return fmt.Errorf("cyclic dependency detected involving %s", name) } if visited[name] { return nil } visiting[name] = true if exporter, ok := exporters[name]; ok { order := dependsOn(exporter) for _, dep := range order { if _, exists := exporters[dep]; exists { if err := visit(dep); err != nil { return err } } } } visiting[name] = false visited[name] = true sorted = append(sorted, name) return nil } for name := range exporters { if err := visit(name); err != nil { return nil, err } } return sorted, nil } func getUniqueKeys(exporters map[string]TypeExporter) map[string]bool { uniqueKeys := make(map[string]bool) for _, e := range exporters { for _, dep := range e.importDependsOn() { uniqueKeys[dep] = true } } return uniqueKeys } func InitProjectExporters(mapper KeyMapper, skipTaskOutput bool, mergeExistingUsers bool) *ExporterChain { exporters := map[string]TypeExporter{ User: &UserExporter{MergeExisting: mergeExistingUsers}, Project: &ProjectExporter{}, Template: &TemplateExporter{}, TemplateVault: &TemplateVaultExporter{}, TemplateRole: &TemplateRoleExporter{}, AccessKey: &AccessKeyExporter{}, Environment: &EnvironmentExporter{}, Repository: &RepositoryExporter{}, SecretStorage: &SecretStorageExporter{}, Inventory: &InventoryExporter{}, View: &ViewExporter{}, Role: &RoleExporter{}, Schedule: &ScheduleExporter{}, ProjectUser: &ProjectUserExporter{}, Integration: &IntegrationExporter{}, IntegrationExtractValue: &IntegrationExtractValueExporter{}, IntegrationMatcher: &IntegrationMatcherExporter{}, IntegrationAlias: &IntegrationAliasExporter{}, Task: &TaskExporter{}, //TaskStage: &TaskStageExporter{}, Option: &OptionExporter{}, Event: &EventExporter{}, Runner: &RunnerExporter{}, } if !skipTaskOutput { exporters[TaskOutput] = &TaskOutputExporter{} } uniqueKeys := getUniqueKeys(exporters) for _, e := range exporters { e.setUniqueKeys(uniqueKeys[e.getName()]) } return &ExporterChain{exporters: exporters, KeyMapper: mapper} } func NewKeyMapper() *TypeKeyMapper { return &TypeKeyMapper{Keys: make(map[string]map[string]map[EntityKey]EntityKey), IgnoreKeyNotFoundErr: true} } type ProgressBar struct { progress float32 printer func(float32, int64) count int64 } func (p *ProgressBar) update(progress float32, count int64) { if progress-p.progress > 0.01 { p.updateForce(progress, count) } } func (p *ProgressBar) updateForce(progress float32, count int64) { p.printer(progress, count) p.progress = progress p.count = count } func (p *ExporterChain) Load(store db.Store) (err error) { keys, err := getSortedKeys(p.exporters, func(t TypeExporter) []string { return t.exportDependsOn() }) if err != nil { return } for _, name := range keys { progress := &ProgressBar{printer: func(progress float32, count int64) { strLen := len(name) spaces := fmt.Sprintf("%*s", 36-strLen, " ") fmt.Printf("\rExporting %s%s %d%%", name, spaces, int(progress*100)) }, progress: 0} progress.updateForce(0, 0) exporter := p.exporters[name] err = exporter.load(store, p, progress) if err != nil { fmt.Println() return fmt.Errorf("failed to export %s: %s", name, err.Error()) } progress.updateForce(1, 0) fmt.Println() } return } func (p *ExporterChain) Restore(store db.Store, errLogSize int) error { keys, err := getSortedKeys(p.exporters, func(t TypeExporter) []string { return t.importDependsOn() }) if err != nil { return err } for _, name := range keys { progress := &ProgressBar{printer: func(progress float32, count int64) { strLen := len(name) spaces := fmt.Sprintf("%*s", 36-strLen, " ") fmt.Printf("\rImporting %s%s %d%%", name, spaces, int(progress*100)) }, progress: 0} progress.updateForce(0, 0) exporter := p.exporters[name] err := exporter.restore(store, p, progress) if err != nil { fmt.Println() return fmt.Errorf("failed to import %s: %s", name, err.Error()) } progress.updateForce(1, progress.count) fmt.Println() errCount := len(exporter.getErrors()) if errCount > 0 { fmt.Printf(" Errors: %d\n", errCount) if errLogSize > 0 { for i, err := range exporter.getErrors() { if i > errLogSize { break } fmt.Println(" ", err) } } } exporter.clear() } return nil } ================================================ FILE: services/export/Integration.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type IntegrationExporter struct { ValueMap[db.Integration] } func (e *IntegrationExporter) load(store db.Store, exporter DataExporter, progress Progress) error { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, proj := range projs { keys, err := store.GetIntegrations(proj, db.RetrieveQueryParams{}, true) if err != nil { return err } err = e.appendValues(keys, strconv.Itoa(proj)) if err != nil { return err } } return nil } func (e *IntegrationExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *IntegrationExporter) restoreValue(val EntityObject[db.Integration], store db.Store, exporter DataExporter) (err error) { old := val.value if old.TaskParams != nil { old.TaskParams.InventoryID, err = exporter.getNewKeyIntRef(Inventory, val.scope, old.TaskParams.InventoryID, e) if err != nil { return err } old.TaskParams.ProjectID, err = exporter.getNewKeyInt(Project, GlobalScope, old.ProjectID) if err != nil { return err } } old.TemplateID, err = exporter.getNewKeyInt(Template, val.scope, old.TemplateID) if err != nil { return err } old.AuthSecretID, err = exporter.getNewKeyIntRef(AccessKey, val.scope, old.AuthSecretID, e) if err != nil { return err } old.ProjectID, err = exporter.getNewKeyInt(Project, GlobalScope, old.ProjectID) if err != nil { return err } integration, err := store.CreateIntegration(old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), integration.GetDbKey()) } func (e *IntegrationExporter) getName() string { return Integration } func (e *IntegrationExporter) exportDependsOn() []string { return []string{Project} } func (e *IntegrationExporter) importDependsOn() []string { return []string{Project, SecretStorage, Template, Inventory, AccessKey} } ================================================ FILE: services/export/IntegrationAliases.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type IntegrationAliasExporter struct { ValueMap[db.IntegrationAlias] } func (e *IntegrationAliasExporter) load(store db.Store, exporter DataExporter, progress Progress) error { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, proj := range projs { vals, err := store.GetIntegrationAliases(proj, nil) if err != nil { return err } allValues := make([]db.IntegrationAlias, 0) allValues = append(allValues, vals...) integrations, err := exporter.getLoadedKeysInt(Integration, strconv.Itoa(proj)) if err != nil { return err } for _, integration := range integrations { vals, err = store.GetIntegrationAliases(proj, &integration) if err != nil { return err } allValues = append(allValues, vals...) } err = e.appendValues(allValues, strconv.Itoa(proj)) if err != nil { return err } } return nil } func (e *IntegrationAliasExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *IntegrationAliasExporter) restoreValue(val EntityObject[db.IntegrationAlias], store db.Store, exporter DataExporter) (err error) { old := val.value old.IntegrationID, err = exporter.getNewKeyIntRef(Integration, val.scope, old.IntegrationID, e) if err != nil { return err } old.ProjectID, err = exporter.getNewKeyInt(Project, GlobalScope, old.ProjectID) if err != nil { return err } newVault, err := store.CreateIntegrationAlias(old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newVault.GetDbKey()) } func (e *IntegrationAliasExporter) getName() string { return IntegrationAlias } func (e *IntegrationAliasExporter) exportDependsOn() []string { return []string{Project, Integration} } func (e *IntegrationAliasExporter) importDependsOn() []string { return []string{Project, Integration} } ================================================ FILE: services/export/IntegrationExtractValue.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type IntegrationExtractValueExporter struct { ValueMap[db.IntegrationExtractValue] } func (e *IntegrationExtractValueExporter) load(store db.Store, exporter DataExporter, progress Progress) error { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, proj := range projs { integrations, err := exporter.getLoadedKeysInt(Integration, strconv.Itoa(proj)) if err != nil { return err } allValues := make([]db.IntegrationExtractValue, 0) for _, integration := range integrations { vals, err := store.GetIntegrationExtractValues(proj, db.RetrieveQueryParams{}, integration) if err != nil { return err } allValues = append(allValues, vals...) } err = e.appendValues(allValues, strconv.Itoa(proj)) if err != nil { return err } } return nil } func (e *IntegrationExtractValueExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *IntegrationExtractValueExporter) restoreValue(val EntityObject[db.IntegrationExtractValue], store db.Store, exporter DataExporter) (err error) { old := val.value old.IntegrationID, err = exporter.getNewKeyInt(Integration, val.scope, old.IntegrationID) if err != nil { return err } newVault, err := store.CreateIntegrationExtractValue(0, old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newVault.GetDbKey()) } func (e *IntegrationExtractValueExporter) getName() string { return IntegrationExtractValue } func (e *IntegrationExtractValueExporter) exportDependsOn() []string { return []string{Project, Integration} } func (e *IntegrationExtractValueExporter) importDependsOn() []string { return []string{Project, Integration} } ================================================ FILE: services/export/IntegrationMatcher.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type IntegrationMatcherExporter struct { ValueMap[db.IntegrationMatcher] } func (e *IntegrationMatcherExporter) load(store db.Store, exporter DataExporter, progress Progress) error { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, proj := range projs { integrations, err := exporter.getLoadedKeysInt(Integration, strconv.Itoa(proj)) if err != nil { return err } allValues := make([]db.IntegrationMatcher, 0) for _, integration := range integrations { vals, err := store.GetIntegrationMatchers(proj, db.RetrieveQueryParams{}, integration) if err != nil { return err } allValues = append(allValues, vals...) } err = e.appendValues(allValues, strconv.Itoa(proj)) if err != nil { return err } } return nil } func (e *IntegrationMatcherExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *IntegrationMatcherExporter) restoreValue(val EntityObject[db.IntegrationMatcher], store db.Store, exporter DataExporter) (err error) { old := val.value old.IntegrationID, err = exporter.getNewKeyInt(Integration, val.scope, old.IntegrationID) if err != nil { return err } newVault, err := store.CreateIntegrationMatcher(0, old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newVault.GetDbKey()) } func (e *IntegrationMatcherExporter) getName() string { return IntegrationMatcher } func (e *IntegrationMatcherExporter) exportDependsOn() []string { return []string{Project, Integration} } func (e *IntegrationMatcherExporter) importDependsOn() []string { return []string{Project, Integration} } ================================================ FILE: services/export/Inventory.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type InventoryExporter struct { ValueMap[db.Inventory] } func (e *InventoryExporter) load(store db.Store, exporter DataExporter, progress Progress) error { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, proj := range projs { envs, err := store.GetInventories(proj, db.RetrieveQueryParams{}, []db.InventoryType{}) if err != nil { return err } err = e.appendValues(envs, strconv.Itoa(proj)) if err != nil { return err } } return nil } func (e *InventoryExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *InventoryExporter) restoreValue(val EntityObject[db.Inventory], store db.Store, exporter DataExporter) (err error) { old := val.value old.ProjectID, err = exporter.getNewKeyInt(Project, GlobalScope, old.ProjectID) if err != nil { return err } old.SSHKeyID, err = exporter.getNewKeyIntRef(AccessKey, val.scope, old.SSHKeyID, e) if err != nil { return err } old.BecomeKeyID, err = exporter.getNewKeyIntRef(AccessKey, val.scope, old.BecomeKeyID, e) if err != nil { return err } old.RepositoryID, err = exporter.getNewKeyIntRef(Repository, val.scope, old.RepositoryID, e) if err != nil { return err } //templateId, err := exporter.getKeyMapForType(Template, *old.BecomeKeyID) //if err != nil { // return err //} //old.TemplateID = &templateId newObj, err := store.CreateInventory(old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newObj.GetDbKey()) } func (e *InventoryExporter) getName() string { return Inventory } func (e *InventoryExporter) exportDependsOn() []string { return []string{Project} } func (e *InventoryExporter) importDependsOn() []string { return []string{Project, AccessKey, Repository} } ================================================ FILE: services/export/Option.go ================================================ package export import ( "github.com/semaphoreui/semaphore/db" ) type OptionExporter struct { ValueMap[db.Option] } func (e *OptionExporter) load(store db.Store, exporter DataExporter, progress Progress) error { options, err := store.GetOptions(db.RetrieveQueryParams{}) if err != nil { return err } err = e.appendValues(getOption(options), GlobalScope) if err != nil { return err } return nil } func getOption(opts map[string]string) []db.Option { values := make([]db.Option, 0) for key, val := range opts { values = append(values, db.Option{ Key: key, Value: val, }) } return values } func (e *OptionExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *OptionExporter) restoreValue(val EntityObject[db.Option], store db.Store, exporter DataExporter) (err error) { return store.SetOption(val.value.Key, val.value.Value) } func (e *OptionExporter) exportDependsOn() []string { return []string{} } func (e *OptionExporter) importDependsOn() []string { return []string{} } func (e *OptionExporter) getName() string { return Option } ================================================ FILE: services/export/Project.go ================================================ package export import "github.com/semaphoreui/semaphore/db" type ProjectExporter struct { ValueMap[db.Project] } func (e *ProjectExporter) load(store db.Store, exporter DataExporter, progress Progress) error { allKeys := make([]db.Project, 0) users, err := exporter.getLoadedKeysInt(User, GlobalScope) if err != nil { return err } ids := make(map[int]bool) for _, userId := range users { projects, err := store.GetProjects(userId) if err != nil { return err } for _, proj := range projects { if ids[proj.ID] { continue } ids[proj.ID] = true allKeys = append(allKeys, proj) } } return e.appendValues(allKeys, GlobalScope) } func (e *ProjectExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *ProjectExporter) restoreValue(val EntityObject[db.Project], store db.Store, exporter DataExporter) (err error) { old := val.value newObj, err := store.CreateProject(old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newObj.GetDbKey()) } func (e *ProjectExporter) exportDependsOn() []string { return []string{User} } func (e *ProjectExporter) getName() string { return Project } ================================================ FILE: services/export/ProjectUser.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type ProjectUserExporter struct { ValueMap[db.ProjectUser] } func (e *ProjectUserExporter) load(store db.Store, exporter DataExporter, progress Progress) error { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, projId := range projs { users, err := store.GetProjectUsers(projId, db.RetrieveQueryParams{}) if err != nil { return err } err = e.appendValues(getUsers(users, projId), strconv.Itoa(projId)) if err != nil { return err } } return nil } func getUsers(vals []db.UserWithProjectRole, projId int) []db.ProjectUser { values := make([]db.ProjectUser, 0) for _, val := range vals { values = append(values, db.ProjectUser{ UserID: val.User.ID, Role: val.Role, ProjectID: projId, }) } return values } func (e *ProjectUserExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *ProjectUserExporter) restoreValue(val EntityObject[db.ProjectUser], store db.Store, exporter DataExporter) (err error) { old := val.value old.ProjectID, err = exporter.getNewKeyInt(Project, GlobalScope, old.ProjectID) if err != nil { return err } old.UserID, err = exporter.getNewKeyInt(User, GlobalScope, old.UserID) if err != nil { return err } newObj, err := store.CreateProjectUser(old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newObj.GetDbKey()) } func (e *ProjectUserExporter) exportDependsOn() []string { return []string{User, Project} } func (e *ProjectUserExporter) importDependsOn() []string { return []string{User, Project} } func (e *ProjectUserExporter) getName() string { return ProjectUser } ================================================ FILE: services/export/Repository.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type RepositoryExporter struct { ValueMap[db.Repository] } func (e *RepositoryExporter) load(store db.Store, exporter DataExporter, progress Progress) error { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, projId := range projs { envs, err := store.GetRepositories(projId, db.RetrieveQueryParams{}) if err != nil { return err } err = e.appendValues(envs, strconv.Itoa(projId)) if err != nil { return err } } return nil } func (e *RepositoryExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *RepositoryExporter) restoreValue(val EntityObject[db.Repository], store db.Store, exporter DataExporter) (err error) { old := val.value old.ProjectID, err = exporter.getNewKeyInt(Project, GlobalScope, old.ProjectID) if err != nil { return err } old.SSHKeyID, err = exporter.getNewKeyInt(AccessKey, val.scope, old.SSHKeyID) if err != nil { return err } newObj, err := store.CreateRepository(old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newObj.GetDbKey()) } func (e *RepositoryExporter) exportDependsOn() []string { return []string{Project} } func (e *RepositoryExporter) importDependsOn() []string { return []string{Project, AccessKey} } func (e *RepositoryExporter) getName() string { return Repository } ================================================ FILE: services/export/Role.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type RoleExporter struct { ValueMap[db.Role] } func (e *RoleExporter) load(store db.Store, exporter DataExporter, progress Progress) error { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, proj := range projs { roles, err := store.GetProjectRoles(proj) if err != nil { return err } err = e.appendValues(roles, strconv.Itoa(proj)) if err != nil { return err } } roles, err := store.GetGlobalRoles() if err != nil { return err } return e.appendValues(roles, GlobalScope) } func (e *RoleExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *RoleExporter) restoreValue(val EntityObject[db.Role], store db.Store, exporter DataExporter) (err error) { old := val.value old.ProjectID, err = exporter.getNewKeyIntRef(Project, GlobalScope, old.ProjectID, e) if err != nil { return err } newObj, err := store.CreateRole(old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newObj.GetDbKey()) } func (e *RoleExporter) exportDependsOn() []string { return []string{Project} } func (e *RoleExporter) importDependsOn() []string { return []string{Project} } func (e *RoleExporter) getName() string { return Role } ================================================ FILE: services/export/Runner.go ================================================ package export import ( "github.com/semaphoreui/semaphore/db" ) type RunnerExporter struct { ValueMap[db.Runner] } func (e *RunnerExporter) load(store db.Store, exporter DataExporter, progress Progress) error { envs, err := store.GetAllRunners(false, false) if err != nil { return err } err = e.appendValues(envs, GlobalScope) if err != nil { return err } return nil } func (e *RunnerExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *RunnerExporter) restoreValue(val EntityObject[db.Runner], store db.Store, exporter DataExporter) (err error) { old := val.value old.ProjectID, err = exporter.getNewKeyIntRef(Project, GlobalScope, old.ProjectID, e) if err != nil { return err } newObj, err := store.CreateRunner(old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newObj.GetDbKey()) } func (e *RunnerExporter) exportDependsOn() []string { return []string{Project} } func (e *RunnerExporter) importDependsOn() []string { return []string{Project} } func (e *RunnerExporter) getName() string { return Runner } ================================================ FILE: services/export/Schedule.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type ScheduleExporter struct { ValueMap[db.Schedule] } func (e *ScheduleExporter) load(store db.Store, exporter DataExporter, progress Progress) error { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, proj := range projs { vals, err := store.GetProjectSchedules(proj, true, true) if err != nil { return err } envs := getSchedules(vals) err = e.appendValues(envs, strconv.Itoa(proj)) if err != nil { return err } } return nil } func getSchedules(vals []db.ScheduleWithTpl) []db.Schedule { values := make([]db.Schedule, 0) for _, val := range vals { values = append(values, val.Schedule) } return values } func (e *ScheduleExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *ScheduleExporter) restoreValue(val EntityObject[db.Schedule], store db.Store, exporter DataExporter) (err error) { old := val.value if old.TaskParamsID != nil { old.TaskParams.InventoryID, err = exporter.getNewKeyIntRef(Inventory, val.scope, old.TaskParams.InventoryID, e) if err != nil { return err } old.TaskParams.ProjectID, err = exporter.getNewKeyInt(Project, GlobalScope, old.ProjectID) if err != nil { return err } } old.ProjectID, err = exporter.getNewKeyInt(Project, GlobalScope, old.ProjectID) if err != nil { return err } old.RepositoryID, err = exporter.getNewKeyIntRef(Repository, val.scope, old.RepositoryID, e) if err != nil { return err } old.TemplateID, err = exporter.getNewKeyInt(Template, val.scope, old.TemplateID) if err != nil { return err } newObj, err := store.CreateSchedule(old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newObj.GetDbKey()) } func (e *ScheduleExporter) getName() string { return Schedule } func (e *ScheduleExporter) exportDependsOn() []string { return []string{Project} } func (e *ScheduleExporter) importDependsOn() []string { return []string{Repository, Project, Inventory, Template} } ================================================ FILE: services/export/SecretStorage.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type SecretStorageExporter struct { ValueMap[db.SecretStorage] } func (e *SecretStorageExporter) load(store db.Store, exporter DataExporter, progress Progress) error { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, projId := range projs { keys, err := store.GetSecretStorages(projId) if err != nil { return err } err = e.appendValues(keys, strconv.Itoa(projId)) if err != nil { return err } } return nil } func (e *SecretStorageExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *SecretStorageExporter) restoreValue(val EntityObject[db.SecretStorage], store db.Store, exporter DataExporter) (err error) { old := val.value old.ProjectID, err = exporter.getNewKeyInt(Project, GlobalScope, old.ProjectID) newObj, err := store.CreateSecretStorage(old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newObj.GetDbKey()) } func (e *SecretStorageExporter) exportDependsOn() []string { return []string{Project} } func (e *SecretStorageExporter) importDependsOn() []string { return []string{Project} } func (e *SecretStorageExporter) getName() string { return SecretStorage } ================================================ FILE: services/export/Task.go ================================================ package export import ( "slices" "strconv" "github.com/semaphoreui/semaphore/db" ) type TaskExporter struct { ValueMap[db.Task] } func (e *TaskExporter) load(store db.Store, exporter DataExporter, progress Progress) error { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, proj := range projs { tasksTmpl, err := store.GetProjectTasks(proj, db.RetrieveQueryParams{}) if err != nil { return err } tasks := make([]db.Task, len(tasksTmpl)) for i, task := range tasksTmpl { tasks[i] = task.Task } slices.Reverse(tasks) err = e.appendValues(tasks, strconv.Itoa(proj)) if err != nil { return err } } return nil } func (e *TaskExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *TaskExporter) restoreValue(val EntityObject[db.Task], store db.Store, exporter DataExporter) (err error) { old := val.value old.ProjectID, err = exporter.getNewKeyInt(Project, GlobalScope, old.ProjectID) if err != nil { return err } old.TemplateID, err = exporter.getNewKeyInt(Template, val.scope, old.TemplateID) if err != nil { return err } old.InventoryID, err = exporter.getNewKeyIntRef(Inventory, val.scope, old.InventoryID, e) if err != nil { return err } old.ScheduleID, err = exporter.getNewKeyIntRef(Schedule, val.scope, old.ScheduleID, e) if err != nil { return err } old.UserID, err = exporter.getNewKeyIntRef(User, GlobalScope, old.UserID, e) if err != nil { return err } old.IntegrationID, err = exporter.getNewKeyIntRef(Integration, val.scope, old.IntegrationID, e) if err != nil { return err } old.BuildTaskID, err = exporter.getNewKeyIntRef(Task, val.scope, old.BuildTaskID, e) if err != nil { return err } newObj, err := store.CreateTask(old, 0) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newObj.GetDbKey()) } func (e *TaskExporter) getName() string { return Task } func (e *TaskExporter) exportDependsOn() []string { return []string{Project} } func (e *TaskExporter) importDependsOn() []string { return []string{Project, Template, Inventory, Integration, Schedule, User} } ================================================ FILE: services/export/TaskOutput.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type TaskOutputExporter struct { ValueMap[db.TaskOutput] } func (e *TaskOutputExporter) load(store db.Store, exporter DataExporter, progress Progress) error { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } taskCount, err := taskCount(exporter) if err != nil { return err } taskIndex := 0 for _, projId := range projs { tasks, err := exporter.getLoadedKeysInt(Task, strconv.Itoa(projId)) if err != nil { return err } allValues := make([]db.TaskOutput, 0) for _, task := range tasks { outputRes, err := store.GetTaskOutputs(projId, task, db.RetrieveQueryParams{}) if err != nil { return err } allValues = append(allValues, outputRes...) taskIndex = taskIndex + 1 progress.update(float32(taskIndex)/float32(taskCount), 0) } err = e.appendValues(allValues, strconv.Itoa(projId)) if err != nil { return err } } return nil } func taskCount(exporter DataExporter) (int, error) { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return 0, err } count := 0 for _, projId := range projs { tasks, err := exporter.getLoadedKeysInt(Task, strconv.Itoa(projId)) if err != nil { return 0, err } count = count + len(tasks) } return count, nil } func (e *TaskOutputExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { outputs := make([]db.TaskOutput, 0) size := len(e.values) for index, val := range e.values { old := val.value old.TaskID, err = exporter.getNewKeyInt(Task, val.scope, old.TaskID) if err != nil { return err } // boltDb currently doesn't support task stages old.StageID = nil //, err = exporter.getNewKeyIntRef(TaskStage, val.scope, old.StageID, e) //if err != nil { // return err //} outputs = append(outputs, old) if len(outputs) >= 1000 { err = store.InsertTaskOutputBatch(outputs) if err != nil { return err } outputs = make([]db.TaskOutput, 0) } progress.update(float32(index)/float32(size), int64(index)) } if len(outputs) > 0 { err = store.InsertTaskOutputBatch(outputs) if err != nil { return err } } return nil } func (e *TaskOutputExporter) getName() string { return TaskOutput } func (e *TaskOutputExporter) exportDependsOn() []string { return []string{Task} } func (e *TaskOutputExporter) importDependsOn() []string { return []string{Task, TaskStage} } ================================================ FILE: services/export/TaskStage.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type TaskStageExporter struct { ValueMap[db.TaskStage] } func (e *TaskStageExporter) load(store db.Store, exporter DataExporter, progress Progress) error { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, projId := range projs { tasks, err := exporter.getLoadedKeysInt(Task, strconv.Itoa(projId)) if err != nil { return err } allValues := make([]db.TaskStage, 0) for _, task := range tasks { stagesRes, err := store.GetTaskStages(projId, task) if err != nil { return err } allValues = append(allValues, getStages(stagesRes)...) } err = e.appendValues(allValues, strconv.Itoa(projId)) if err != nil { return err } } return nil } func getStages(vals []db.TaskStageWithResult) []db.TaskStage { values := make([]db.TaskStage, 0) for _, val := range vals { values = append(values, db.TaskStage{ ID: val.ID, TaskID: val.TaskID, Start: val.Start, End: val.End, Type: val.Type, }) } return values } func (e *TaskStageExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *TaskStageExporter) restoreValue(val EntityObject[db.TaskStage], store db.Store, exporter DataExporter) (err error) { old := val.value old.TaskID, err = exporter.getNewKeyInt(Task, val.scope, old.TaskID) if err != nil { return err } newObj, err := store.CreateTaskStage(old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newObj.GetDbKey()) } func (e *TaskStageExporter) getName() string { return TaskStage } func (e *TaskStageExporter) exportDependsOn() []string { return []string{Task} } func (e *TaskStageExporter) importDependsOn() []string { return []string{Task} } ================================================ FILE: services/export/TaskStageResult.go ================================================ package export import ( "encoding/json" "fmt" "strconv" "github.com/semaphoreui/semaphore/db" ) type TaskStageResultExporter struct { ValueMap[db.TaskStageResult] } func (e *TaskStageResultExporter) load(store db.Store, exporter DataExporter, progress Progress) error { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, projId := range projs { tasks, err := exporter.getLoadedKeysInt(Task, strconv.Itoa(projId)) if err != nil { return err } allValues := make([]db.TaskStageResult, 0) for _, task := range tasks { stagesRes, err := store.GetTaskStages(projId, task) if err != nil { return err } allValues = append(allValues, getStageResults(stagesRes)...) } err = e.appendValues(allValues, strconv.Itoa(projId)) if err != nil { return err } } return nil } func getStageResults(vals []db.TaskStageWithResult) []db.TaskStageResult { values := make([]db.TaskStageResult, 0) for _, val := range vals { values = append(values, db.TaskStageResult{ ID: val.ID, TaskID: val.TaskID, JSON: val.JSON, }) } return values } func (e *TaskStageResultExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *TaskStageResultExporter) restoreValue(val EntityObject[db.TaskStageResult], store db.Store, exporter DataExporter) (err error) { old := val.value old.TaskID, err = exporter.getNewKeyInt(Task, val.scope, old.TaskID) if err != nil { return err } res := make(map[string]any) err = json.Unmarshal([]byte(old.JSON), &res) if err != nil { fmt.Println("Unable to parse TaskStageResult " + old.JSON) } return store.CreateTaskStageResult(old.TaskID, old.StageID, res) } func (e *TaskStageResultExporter) getName() string { return TaskStageResult } func (e *TaskStageResultExporter) exportDependsOn() []string { return []string{Task, Project} } func (e *TaskStageResultExporter) importDependsOn() []string { return []string{Task, TaskStage} } ================================================ FILE: services/export/Template.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type TemplateExporter struct { ValueMap[db.Template] } func (e *TemplateExporter) load(store db.Store, exporter DataExporter, progress Progress) (err error) { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, projId := range projs { templates, err := store.GetTemplates(projId, db.TemplateFilter{}, db.RetrieveQueryParams{}) if err != nil { return err } err = e.appendValues(templates, strconv.Itoa(projId)) if err != nil { return err } } return nil } func (e *TemplateExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *TemplateExporter) restoreValue(val EntityObject[db.Template], store db.Store, exporter DataExporter) (err error) { old := val.value old.Vaults = nil old.ProjectID, err = exporter.getNewKeyInt(Project, GlobalScope, old.ProjectID) if err != nil { return err } old.InventoryID, err = exporter.getNewKeyIntRef(Inventory, val.scope, old.InventoryID, e) if err != nil { return err } old.EnvironmentID, err = exporter.getNewKeyIntRef(Environment, val.scope, old.EnvironmentID, e) if err != nil { return err } old.RepositoryID, err = exporter.getNewKeyInt(Repository, val.scope, old.RepositoryID) if err != nil { return err } old.ViewID, err = exporter.getNewKeyIntRef(View, val.scope, old.ViewID, e) if err != nil { return err } old.BuildTemplateID, err = exporter.getNewKeyIntRef(Template, val.scope, old.BuildTemplateID, e) if err != nil { return err } newObj, err := store.CreateTemplate(old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newObj.GetDbKey()) } func (e *TemplateExporter) getName() string { return Template } func (e *TemplateExporter) exportDependsOn() []string { return []string{Project} } func (e *TemplateExporter) importDependsOn() []string { return []string{Project, Inventory, Environment, Repository, View} } ================================================ FILE: services/export/TemplateRoles.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type TemplateRoleExporter struct { ValueMap[db.TemplateRolePerm] } func (e *TemplateRoleExporter) load(store db.Store, exporter DataExporter, progress Progress) (err error) { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, projId := range projs { templates, err := exporter.getLoadedKeysInt(Template, strconv.Itoa(projId)) if err != nil { return err } roles := make([]db.TemplateRolePerm, 0) for key := range templates { templateRoles, err := store.GetTemplateRoles(projId, key) if err != nil { return err } roles = append(roles, templateRoles...) } err = e.appendValues(roles, strconv.Itoa(projId)) if err != nil { return err } } return nil } func (e *TemplateRoleExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *TemplateRoleExporter) restoreValue(val EntityObject[db.TemplateRolePerm], store db.Store, exporter DataExporter) (err error) { old := val.value old.RoleSlug, err = exporter.getNewKey(Role, val.scope, old.RoleSlug) if err != nil { return err } old.TemplateID, err = exporter.getNewKeyInt(Template, val.scope, old.TemplateID) if err != nil { return err } old.ProjectID, err = exporter.getNewKeyInt(Project, GlobalScope, old.ProjectID) if err != nil { return err } newObj, err := store.CreateTemplateRole(old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newObj.GetDbKey()) } func (e *TemplateRoleExporter) getName() string { return TemplateRole } func (e *TemplateRoleExporter) importDependsOn() []string { return []string{Role, Template, Project} } func (e *TemplateRoleExporter) exportDependsOn() []string { return []string{Template, Project} } ================================================ FILE: services/export/TemplateVault.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type TemplateVaultExporter struct { ValueMap[db.TemplateVault] } func (e *TemplateVaultExporter) load(store db.Store, exporter DataExporter, progress Progress) (err error) { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, projId := range projs { templates, err := exporter.getLoadedKeysInt(Template, strconv.Itoa(projId)) if err != nil { return err } vaultsArr := make([]db.TemplateVault, 0) for key := range templates { vaults, err := store.GetTemplateVaults(projId, key) if err != nil { return err } vaultsArr = append(vaultsArr, vaults...) } err = e.appendValues(vaultsArr, strconv.Itoa(projId)) if err != nil { return err } } return nil } func (e *TemplateVaultExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *TemplateVaultExporter) restoreValue(val EntityObject[db.TemplateVault], store db.Store, exporter DataExporter) (err error) { old := val.value old.VaultKeyID, err = exporter.getNewKeyIntRef(AccessKey, val.scope, old.VaultKeyID, e) if err != nil { return err } old.TemplateID, err = exporter.getNewKeyInt(Template, val.scope, old.TemplateID) if err != nil { return err } old.ProjectID, err = exporter.getNewKeyInt(Project, GlobalScope, old.ProjectID) if err != nil { return err } newObj, err := store.CreateTemplateVault(old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newObj.GetDbKey()) } func (e *TemplateVaultExporter) getName() string { return TemplateVault } func (e *TemplateVaultExporter) importDependsOn() []string { return []string{Project, Template, AccessKey} } func (e *TemplateVaultExporter) exportDependsOn() []string { return []string{Template} } ================================================ FILE: services/export/User.go ================================================ package export import ( "github.com/semaphoreui/semaphore/db" ) type UserExporter struct { ValueMap[db.User] MergeExisting bool } func (a *UserExporter) load(store db.Store, exporter DataExporter, progress Progress) error { users, err := store.GetUsers(db.RetrieveQueryParams{}) if err != nil { return err } return a.appendValues(users, GlobalScope) } func (a *UserExporter) restore(store db.Store, exporter DataExporter, progress Progress) error { var userMap = make(map[string]*db.User) if a.MergeExisting { users, err := store.GetUsers(db.RetrieveQueryParams{}) if err != nil { return err } for _, user := range users { userMap[user.Username] = &user } } for _, val := range a.values { var err error old := val.value var obj db.User if u, ok := userMap[old.Username]; ok && a.MergeExisting { obj = *u } else { obj, err = store.ImportUser(db.UserWithPwd{Pwd: old.Password, User: old}) if err != nil { return err } } err = exporter.mapKeys(a.getName(), GlobalScope, old.GetDbKey(), obj.GetDbKey()) if err != nil { return err } } return nil } func (a *UserExporter) getName() string { return User } ================================================ FILE: services/export/View.go ================================================ package export import ( "strconv" "github.com/semaphoreui/semaphore/db" ) type ViewExporter struct { ValueMap[db.View] } func (e *ViewExporter) load(store db.Store, exporter DataExporter, progress Progress) error { projs, err := exporter.getLoadedKeysInt(Project, GlobalScope) if err != nil { return err } for _, proj := range projs { envs, err := store.GetViews(proj) if err != nil { return err } err = e.appendValues(envs, strconv.Itoa(proj)) if err != nil { return err } } return nil } func (e *ViewExporter) restore(store db.Store, exporter DataExporter, progress Progress) (err error) { return e.restoreValues(store, exporter, progress, e) } func (e *ViewExporter) restoreValue(val EntityObject[db.View], store db.Store, exporter DataExporter) (err error) { old := val.value old.ProjectID, err = exporter.getNewKeyInt(Project, GlobalScope, old.ProjectID) if err != nil { return err } newObj, err := store.CreateView(old) if err != nil { return err } return exporter.mapKeys(e.getName(), val.scope, old.GetDbKey(), newObj.GetDbKey()) } func (e *ViewExporter) exportDependsOn() []string { return []string{Project} } func (e *ViewExporter) importDependsOn() []string { return []string{Project} } func (e *ViewExporter) getName() string { return View } ================================================ FILE: services/project/backup.go ================================================ package project import ( "encoding/json" "fmt" "reflect" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/random" ) func findNameBySlug[T db.BackupSluggedEntity](slug string, items []T) (*string, error) { for _, o := range items { if o.GetSlug() == slug { name := o.GetName() return &name, nil } } return nil, fmt.Errorf("item %s does not exist", slug) } func findNameByID[T db.BackupEntity](ID int, items []T) (*string, error) { for _, o := range items { if o.GetID() == ID { name := o.GetName() return &name, nil } } return nil, fmt.Errorf("item %d does not exist", ID) } func findEntityByName[T db.BackupEntity](name *string, items []T) *T { if name == nil { return nil } for _, o := range items { if o.GetName() == *name { return &o } } return nil } func getSchedulesByProject(projectID int, schedules []db.Schedule) []db.Schedule { result := make([]db.Schedule, 0) for _, o := range schedules { if o.ProjectID == projectID { result = append(result, o) } } return result } func getScheduleByTemplate(templateID int, schedules []db.Schedule) *string { for _, o := range schedules { if o.TemplateID == templateID { return &o.CronFormat } } return nil } func getRandomName(name string) string { return name + " - " + random.String(10) } func makeUniqueNames[T any](items []T, getter func(item *T) string, setter func(item *T, name string)) { for i := len(items) - 1; i >= 0; i-- { for k, other := range items { if k == i { break } name := getter(&items[i]) if name == getter(&other) { randomName := getRandomName(name) setter(&items[i], randomName) break } } } } func (b *BackupDB) makeUniqueNames() { makeUniqueNames(b.templates, func(item *db.Template) string { return item.Name }, func(item *db.Template, name string) { item.Name = name }) makeUniqueNames(b.repositories, func(item *db.Repository) string { return item.Name }, func(item *db.Repository, name string) { item.Name = name }) makeUniqueNames(b.inventories, func(item *db.Inventory) string { return item.Name }, func(item *db.Inventory, name string) { item.Name = name }) makeUniqueNames(b.environments, func(item *db.Environment) string { return item.Name }, func(item *db.Environment, name string) { item.Name = name }) makeUniqueNames(b.keys, func(item *db.AccessKey) string { return item.Name }, func(item *db.AccessKey, name string) { item.Name = name }) makeUniqueNames(b.views, func(item *db.View) string { return item.Title }, func(item *db.View, name string) { item.Title = name }) makeUniqueNames(b.integrations, func(item *db.Integration) string { return item.Name }, func(item *db.Integration, name string) { item.Name = name }) makeUniqueNames(b.schedules, func(item *db.Schedule) string { return item.Name }, func(item *db.Schedule, name string) { item.Name = name }) makeUniqueNames(b.roles, func(item *db.Role) string { return item.Name }, func(item *db.Role, name string) { item.Name = name }) } func (b *BackupDB) load(projectID int, store db.Store) (err error) { b.templates, err = store.GetTemplates(projectID, db.TemplateFilter{}, db.RetrieveQueryParams{}) if err != nil { return } for i := range b.templates { var vaults []db.TemplateVault vaults, err = store.GetTemplateVaults(b.templates[i].ProjectID, b.templates[i].ID) if err != nil { return } b.templates[i].Vaults = vaults } b.repositories, err = store.GetRepositories(projectID, db.RetrieveQueryParams{}) if err != nil { return } b.keys, err = store.GetAccessKeys(projectID, db.GetAccessKeyOptions{IgnoreOwner: true}, db.RetrieveQueryParams{}) if err != nil { return } b.views, err = store.GetViews(projectID) if err != nil { return } b.inventories, err = store.GetInventories(projectID, db.RetrieveQueryParams{}, []db.InventoryType{}) if err != nil { return } b.environments, err = store.GetEnvironments(projectID, db.RetrieveQueryParams{}) if err != nil { return } schedules, err := store.GetProjectSchedules(projectID, true, true) if err != nil { return } for _, s := range schedules { b.schedules = append(b.schedules, s.Schedule) } b.secretStorages, err = store.GetSecretStorages(projectID) if err != nil { return } b.roles, err = store.GetProjectRoles(projectID) if err != nil { return } b.globalRoles, err = store.GetGlobalRoles() if err != nil { return } b.meta, err = store.GetProject(projectID) if err != nil { return } b.integrationProjAliases, err = store.GetIntegrationAliases(projectID, nil) if err != nil { return } b.integrations, err = store.GetIntegrations(projectID, db.RetrieveQueryParams{}, true) if err != nil { return } b.integrationAliases = make(map[int][]db.IntegrationAlias) b.integrationMatchers = make(map[int][]db.IntegrationMatcher) b.integrationExtractValues = make(map[int][]db.IntegrationExtractValue) for _, o := range b.integrations { b.integrationAliases[o.ID], err = store.GetIntegrationAliases(projectID, &o.ID) if err != nil { return } b.integrationMatchers[o.ID], err = store.GetIntegrationMatchers(projectID, db.RetrieveQueryParams{}, o.ID) if err != nil { return } b.integrationExtractValues[o.ID], err = store.GetIntegrationExtractValues(projectID, db.RetrieveQueryParams{}, o.ID) if err != nil { return } } b.templateRoles = make(map[int][]db.TemplateRolePerm) for _, t := range b.templates { b.templateRoles[t.ID], err = store.GetTemplateRoles(projectID, t.ID) if err != nil { return } } b.makeUniqueNames() return } func (b *BackupDB) format() (*BackupFormat, error) { roles := make([]BackupRole, len(b.roles)) for i, r := range b.roles { roles[i] = BackupRole{ r, } } schedules := make([]BackupSchedule, len(b.schedules)) for i, o := range b.schedules { tplName, _ := findNameByID[db.Template](o.TemplateID, b.templates) var repoName *string if o.RepositoryID != nil { repoName, _ = findNameByID[db.Repository](*o.RepositoryID, b.repositories) } if tplName == nil { continue } schedules[i] = BackupSchedule{ o, *tplName, repoName, } if o.TaskParams != nil && o.TaskParams.InventoryID != nil { schedules[i].TaskParams.InventoryName, _ = findNameByID[db.Inventory](*o.TaskParams.InventoryID, b.inventories) } } keys := make([]BackupAccessKey, len(b.keys)) for i, o := range b.keys { keys[i] = BackupAccessKey{ AccessKey: o, } } secretStorages := make([]BackupSecretStorage, len(b.secretStorages)) for i, o := range b.secretStorages { secretStorages[i] = BackupSecretStorage{ SecretStorage: o, } for k := range keys { if keys[k].StorageID != nil && *keys[k].StorageID == o.ID { keys[k].Storage = &o.Name } if keys[k].SourceStorageID != nil && *keys[k].SourceStorageID == o.ID { keys[k].SourceStorage = &o.Name } } } environments := make([]BackupEnvironment, len(b.environments)) for i, o := range b.environments { environments[i] = BackupEnvironment{ o, } } inventories := make([]BackupInventory, len(b.inventories)) for i, o := range b.inventories { var SSHKey *string = nil if o.SSHKeyID != nil { SSHKey, _ = findNameByID[db.AccessKey](*o.SSHKeyID, b.keys) } var BecomeKey *string = nil if o.BecomeKeyID != nil { BecomeKey, _ = findNameByID[db.AccessKey](*o.BecomeKeyID, b.keys) } inventories[i] = BackupInventory{ Inventory: o, SSHKey: SSHKey, BecomeKey: BecomeKey, } } views := make([]BackupView, len(b.views)) for i, o := range b.views { views[i] = BackupView{ o, } } repositories := make([]BackupRepository, len(b.repositories)) for i, o := range b.repositories { SSHKey, _ := findNameByID[db.AccessKey](o.SSHKeyID, b.keys) repositories[i] = BackupRepository{ Repository: o, SSHKey: SSHKey, } } templates := make([]BackupTemplate, len(b.templates)) for i, o := range b.templates { var View *string = nil if o.ViewID != nil { View, _ = findNameByID[db.View](*o.ViewID, b.views) } var vaults []BackupTemplateVault = nil for _, vault := range o.Vaults { var vaultKey *string = nil if vault.VaultKeyID != nil { vaultKey, _ = findNameByID[db.AccessKey](*vault.VaultKeyID, b.keys) } vaults = append(vaults, BackupTemplateVault{ TemplateVault: vault, VaultKey: vaultKey, }) } var Environment *string = nil if o.EnvironmentID != nil { Environment, _ = findNameByID[db.Environment](*o.EnvironmentID, b.environments) } var BuildTemplate *string = nil if o.BuildTemplateID != nil { BuildTemplate, _ = findNameByID[db.Template](*o.BuildTemplateID, b.templates) } Repository, _ := findNameByID[db.Repository](o.RepositoryID, b.repositories) var Inventory *string = nil if o.InventoryID != nil { Inventory, _ = findNameByID[db.Inventory](*o.InventoryID, b.inventories) } if o.SurveyVarsJSON != nil { surveyVars := make([]db.SurveyVar, 0) err := json.Unmarshal([]byte(*o.SurveyVarsJSON), &surveyVars) if err != nil { return nil, err } o.SurveyVars = surveyVars } var roles []BackupTemplateRole for _, r := range b.templateRoles[o.ID] { name, err := findNameBySlug[db.Role](r.RoleSlug, b.roles) if err == nil { roles = append(roles, BackupTemplateRole{ Role: *name, IsGlobal: false, Permissions: r.Permissions, }) } else { // Try to find in Global name, err = findNameBySlug[db.Role](r.RoleSlug, b.globalRoles) if err != nil { continue } roles = append(roles, BackupTemplateRole{ Role: *name, IsGlobal: true, Permissions: r.Permissions, }) } } templates[i] = BackupTemplate{ Template: o, View: View, Repository: *Repository, Inventory: Inventory, Environment: Environment, BuildTemplate: BuildTemplate, Vaults: vaults, Roles: roles, } } integrations := make([]BackupIntegration, len(b.integrations)) for i, o := range b.integrations { var aliases []string for _, a := range b.integrationAliases[o.ID] { aliases = append(aliases, a.Alias) } tplName, _ := findNameByID[db.Template](o.TemplateID, b.templates) if tplName == nil { continue } var keyName *string if o.AuthSecretID != nil { keyName, _ = findNameByID[db.AccessKey](*o.AuthSecretID, b.keys) } integrations[i] = BackupIntegration{ Integration: o, Aliases: aliases, Matchers: b.integrationMatchers[o.ID], ExtractValues: b.integrationExtractValues[o.ID], Template: *tplName, AuthSecret: keyName, } if o.TaskParams != nil && o.TaskParams.InventoryID != nil { integrations[i].TaskParams.InventoryName, _ = findNameByID[db.Inventory](*o.TaskParams.InventoryID, b.inventories) } } var integrationAliases []string for _, alias := range b.integrationProjAliases { integrationAliases = append(integrationAliases, alias.Alias) } return &BackupFormat{ Meta: BackupMeta{ b.meta, }, Inventories: inventories, Environments: environments, Views: views, Repositories: repositories, Keys: keys, Templates: templates, Integration: integrations, IntegrationAliases: integrationAliases, Schedules: schedules, SecretStorages: secretStorages, Roles: roles, }, nil } func GetBackup(projectID int, store db.Store) (*BackupFormat, error) { backup := BackupDB{} if err := backup.load(projectID, store); err != nil { return nil, err } return backup.format() } func (b *BackupFormat) Marshal() (res string, err error) { data, err := marshalValue(reflect.ValueOf(b)) if err != nil { return } bytes, err := json.MarshalIndent(data, "", " ") if err != nil { return } res = string(bytes) return } func (b *BackupFormat) Unmarshal(res string) (err error) { // Parse the JSON data into a map var jsonData any if err = json.Unmarshal([]byte(res), &jsonData); err != nil { return } // Start the recursive unmarshaling process err = unmarshalValueWithBackupTags(jsonData, reflect.ValueOf(b)) return } ================================================ FILE: services/project/backup_marshal.go ================================================ package project import ( "fmt" "github.com/semaphoreui/semaphore/db" "reflect" ) func marshalValue(v reflect.Value) (any, error) { // Handle pointers if v.Kind() == reflect.Ptr { if v.IsNil() { return nil, nil } return marshalValue(v.Elem()) } // Handle structs if v.Kind() == reflect.Struct { typeOfV := v.Type() result := make(map[string]any) for i := 0; i < v.NumField(); i++ { fieldValue := v.Field(i) fieldType := typeOfV.Field(i) // Handle anonymous fields (embedded structs) if fieldType.Anonymous { embeddedValue, err := marshalValue(fieldValue) if err != nil { return nil, err } if embeddedMap, ok := embeddedValue.(map[string]any); ok { // Merge embedded struct fields into parent result map for k, v := range embeddedMap { result[k] = v } } continue } tag := fieldType.Tag.Get("backup") if tag == "-" { continue // Skip fields with backup:"-" } // Check if the field should be backed up if tag == "" { // Get the field name from the "db" tag tag = fieldType.Tag.Get("db") if tag == "" || tag == "-" { continue // Skip if "db" tag is empty or "-" } } // Recursively process the field value value, err := marshalValue(fieldValue) if err != nil { return nil, err } if value == nil { continue } result[tag] = value } return result, nil } // Handle slices and arrays if v.Kind() == reflect.Slice || v.Kind() == reflect.Array { if v.IsNil() { return make([]any, 0), nil } var result = make([]any, 0) for i := 0; i < v.Len(); i++ { elemValue, err := marshalValue(v.Index(i)) if err != nil { return nil, err } result = append(result, elemValue) } return result, nil } // Handle maps if v.Kind() == reflect.Map { if v.IsNil() { return make(map[string]any), nil } result := make(map[string]any) for _, key := range v.MapKeys() { // Assuming the key is a string mapKey := fmt.Sprintf("%v", key.Interface()) mapValue, err := marshalValue(v.MapIndex(key)) if err != nil { return nil, err } result[mapKey] = mapValue } return result, nil } // Handle other types (int, string, etc.) return v.Interface(), nil } func setBasicType(data any, v reflect.Value) error { if !v.CanSet() { return fmt.Errorf("cannot set value") } switch v.Kind() { case reflect.Bool: b, ok := data.(bool) if !ok { return fmt.Errorf("expected bool for field, got %T", data) } v.SetBool(b) case reflect.String: s, ok := data.(string) if !ok { return fmt.Errorf("expected string for field, got %T", data) } v.SetString(s) case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: n, ok := toFloat64(data) if !ok { return fmt.Errorf("expected number for field, got %T", data) } v.SetInt(int64(n)) case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: n, ok := toFloat64(data) if !ok { return fmt.Errorf("expected number for field, got %T", data) } v.SetUint(uint64(n)) case reflect.Float32, reflect.Float64: n, ok := toFloat64(data) if !ok { return fmt.Errorf("expected number for field, got %T", data) } v.SetFloat(n) default: return fmt.Errorf("unsupported kind %v", v.Kind()) } return nil } func toFloat64(data any) (float64, bool) { switch n := data.(type) { case float64: return n, true case float32: return float64(n), true case int: return float64(n), true case int64: return float64(n), true case int32: return float64(n), true case int16: return float64(n), true case int8: return float64(n), true case uint: return float64(n), true case uint64: return float64(n), true case uint32: return float64(n), true case uint16: return float64(n), true case uint8: return float64(n), true default: return 0, false } } func unmarshalValueWithBackupTags(data any, v reflect.Value) error { // Handle pointers if v.Kind() == reflect.Ptr { // Initialize pointer if it's nil if v.IsNil() { v.Set(reflect.New(v.Type().Elem())) } return unmarshalValueWithBackupTags(data, v.Elem()) } // Handle structs if v.Kind() == reflect.Struct { // Data should be a map m, ok := data.(map[string]any) if !ok { return fmt.Errorf("expected object for struct, got %T", data) } return unmarshalStructWithBackupTags(m, v) } // Handle slices and arrays if v.Kind() == reflect.Slice { dataSlice, ok := data.([]any) if !ok { return fmt.Errorf("expected array for slice, got %T", data) } slice := reflect.MakeSlice(v.Type(), len(dataSlice), len(dataSlice)) for i := 0; i < len(dataSlice); i++ { elem := slice.Index(i) if err := unmarshalValueWithBackupTags(dataSlice[i], elem); err != nil { return err } } v.Set(slice) return nil } if v.Type() == reflect.TypeOf(db.MapStringAnyField{}) { // Data should be a map m, ok := data.(map[string]any) if !ok { return fmt.Errorf("expected object for map[string]interface{}, got %T", data) } v.Set(reflect.ValueOf(db.MapStringAnyField(m))) return nil } // Handle maps if v.Kind() == reflect.Map { dataMap, ok := data.(map[string]any) if !ok { return fmt.Errorf("expected object for map, got %T", data) } mapType := v.Type() mapValue := reflect.MakeMap(mapType) for key, value := range dataMap { keyVal := reflect.ValueOf(key).Convert(mapType.Key()) valVal := reflect.New(mapType.Elem()).Elem() if err := unmarshalValueWithBackupTags(value, valVal); err != nil { return err } mapValue.SetMapIndex(keyVal, valVal) } v.Set(mapValue) return nil } // Handle basic types return setBasicType(data, v) } func unmarshalStructWithBackupTags(data map[string]any, v reflect.Value) error { t := v.Type() for i := 0; i < v.NumField(); i++ { fieldType := t.Field(i) fieldValue := v.Field(i) // Handle anonymous fields (embedded structs) if fieldType.Anonymous { // Pass the entire data map to the embedded struct if err := unmarshalStructWithBackupTags(data, fieldValue); err != nil { return err } continue } // Skip fields with backup:"-" backupTag := fieldType.Tag.Get("backup") if backupTag == "-" { continue } // Determine the JSON key to use var jsonKey string if backupTag != "" { jsonKey = backupTag } else { dbTag := fieldType.Tag.Get("db") if dbTag != "" && dbTag != "-" { jsonKey = dbTag } else { continue // Skip if no backup or db tag } } // Check if the key exists in the data if value, ok := data[jsonKey]; ok { if !fieldValue.CanSet() { continue // Skip fields that cannot be set } if value == nil { continue } if err := unmarshalValueWithBackupTags(value, fieldValue); err != nil { return err } } } return nil } ================================================ FILE: services/project/backup_marshal_test.go ================================================ package project import ( "github.com/semaphoreui/semaphore/db" "github.com/stretchr/testify/assert" "reflect" "testing" ) func Test_MarshalValue_NilPointer_ReturnsNil(t *testing.T) { var ptr *int result, err := marshalValue(reflect.ValueOf(ptr)) assert.NoError(t, err) assert.Nil(t, result) } func Test_MarshalValue_StructWithFields_ReturnsMap(t *testing.T) { type TestStruct struct { Field1 string `backup:"field1"` Field2 int `backup:"field2"` } testStruct := TestStruct{Field1: "value1", Field2: 42} result, err := marshalValue(reflect.ValueOf(testStruct)) assert.NoError(t, err) expected := map[string]any{"field1": "value1", "field2": 42} assert.Equal(t, expected, result) } func Test_MarshalValue_Slice_ReturnsSlice(t *testing.T) { slice := []int{1, 2, 3} result, err := marshalValue(reflect.ValueOf(slice)) assert.NoError(t, err) expected := []any{1, 2, 3} assert.Equal(t, expected, result) } func Test_UnmarshalValueWithBackupTags_StructWithFields_SetsFields(t *testing.T) { type TestStruct struct { //Field1 string `backup:"field1"` //Field2 int `backup:"field2"` TaskParams db.MapStringAnyField `backup:"task_params"` } data := map[string]any{ //"field1": "value1", //"field2": 42, "task_params": map[string]any{ "allow_debug": true, "skip_tags": []string{"123"}, }, } var testStruct TestStruct err := unmarshalValueWithBackupTags(data, reflect.ValueOf(&testStruct).Elem()) assert.NoError(t, err) //assert.Equal(t, "value1", testStruct.Field1) //assert.Equal(t, 42, testStruct.Field2) } func Test_UnmarshalValueWithBackupTags_Slice_SetsElements(t *testing.T) { data := []any{1, 2, 3} var slice []int err := unmarshalValueWithBackupTags(data, reflect.ValueOf(&slice).Elem()) assert.NoError(t, err) expected := []int{1, 2, 3} assert.Equal(t, expected, slice) } func Test_UnmarshalValueWithBackupTags_Map_SetsEntries(t *testing.T) { data := map[string]any{"key1": "value1", "key2": "value2"} var m map[string]string err := unmarshalValueWithBackupTags(data, reflect.ValueOf(&m).Elem()) assert.NoError(t, err) expected := map[string]string{"key1": "value1", "key2": "value2"} assert.Equal(t, expected, m) } func Test_SetBasicType_InvalidType_ReturnsError(t *testing.T) { var v reflect.Value err := setBasicType("string", v) assert.Error(t, err) } func Test_ToFloat64_ValidInt_ReturnsFloat64(t *testing.T) { result, ok := toFloat64(42) assert.True(t, ok) assert.Equal(t, 42.0, result) } func Test_ToFloat64_InvalidType_ReturnsFalse(t *testing.T) { _, ok := toFloat64("string") assert.False(t, ok) } ================================================ FILE: services/project/backup_test.go ================================================ package project import ( "encoding/json" "testing" "github.com/semaphoreui/semaphore/db/sql" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/util" "github.com/stretchr/testify/assert" ) type testItem struct { Name string } func TestBackupProject(t *testing.T) { util.Config = &util.ConfigType{ TmpPath: "/tmp", } store := sql.CreateTestStore() proj, err := store.CreateProject(db.Project{ Name: "Test 123", }) assert.NoError(t, err) key, err := store.CreateAccessKey(db.AccessKey{ ProjectID: &proj.ID, Type: db.AccessKeyNone, }) assert.NoError(t, err) repo, err := store.CreateRepository(db.Repository{ ProjectID: proj.ID, SSHKeyID: key.ID, Name: "Test", GitURL: "git@example.com:test/test", GitBranch: "master", }) assert.NoError(t, err) inv, err := store.CreateInventory(db.Inventory{ ProjectID: proj.ID, ID: 1, }) assert.NoError(t, err) env, err := store.CreateEnvironment(db.Environment{ ProjectID: proj.ID, Name: "test", JSON: `{"author": "Denis", "comment": "Hello, World!"}`, }) assert.NoError(t, err) _, err = store.CreateTemplate(db.Template{ Name: "Test", Playbook: "test.yml", ProjectID: proj.ID, RepositoryID: repo.ID, InventoryID: &inv.ID, EnvironmentID: &env.ID, }) assert.NoError(t, err) backup, err := GetBackup(proj.ID, store) assert.NoError(t, err) assert.Equal(t, proj.ID, backup.Meta.ID) str, err := backup.Marshal() assert.NoError(t, err) restoredBackup := &BackupFormat{} err = restoredBackup.Unmarshal(str) assert.NoError(t, err) assert.Equal(t, restoredBackup.Meta.Name, "Test 123") restoredBackup.Meta.Name = "Test 1234" user, err := store.CreateUser(db.UserWithPwd{ Pwd: "3412341234123", User: db.User{ Username: "test", Name: "Test", Email: "test@example.com", Admin: true, }, }) assert.NoError(t, err) restoredProj, err := restoredBackup.Restore(user, store) assert.NoError(t, err) assert.Equal(t, restoredProj.Name, "Test 1234") } func TestBackup_BackupSecretStorage(t *testing.T) { util.Config = &util.ConfigType{ TmpPath: "/tmp", } store := sql.CreateTestStore() proj, err := store.CreateProject(db.Project{ Name: "Test 123", }) assert.NoError(t, err) storage, err := store.CreateSecretStorage(db.SecretStorage{ ProjectID: proj.ID, Type: "vault", Name: "Test", }) assert.NoError(t, err) _, err = store.CreateAccessKey(db.AccessKey{ ProjectID: &proj.ID, Type: db.AccessKeyNone, StorageID: &storage.ID, Name: "Test Key", Owner: "vault", }) assert.NoError(t, err) backup, err := GetBackup(proj.ID, store) assert.NoError(t, err) assert.Equal(t, proj.ID, backup.Meta.ID) backup.Meta.Name = "Test 1234" str, err := backup.Marshal() assert.NoError(t, err) var res map[string]any json.Unmarshal([]byte(str), &res) assert.Equal(t, `{ "environments": [], "integration_aliases": [], "integrations": [], "inventories": [], "keys": [ { "name": "Test Key", "owner": "vault", "storage": "Test", "type": "none" } ], "meta": { "alert": false, "max_parallel_tasks": 0, "name": "Test 1234", "type": "" }, "repositories": [], "roles": [], "schedules": [], "secret_storages": [ { "name": "Test", "params": {}, "readonly": false, "type": "vault" } ], "templates": [], "views": [] }`, str) restoredBackup := &BackupFormat{} err = restoredBackup.Unmarshal(str) assert.NoError(t, err) assert.Equal(t, restoredBackup.Meta.Name, "Test 1234") user, err := store.CreateUser(db.UserWithPwd{ Pwd: "3412341234123", User: db.User{ Username: "test", Name: "Test", Email: "test@example.com", Admin: true, }, }) assert.NoError(t, err) restoredProj, err := restoredBackup.Restore(user, store) assert.Nil(t, err) restoredStorages, err := store.GetSecretStorages(restoredProj.ID) assert.NoError(t, err) assert.Len(t, restoredStorages, 1) restoredKeys, err := store.GetAccessKeys(restoredProj.ID, db.GetAccessKeyOptions{IgnoreOwner: true}, db.RetrieveQueryParams{}) assert.NoError(t, err) assert.Len(t, restoredKeys, 1) assert.Equal(t, *restoredKeys[0].StorageID, restoredStorages[0].ID) } func isUnique(items []testItem) bool { for i, item := range items { for k, other := range items { if i == k { continue } if item.Name == other.Name { return false } } } return true } func TestMakeUniqueNames(t *testing.T) { items := []testItem{ {Name: "Project"}, {Name: "Solution"}, {Name: "Project"}, {Name: "Project"}, {Name: "Project"}, {Name: "Project"}, } makeUniqueNames(items, func(item *testItem) string { return item.Name }, func(item *testItem, name string) { item.Name = name }) assert.True(t, isUnique(items), "Not unique names") } ================================================ FILE: services/project/restore.go ================================================ package project import ( "fmt" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/random" ) func getEntryByName[T BackupEntry](name *string, items []T) *T { if name == nil { return nil } for _, o := range items { if o.GetName() == *name { return &o } } return nil } func verifyDuplicate[T BackupEntry](name string, items []T) error { n := 0 for _, o := range items { if o.GetName() == name { n++ } if n > 2 { return fmt.Errorf("%s is duplicate", name) } } return nil } func (e BackupSecretStorage) Verify(backup *BackupFormat) error { return verifyDuplicate[BackupSecretStorage](e.Name, backup.SecretStorages) } func (e BackupSecretStorage) Restore(store db.Store, b *BackupDB) error { st := e.SecretStorage st.ProjectID = b.meta.ID newStorage, err := store.CreateSecretStorage(st) if err != nil { return err } b.secretStorages = append(b.secretStorages, newStorage) return nil } func (e BackupRole) Verify(backup *BackupFormat) error { return verifyDuplicate[BackupRole](e.Name, backup.Roles) } func (e BackupRole) Restore(store db.Store, b *BackupDB) error { role := e.Role role.ProjectID = &b.meta.ID role.Slug = random.String(16) newRole, err := store.CreateRole(role) if err != nil { return err } b.roles = append(b.roles, newRole) return nil } func (e BackupEnvironment) Verify(backup *BackupFormat) error { return verifyDuplicate[BackupEnvironment](e.Name, backup.Environments) } func (e BackupEnvironment) Restore(store db.Store, b *BackupDB) error { env := e.Environment env.ProjectID = b.meta.ID newEnv, err := store.CreateEnvironment(env) if err != nil { return err } b.environments = append(b.environments, newEnv) return nil } func (e BackupView) Verify(backup *BackupFormat) error { return verifyDuplicate[BackupView](e.Title, backup.Views) } func (e BackupView) Restore(store db.Store, b *BackupDB) error { v := e.View v.ProjectID = b.meta.ID newView, err := store.CreateView(v) if err != nil { return err } b.views = append(b.views, newView) return nil } func (e BackupSchedule) Verify(backup *BackupFormat) error { return verifyDuplicate[BackupSchedule](e.Name, backup.Schedules) } func (e BackupSchedule) Restore(store db.Store, b *BackupDB) error { v := e.Schedule v.ProjectID = b.meta.ID tpl := findEntityByName[db.Template](&e.Template, b.templates) if tpl == nil { return fmt.Errorf("template does not exist in templates[].name") } v.TemplateID = tpl.ID if e.CheckableRepository != nil { repo := findEntityByName[db.Repository](e.CheckableRepository, b.repositories) if repo == nil { return fmt.Errorf("repo does not exist in repositories[].name") } v.RepositoryID = &repo.ID } inv := findEntityByName[db.Inventory](e.TaskParams.InventoryName, b.inventories) if inv != nil { v.TaskParams.InventoryID = &inv.ID } newSchedule, err := store.CreateSchedule(v) if err != nil { return err } b.schedules = append(b.schedules, newSchedule) return nil } func (e BackupAccessKey) Verify(backup *BackupFormat) error { return verifyDuplicate[BackupAccessKey](e.Name, backup.Keys) } func (e BackupAccessKey) Restore(store db.Store, b *BackupDB) error { key := e.AccessKey key.ProjectID = &b.meta.ID if e.Storage != nil { storage := findEntityByName[db.SecretStorage](e.Storage, b.secretStorages) if storage == nil { return fmt.Errorf("secret storage does not exist in secret_storage[].name") } key.StorageID = &storage.ID } if e.SourceStorage != nil { sourceStorage := findEntityByName[db.SecretStorage](e.SourceStorage, b.secretStorages) if sourceStorage == nil { return fmt.Errorf("secret storage does not exist in secret_storage[].name") } key.SourceStorageID = &sourceStorage.ID } newKey, err := store.CreateAccessKey(key) if err != nil { return err } b.keys = append(b.keys, newKey) return nil } func (e BackupInventory) Verify(backup *BackupFormat) error { if err := verifyDuplicate[BackupInventory](e.Name, backup.Inventories); err != nil { return err } if e.SSHKey != nil && getEntryByName[BackupAccessKey](e.SSHKey, backup.Keys) == nil { return fmt.Errorf("SSHKey does not exist in keys[].Name") } if e.BecomeKey != nil && getEntryByName[BackupAccessKey](e.BecomeKey, backup.Keys) == nil { return fmt.Errorf("BecomeKey does not exist in keys[].Name") } return nil } func (e BackupInventory) Restore(store db.Store, b *BackupDB) error { var SSHKeyID *int if e.SSHKey == nil { SSHKeyID = nil } else if k := findEntityByName[db.AccessKey](e.SSHKey, b.keys); k == nil { SSHKeyID = nil } else { SSHKeyID = &((*k).ID) } var BecomeKeyID *int if e.BecomeKey == nil { BecomeKeyID = nil } else if k := findEntityByName[db.AccessKey](e.BecomeKey, b.keys); k == nil { BecomeKeyID = nil } else { BecomeKeyID = &((*k).ID) } inv := e.Inventory inv.ProjectID = b.meta.ID inv.SSHKeyID = SSHKeyID inv.BecomeKeyID = BecomeKeyID newInventory, err := store.CreateInventory(inv) if err != nil { return err } b.inventories = append(b.inventories, newInventory) return nil } func (e BackupRepository) Verify(backup *BackupFormat) error { if err := verifyDuplicate[BackupRepository](e.Name, backup.Repositories); err != nil { return err } if e.SSHKey != nil && getEntryByName[BackupAccessKey](e.SSHKey, backup.Keys) == nil { return fmt.Errorf("SSHKey does not exist in keys[].Name") } return nil } func (e BackupRepository) Restore(store db.Store, b *BackupDB) error { var SSHKeyID int if k := findEntityByName[db.AccessKey](e.SSHKey, b.keys); k == nil { return fmt.Errorf("SSHKey does not exist in keys[].Name") } else { SSHKeyID = (*k).ID } repo := e.Repository repo.ProjectID = b.meta.ID repo.SSHKeyID = SSHKeyID newRepo, err := store.CreateRepository(repo) if err != nil { return err } b.repositories = append(b.repositories, newRepo) return nil } func (e BackupTemplate) Verify(backup *BackupFormat) error { if err := verifyDuplicate[BackupTemplate](e.Name, backup.Templates); err != nil { return err } if getEntryByName[BackupRepository](&e.Repository, backup.Repositories) == nil { return fmt.Errorf("repository does not exist in repositories[].name") } if e.Inventory != nil && getEntryByName[BackupInventory](e.Inventory, backup.Inventories) == nil { return fmt.Errorf("inventory does not exist in inventories[].name") } if e.VaultKey != nil && getEntryByName[BackupAccessKey](e.VaultKey, backup.Keys) == nil { return fmt.Errorf("vault_key does not exist in keys[].name") } if e.Vaults != nil { for _, vault := range e.Vaults { if vault.VaultKey != nil { if getEntryByName[BackupAccessKey](vault.VaultKey, backup.Keys) == nil { return fmt.Errorf("vaults[].vaultKey does not exist in keys[].name") } } } } if e.View != nil && getEntryByName[BackupView](e.View, backup.Views) == nil { return fmt.Errorf("view does not exist in views[].name") } if buildTemplate := getEntryByName[BackupTemplate](e.BuildTemplate, backup.Templates); string(e.Type) == "deploy" && buildTemplate == nil { return fmt.Errorf("deploy is build but build_template does not exist in templates[].name") } return nil } func (e BackupTemplate) Restore(store db.Store, b *BackupDB) error { var InventoryID *int if e.Inventory != nil { if k := findEntityByName[db.Inventory](e.Inventory, b.inventories); k == nil { return fmt.Errorf("inventory does not exist in inventories[].name") } else { id := k.GetID() InventoryID = &id } } var EnvironmentID *int if e.Environment != nil { if k := findEntityByName[db.Environment](e.Environment, b.environments); k == nil { return fmt.Errorf("environment does not exist in environments[].name") } else { id := k.GetID() EnvironmentID = &id } } var RepositoryID int if k := findEntityByName[db.Repository](&e.Repository, b.repositories); k == nil { return fmt.Errorf("repository does not exist in repositories[].name") } else { RepositoryID = k.GetID() } var BuildTemplateID *int if string(e.Type) != "deploy" { BuildTemplateID = nil } else if k := findEntityByName[db.Template](e.BuildTemplate, b.templates); k == nil { BuildTemplateID = nil } else { BuildTemplateID = &(k.ID) } var ViewID *int if k := findEntityByName[db.View](e.View, b.views); k == nil { ViewID = nil } else { ViewID = &k.ID } template := e.Template template.ProjectID = b.meta.ID template.RepositoryID = RepositoryID template.EnvironmentID = EnvironmentID template.InventoryID = InventoryID template.ViewID = ViewID template.BuildTemplateID = BuildTemplateID newTemplate, err := store.CreateTemplate(template) if err != nil { return err } b.templates = append(b.templates, newTemplate) if e.Vaults != nil { for _, vault := range e.Vaults { var VaultKeyID *int if vault.VaultKey != nil { if k := findEntityByName[db.AccessKey](vault.VaultKey, b.keys); k == nil { return fmt.Errorf("vaults[].vaultKey does not exist in keys[].name") } else { VaultKeyID = &k.ID } } tplVault := vault.TemplateVault tplVault.ProjectID = b.meta.ID tplVault.TemplateID = newTemplate.ID tplVault.VaultKeyID = VaultKeyID _, err := store.CreateTemplateVault(tplVault) if err != nil { return err } } } if e.Roles != nil { for _, role := range e.Roles { if role.IsGlobal { r, err := store.GetGlobalRoleBySlug(role.Role) if err != nil { return fmt.Errorf("global role does not exist: %s", role.Role) } _, err = store.CreateTemplateRole(db.TemplateRolePerm{ TemplateID: newTemplate.ID, RoleSlug: r.Slug, ProjectID: b.meta.ID, Permissions: role.Permissions, }) if err != nil { return err } continue } if k := findEntityByName[db.Role](&role.Role, b.roles); k == nil { return fmt.Errorf("roles[].role does not exist in roles[].name") } else { _, err = store.CreateTemplateRole(db.TemplateRolePerm{ TemplateID: newTemplate.ID, RoleSlug: k.Slug, ProjectID: b.meta.ID, Permissions: role.Permissions, }) if err != nil { return err } } } } return nil } func (e BackupIntegration) Restore(store db.Store, b *BackupDB) error { var authSecretID *int if e.AuthSecret == nil { authSecretID = nil } else if k := findEntityByName[db.AccessKey](e.AuthSecret, b.keys); k == nil { authSecretID = nil } else { authSecretID = &((*k).ID) } tpl := findEntityByName[db.Template](&e.Template, b.templates) if tpl == nil { return fmt.Errorf("template does not exist in templates[].name") } integration := e.Integration integration.ProjectID = b.meta.ID integration.AuthSecretID = authSecretID integration.TemplateID = tpl.ID if integration.TaskParams != nil { inv := findEntityByName[db.Inventory](e.TaskParams.InventoryName, b.inventories) if inv != nil { integration.TaskParams.InventoryID = &inv.ID } } newIntegration, err := store.CreateIntegration(integration) if err != nil { return err } b.integrations = append(b.integrations, newIntegration) for _, m := range e.Matchers { m.IntegrationID = newIntegration.ID _, _ = store.CreateIntegrationMatcher(b.meta.ID, m) } for _, v := range e.ExtractValues { v.IntegrationID = newIntegration.ID _, _ = store.CreateIntegrationExtractValue(b.meta.ID, v) } for _, a := range e.Aliases { alias := db.IntegrationAlias{ Alias: a, ProjectID: b.meta.ID, IntegrationID: &newIntegration.ID, } _, _ = store.CreateIntegrationAlias(alias) } return nil } func (backup *BackupFormat) Verify() error { for i, o := range backup.Environments { if err := o.Verify(backup); err != nil { return fmt.Errorf("error at environments[%d]: %s", i, err.Error()) } } for i, o := range backup.Views { if err := o.Verify(backup); err != nil { return fmt.Errorf("error at views[%d]: %s", i, err.Error()) } } for i, o := range backup.Schedules { if err := o.Verify(backup); err != nil { return fmt.Errorf("error at templates[%d]: %s", i, err.Error()) } } for i, o := range backup.Keys { if err := o.Verify(backup); err != nil { return fmt.Errorf("error at keys[%d]: %s", i, err.Error()) } } for i, o := range backup.Repositories { if err := o.Verify(backup); err != nil { return fmt.Errorf("error at repositories[%d]: %s", i, err.Error()) } } for i, o := range backup.Inventories { if err := o.Verify(backup); err != nil { return fmt.Errorf("error at inventories[%d]: %s", i, err.Error()) } } for i, o := range backup.SecretStorages { if err := o.Verify(backup); err != nil { return fmt.Errorf("error at secret storage[%d]: %s", i, err.Error()) } } for i, o := range backup.Templates { if err := o.Verify(backup); err != nil { return fmt.Errorf("error at templates[%d]: %s", i, err.Error()) } } for i, o := range backup.Roles { if err := o.Verify(backup); err != nil { return fmt.Errorf("error at roles[%d]: %s", i, err.Error()) } } return nil } func (backup *BackupFormat) Restore(user db.User, store db.Store) (*db.Project, error) { var b = BackupDB{} project := backup.Meta.Project // Prevent importing a project with a name that already exists existingProjects, err := store.GetAllProjects() if err == nil { for _, p := range existingProjects { if p.Name == project.Name { // exact name match return nil, db.NewValidationError(fmt.Sprintf("project with name '%s' already exists", project.Name)) } } } newProject, err := store.CreateProject(project) if err != nil { return nil, err } if _, err = store.CreateProjectUser(db.ProjectUser{ ProjectID: newProject.ID, UserID: user.ID, Role: db.ProjectOwner, }); err != nil { return nil, err } b.meta = newProject for i, o := range backup.SecretStorages { if err := o.Restore(store, &b); err != nil { return nil, fmt.Errorf("error at secret storage[%d]: %s", i, err.Error()) } } for i, o := range backup.Roles { if err := o.Restore(store, &b); err != nil { return nil, fmt.Errorf("error at roles[%d]: %s", i, err.Error()) } } for i, o := range backup.Environments { if err := o.Restore(store, &b); err != nil { return nil, fmt.Errorf("error at environments[%d]: %s", i, err.Error()) } } for i, o := range backup.Views { if err := o.Restore(store, &b); err != nil { return nil, fmt.Errorf("error at views[%d]: %s", i, err.Error()) } } for i, o := range backup.Keys { if err := o.Restore(store, &b); err != nil { return nil, fmt.Errorf("error at keys[%d]: %s", i, err.Error()) } } for i, o := range backup.Repositories { if err := o.Restore(store, &b); err != nil { return nil, fmt.Errorf("error at repositories[%d]: %s", i, err.Error()) } } for i, o := range backup.Inventories { if err := o.Restore(store, &b); err != nil { return nil, fmt.Errorf("error at inventories[%d]: %s", i, err.Error()) } } deployTemplates := make([]int, 0) for i, o := range backup.Templates { if string(o.Type) == "deploy" { deployTemplates = append(deployTemplates, i) continue } if err := o.Restore(store, &b); err != nil { return nil, fmt.Errorf("error at templates[%d]: %s", i, err.Error()) } } for _, i := range deployTemplates { o := backup.Templates[i] if err := o.Restore(store, &b); err != nil { return nil, fmt.Errorf("error at templates[%d]: %s", i, err.Error()) } } for i, o := range backup.Integration { if err := o.Restore(store, &b); err != nil { return nil, fmt.Errorf("error at integrations[%d]: %s", i, err.Error()) } } for _, o := range backup.IntegrationAliases { alias := db.IntegrationAlias{ Alias: o, ProjectID: b.meta.ID, } _, _ = store.CreateIntegrationAlias(alias) } for i, o := range backup.Schedules { if err := o.Restore(store, &b); err != nil { return nil, fmt.Errorf("error at schedules[%d]: %s", i, err.Error()) } } return &newProject, nil } ================================================ FILE: services/project/types.go ================================================ package project import ( "github.com/semaphoreui/semaphore/db" ) type BackupDB struct { meta db.Project templates []db.Template repositories []db.Repository keys []db.AccessKey views []db.View inventories []db.Inventory environments []db.Environment schedules []db.Schedule integrationProjAliases []db.IntegrationAlias integrations []db.Integration integrationAliases map[int][]db.IntegrationAlias integrationMatchers map[int][]db.IntegrationMatcher integrationExtractValues map[int][]db.IntegrationExtractValue secretStorages []db.SecretStorage globalRoles []db.Role roles []db.Role templateRoles map[int][]db.TemplateRolePerm } type BackupFormat struct { Meta BackupMeta `backup:"meta"` Templates []BackupTemplate `backup:"templates"` Repositories []BackupRepository `backup:"repositories"` Keys []BackupAccessKey `backup:"keys"` Views []BackupView `backup:"views"` Inventories []BackupInventory `backup:"inventories"` Environments []BackupEnvironment `backup:"environments"` Integration []BackupIntegration `backup:"integrations"` IntegrationAliases []string `backup:"integration_aliases"` Schedules []BackupSchedule `backup:"schedules"` SecretStorages []BackupSecretStorage `backup:"secret_storages"` Roles []BackupRole `backup:"roles"` } type BackupMeta struct { db.Project } type BackupEnvironment struct { db.Environment } type BackupAccessKey struct { db.AccessKey SourceStorage *string `backup:"source_storage"` Storage *string `backup:"storage"` } type BackupSchedule struct { db.Schedule Template string `backup:"template"` CheckableRepository *string `backup:"checkable_repository"` } type BackupView struct { db.View } type BackupInventory struct { db.Inventory SSHKey *string `backup:"ssh_key"` BecomeKey *string `backup:"become_key"` } type BackupRepository struct { db.Repository SSHKey *string `backup:"ssh_key"` } type BackupTemplateRole struct { Role string `backup:"role"` IsGlobal bool `backup:"is_global"` Permissions db.ProjectUserPermission `backup:"permissions"` } type BackupTemplate struct { db.Template Inventory *string `backup:"inventory"` Repository string `backup:"repository"` Environment *string `backup:"environment"` BuildTemplate *string `backup:"build_template"` View *string `backup:"view"` Vaults []BackupTemplateVault `backup:"vaults"` //Cron *string `backup:"cron"` // Deprecated: Left here for compatibility with old backups VaultKey *string `json:"vault_key"` Roles []BackupTemplateRole `backup:"roles"` } type BackupTemplateVault struct { db.TemplateVault VaultKey *string `backup:"vault_key"` } type BackupIntegration struct { db.Integration Aliases []string `backup:"aliases"` Matchers []db.IntegrationMatcher `backup:"matchers"` ExtractValues []db.IntegrationExtractValue `backup:"extract_values"` Template string `backup:"template"` AuthSecret *string `backup:"auth_secret"` } type BackupSecretStorage struct { db.SecretStorage } type BackupRole struct { db.Role } type BackupEntry interface { GetName() string Verify(backup *BackupFormat) error Restore(store db.Store, b *BackupDB) error } func (e BackupEnvironment) GetName() string { return e.Name } func (e BackupInventory) GetName() string { return e.Name } func (e BackupAccessKey) GetName() string { return e.Name } func (e BackupRepository) GetName() string { return e.Name } func (e BackupView) GetName() string { return e.Title } func (e BackupTemplate) GetName() string { return e.Name } func (e BackupSecretStorage) GetName() string { return e.Name } func (e BackupRole) GetName() string { return e.Name } ================================================ FILE: services/runners/job_pool.go ================================================ package runners import ( "bytes" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/json" "encoding/pem" "fmt" "io" "net/http" "os" "strconv" "sync/atomic" "time" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/db_lib" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/services/tasks" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" ) type JobLogger struct { Context string } func (e *JobLogger) ActionError(err error, action string, message string) { util.LogErrorF(err, log.Fields{ "type": "action", "context": e.Context, "action": action, "error": message, }) } func (e *JobLogger) Info(message string) { log.WithFields(log.Fields{ "context": e.Context, }).Info(message) } func (e *JobLogger) TaskInfo(message string, task int, status string) { log.WithFields(log.Fields{ "type": "task", "context": e.Context, "task": task, "status": status, }).Info(message) } func (e *JobLogger) Panic(err error, action string, message string) { log.WithFields(log.Fields{ "context": e.Context, }).Panic(message) } func (e *JobLogger) Debug(message string) { log.WithFields(log.Fields{ "context": e.Context, }).Debug(message) } type JobPool struct { runningJobs map[int]*runningJob queue []*job processing int32 keyInstaller db_lib.AccessKeyInstaller } func NewJobPool(keyInstaller db_lib.AccessKeyInstaller) *JobPool { return &JobPool{ runningJobs: make(map[int]*runningJob), queue: make([]*job, 0), processing: 0, keyInstaller: keyInstaller, } } func (p *JobPool) existsInQueue(taskID int) bool { for _, j := range p.queue { if j.job.Task.ID == taskID { return true } } return false } func (p *JobPool) hasRunningJobs() bool { for _, j := range p.runningJobs { if !j.status.IsFinished() { return true } } return false } func (p *JobPool) Register(configFilePath *string) (err error) { ok := p.tryRegisterRunner(configFilePath) if !ok { err = fmt.Errorf("runner registration failed") return } return } func (p *JobPool) Unregister() (err error) { if util.Config.Runner.Token == "" { return fmt.Errorf("runner is not registered") } client := &http.Client{} url := util.Config.WebHost + "/api/internal/runners" req, err := http.NewRequest("DELETE", url, nil) if err != nil { return } resp, err := client.Do(req) if err != nil { return } if resp.StatusCode >= 400 && resp.StatusCode != 404 { err = fmt.Errorf("encountered error while unregistering runner; server returned code %d", resp.StatusCode) return } if util.Config.Runner.TokenFile != "" { err = os.Remove(util.Config.Runner.TokenFile) } return } func (p *JobPool) Run() { logger := JobLogger{Context: "running"} launched := false if util.Config.Runner.Token == "" { logger.Panic(fmt.Errorf("no token provided"), "read input", "can not retrieve runner token") } queueTicker := time.NewTicker(5 * time.Second) requestTimer := time.NewTicker(1 * time.Second) p.runningJobs = make(map[int]*runningJob) defer func() { queueTicker.Stop() requestTimer.Stop() }() for { select { case <-queueTicker.C: // timer 5 seconds: get task from queue and run it logger.Debug("Checking queue") if len(p.queue) == 0 { break } t := p.queue[0] if t.status == task_logger.TaskFailStatus { //delete failed TaskRunner from queue p.queue = p.queue[1:] logger.TaskInfo("Task dequeued", t.job.Task.ID, "failed") break } p.runningJobs[t.job.Task.ID] = &runningJob{ job: t.job, } t.job.Logger = t.job.App.SetLogger(p.runningJobs[t.job.Task.ID]) go func(runningJob *runningJob) { runningJob.SetStatus(task_logger.TaskRunningStatus) err := runningJob.job.Run(t.username, t.incomingVersion, t.alias) if runningJob.status.IsFinished() { return } if err != nil { logger.ActionError(err, "launch job", "job failed") t.job.Logger.Log("Unable to launch the application. Please contact your system administrator for assistance.") if runningJob.status == task_logger.TaskStoppingStatus { runningJob.SetStatus(task_logger.TaskStoppedStatus) } else { runningJob.SetStatus(task_logger.TaskFailStatus) } } else { runningJob.SetStatus(task_logger.TaskSuccessStatus) } logger.TaskInfo("Task finished", runningJob.job.Task.ID, string(runningJob.status)) }(p.runningJobs[t.job.Task.ID]) p.queue = p.queue[1:] logger.TaskInfo("Task dequeued", t.job.Task.ID, string(t.job.Task.Status)) logger.TaskInfo("Task started", t.job.Task.ID, string(t.job.Task.Status)) case <-requestTimer.C: go func() { if !atomic.CompareAndSwapInt32(&p.processing, 0, 1) { return } defer atomic.StoreInt32(&p.processing, 0) ok := p.sendProgress() if ok && !launched { launched = true fmt.Println("Runner connected") } if util.Config.Runner.OneOff && len(p.runningJobs) > 0 && !p.hasRunningJobs() { os.Exit(0) } p.checkNewJobs() }() } } } func (p *JobPool) sendProgress() (ok bool) { logger := JobLogger{Context: "sending_progress"} client := &http.Client{} url := util.Config.WebHost + "/api/internal/runners" body := RunnerProgress{ Jobs: nil, } for id, j := range p.runningJobs { body.Jobs = append(body.Jobs, JobProgress{ ID: id, LogRecords: j.logRecords, Status: j.status, Commit: j.commit, }) j.logRecords = make([]LogRecord, 0) if j.status.IsFinished() { logger.TaskInfo("Task removed from running list", id, string(j.status)) delete(p.runningJobs, id) } } jsonBytes, err := json.Marshal(body) if err != nil { logger.ActionError(err, "form request body", "can not marshal json") return } req, err := http.NewRequest("PUT", url, bytes.NewBuffer(jsonBytes)) if err != nil { logger.ActionError(err, "create request", "can not create request to the server") return } req.Header.Set("X-Runner-Token", util.Config.Runner.Token) resp, err := client.Do(req) if err != nil { logger.ActionError(err, "send request", "the server returned error") return } if resp.StatusCode >= 400 { logger.ActionError(fmt.Errorf("invalid status code"), "send request", "the server returned error "+strconv.Itoa(resp.StatusCode)) } else { ok = true } defer resp.Body.Close() //nolint:errcheck return } func (p *JobPool) getResponseErrorMessage(resp *http.Response) (res string) { res = "the server returned error " + strconv.Itoa(resp.StatusCode) body, err := io.ReadAll(resp.Body) if err != nil { return } var errRes struct { Error string `json:"error"` } err = json.Unmarshal(body, &errRes) if err != nil { return } res += ": " + errRes.Error return } func (p *JobPool) tryRegisterRunner(configFilePath *string) (ok bool) { logger := JobLogger{Context: "registration"} log.Info("Registering a new runner") if util.Config.Runner.RegistrationToken == "" { logger.ActionError(fmt.Errorf("registration token cannot be empty"), "read input", "can not retrieve registration token") return } var err error publicKey := "" if util.Config.Runner.PrivateKeyFile != "" { publicKey, err = generatePrivateKey(util.Config.Runner.PrivateKeyFile) } if err != nil { logger.ActionError(err, "read input", "can not generate private key file") return } client := &http.Client{} url := util.Config.WebHost + "/api/internal/runners" jsonBytes, err := json.Marshal(RunnerRegistration{ RegistrationToken: util.Config.Runner.RegistrationToken, Webhook: util.Config.Runner.Webhook, MaxParallelTasks: util.Config.Runner.MaxParallelTasks, PublicKey: &publicKey, }) if err != nil { logger.ActionError(err, "form request", "can not marshal json") return } req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBytes)) if err != nil { logger.ActionError(err, "create request", "can not create request to the server") return } resp, err := client.Do(req) if err != nil { logger.ActionError(err, "send request", "unexpected error") return } if resp.StatusCode != 200 { logger.ActionError(fmt.Errorf("invalid status code"), "send request", p.getResponseErrorMessage(resp)) return } body, err := io.ReadAll(resp.Body) if err != nil { logger.ActionError(err, "read response body", "can not read server's response body") return } var res struct { Token string `json:"token"` } err = json.Unmarshal(body, &res) if err != nil { logger.ActionError(err, "parsing result json", "server's response has invalid format") return } if util.Config.Runner.TokenFile != "" { err = os.WriteFile(util.Config.Runner.TokenFile, []byte(res.Token), 0644) } else { if configFilePath == nil { logger.ActionError(fmt.Errorf("config file path required"), "read input", "can not retrieve config file path") return } var configFileBuffer []byte configFileBuffer, err = os.ReadFile(*configFilePath) if err != nil { logger.ActionError(err, "read config file", "can not read config file") return } config := util.ConfigType{} err = json.Unmarshal(configFileBuffer, &config) if err != nil { logger.ActionError(err, "parse config file", "can not parse config file") return } config.Runner.Token = res.Token configFileBuffer, err = json.MarshalIndent(&config, " ", "\t") if err != nil { logger.ActionError(err, "marshal config file", "can not marshal config file") return } err = os.WriteFile(*configFilePath, configFileBuffer, 0644) if err != nil { logger.ActionError(err, "write config file", "can not write config file") return } } defer resp.Body.Close() //nolint:errcheck ok = true return } func loadPrivateKey(privateKeyFilePath string) (*rsa.PrivateKey, error) { keyData, err := os.ReadFile(privateKeyFilePath) if err != nil { return nil, err } block, _ := pem.Decode(keyData) if block == nil || block.Type != "RSA PRIVATE KEY" { return nil, fmt.Errorf("invalid private key") } return x509.ParsePKCS1PrivateKey(block.Bytes) } func generatePrivateKey(privateKeyFilePath string) (publicKey string, err error) { privateKeyFile, err := os.Create(privateKeyFilePath) if err != nil { return } defer privateKeyFile.Close() //nolint:errcheck return util.GeneratePrivateKey(privateKeyFile) } func decryptChunkedBytes(combinedCiphertext []byte, privateKey *rsa.PrivateKey) (fullPlaintext []byte, err error) { rsaBlockSize := privateKey.N.BitLen() / 8 // e.g. 256 for 2048-bit key // 3. Decrypt all chunks for i := 0; i < len(combinedCiphertext); i += rsaBlockSize { end := i + rsaBlockSize if end > len(combinedCiphertext) { // In case of partial/corrupted data end = len(combinedCiphertext) } chunk := combinedCiphertext[i:end] var decryptedChunk []byte decryptedChunk, err = rsa.DecryptPKCS1v15(rand.Reader, privateKey, chunk) if err != nil { return } // 4. Append decrypted chunk to our full plaintext buffer fullPlaintext = append(fullPlaintext, decryptedChunk...) } return } // checkNewJobs tries to find runner to queued jobs func (p *JobPool) checkNewJobs() { logger := JobLogger{Context: "checking new jobs"} if util.Config.Runner.Token == "" { logger.ActionError(fmt.Errorf("no token provided"), "read input", "can not retrieve runner token") return } client := &http.Client{} url := util.Config.WebHost + "/api/internal/runners" req, err := http.NewRequest("GET", url, nil) if err != nil { logger.ActionError(err, "create request", "can not create request to the server") return } req.Header.Set("X-Runner-Token", util.Config.Runner.Token) resp, err := client.Do(req) if err != nil { logger.ActionError(err, "send request", "unexpected error") return } if resp.StatusCode >= 400 { logger.ActionError(fmt.Errorf("error status code"), "send request", p.getResponseErrorMessage(resp)) return } defer resp.Body.Close() //nolint:errcheck body, err := io.ReadAll(resp.Body) if err != nil { logger.ActionError(err, "read response body", "can not read server's response body") return } if util.Config.Runner.PrivateKeyFile != "" { var pk *rsa.PrivateKey pk, err = loadPrivateKey(util.Config.Runner.PrivateKeyFile) if err != nil { logger.ActionError(err, "decrypt response body", "can not read private key") return } body, err = decryptChunkedBytes(body, pk) if err != nil { logger.ActionError(err, "decrypt response body", "can not decrypt server's response body") return } } var response RunnerState err = json.Unmarshal(body, &response) if err != nil { logger.ActionError(err, "parsing result json", "server's response has invalid format") return } if response.ClearCache { if response.CacheCleanProjectID == nil { if err2 := util.Config.ClearTmpDir(); err2 != nil { logger.ActionError( err2, "cleaning cache", "cannot clear tmp directory", ) } } else { if err2 := util.Config.ClearProjectTmpDir(*response.CacheCleanProjectID); err2 != nil { logger.ActionError( err2, "cleaning cache", "cannot clear project "+strconv.Itoa(*response.CacheCleanProjectID)+" tmp directory", ) } } } for _, currJob := range response.CurrentJobs { runJob, exists := p.runningJobs[currJob.ID] if !exists { continue } if runJob.status == task_logger.TaskStoppingStatus || runJob.status == task_logger.TaskStoppedStatus { p.runningJobs[currJob.ID].job.Kill() } if runJob.status.IsFinished() { continue } switch runJob.status { case task_logger.TaskRunningStatus: if currJob.Status == task_logger.TaskStartingStatus || currJob.Status == task_logger.TaskWaitingStatus || currJob.Status == task_logger.TaskConfirmed { continue } case task_logger.TaskStoppingStatus: if !currJob.Status.IsFinished() { continue } case task_logger.TaskConfirmed: if currJob.Status == task_logger.TaskWaitingConfirmation { continue } case task_logger.TaskWaitingConfirmation: if currJob.Status == task_logger.TaskRunningStatus { continue } } runJob.SetStatus(currJob.Status) } if util.Config.Runner.OneOff { if len(p.queue) > 0 || len(p.runningJobs) > 0 { return } } for _, newJob := range response.NewJobs { if _, exists := p.runningJobs[newJob.Task.ID]; exists { continue } if p.existsInQueue(newJob.Task.ID) { continue } newJob.Inventory.Repository = newJob.InventoryRepository taskRunner := job{ username: newJob.Username, incomingVersion: newJob.IncomingVersion, alias: newJob.Alias, job: &tasks.LocalJob{ Task: newJob.Task, Template: newJob.Template, Inventory: newJob.Inventory, Repository: newJob.Repository, Environment: newJob.Environment, KeyInstaller: p.keyInstaller, App: db_lib.CreateApp( newJob.Template, newJob.Repository, newJob.Inventory, nil), }, } taskRunner.job.Repository.SSHKey = response.AccessKeys[taskRunner.job.Repository.SSHKeyID] if taskRunner.job.Inventory.SSHKeyID != nil { taskRunner.job.Inventory.SSHKey = response.AccessKeys[*taskRunner.job.Inventory.SSHKeyID] } if taskRunner.job.Inventory.BecomeKeyID != nil { taskRunner.job.Inventory.BecomeKey = response.AccessKeys[*taskRunner.job.Inventory.BecomeKeyID] } var vaults []db.TemplateVault if taskRunner.job.Template.Vaults != nil { for _, vault := range taskRunner.job.Template.Vaults { vault2 := vault if vault2.VaultKeyID != nil { key := response.AccessKeys[*vault2.VaultKeyID] vault2.Vault = &key } vaults = append(vaults, vault2) } } taskRunner.job.Template.Vaults = vaults if taskRunner.job.Inventory.RepositoryID != nil { taskRunner.job.Inventory.Repository.SSHKey = response.AccessKeys[taskRunner.job.Inventory.Repository.SSHKeyID] } p.queue = append(p.queue, &taskRunner) logger.TaskInfo("Task enqueued", taskRunner.job.Task.ID, string(taskRunner.job.Task.Status)) } } ================================================ FILE: services/runners/running_job.go ================================================ package runners import ( "bufio" "fmt" "github.com/semaphoreui/semaphore/pkg/tz" "io" "os/exec" "sync" "time" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/services/tasks" log "github.com/sirupsen/logrus" ) type runningJob struct { status task_logger.TaskStatus logRecords []LogRecord job *tasks.LocalJob commit *CommitInfo statusListeners []task_logger.StatusListener logListeners []task_logger.LogListener logWG sync.WaitGroup } func (p *runningJob) AddStatusListener(l task_logger.StatusListener) { p.statusListeners = append(p.statusListeners, l) } func (p *runningJob) AddLogListener(l task_logger.LogListener) { p.logListeners = append(p.logListeners, l) } func (p *runningJob) Log(msg string) { p.LogWithTime(tz.Now(), msg) } func (p *runningJob) Logf(format string, a ...any) { p.LogfWithTime(tz.Now(), format, a...) } func (p *runningJob) LogWithTime(now time.Time, msg string) { p.logRecords = append( p.logRecords, LogRecord{ Time: now, Message: msg, }, ) for _, l := range p.logListeners { l(now, msg) } } func (p *runningJob) LogfWithTime(now time.Time, format string, a ...any) { p.LogWithTime(now, fmt.Sprintf(format, a...)) } func (p *runningJob) LogCmd(cmd *exec.Cmd) { stderr, _ := cmd.StderrPipe() stdout, _ := cmd.StdoutPipe() go p.logPipe(stderr) go p.logPipe(stdout) } func (p *runningJob) WaitLog() { p.logWG.Wait() } func (p *runningJob) SetCommit(hash, message string) { p.commit = &CommitInfo{ Hash: hash, Message: message, } } func (p *runningJob) SetStatus(status task_logger.TaskStatus) { if p.status == status { return } p.status = status p.job.SetStatus(status) for _, l := range p.statusListeners { l(status) } } func (p *runningJob) logPipe(reader io.Reader) { p.logWG.Add(1) defer p.logWG.Done() scanner := bufio.NewScanner(reader) const maxCapacity = 10 * 1024 * 1024 // 10 MB buf := make([]byte, maxCapacity) scanner.Buffer(buf, maxCapacity) for scanner.Scan() { line := scanner.Text() p.Log(line) } err := scanner.Err() if err != nil { msg := "Failed to read TaskRunner output" switch err.Error() { case "EOF", "os: process already finished", "read |0: file already closed": return // it is ok case "bufio.Scanner: token too long": msg = "TaskRunner output exceeds the maximum allowed size of 10MB" break } p.job.Kill() // kill the job because stdout cannot be read. log.WithError(err).WithFields(log.Fields{ "task_id": p.job.Task.ID, "context": "task_logger", }).Error(msg) p.Log("Fatal error: " + msg) } } ================================================ FILE: services/runners/types.go ================================================ package runners import ( "time" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/services/tasks" ) type JobData struct { Username string IncomingVersion *string Alias string Task db.Task `json:"task" binding:"required"` Template db.Template `json:"template" binding:"required"` Inventory db.Inventory `json:"inventory" binding:"required"` InventoryRepository *db.Repository `json:"inventory_repository" binding:"required"` Repository db.Repository `json:"repository" binding:"required"` Environment db.Environment `json:"environment" binding:"required"` } type RunnerState struct { CurrentJobs []JobState NewJobs []JobData `json:"new_jobs" binding:"required"` AccessKeys map[int]db.AccessKey `json:"access_keys" binding:"required"` ClearCache bool `json:"clear_cache,omitempty"` CacheCleanProjectID *int `json:"cache_clean_project_id,omitempty"` } type JobState struct { ID int `json:"id" binding:"required"` Status task_logger.TaskStatus `json:"status" binding:"required"` } type LogRecord struct { Time time.Time `json:"time" binding:"required"` Message string `json:"message" binding:"required"` } type CommitInfo struct { Hash string `json:"hash" binding:"required"` Message string `json:"message" binding:"required"` } type RunnerProgress struct { Jobs []JobProgress } type JobProgress struct { ID int Status task_logger.TaskStatus LogRecords []LogRecord Commit *CommitInfo } type RunnerRegistration struct { RegistrationToken string `json:"registration_token" binding:"required"` Webhook string `json:"webhook,omitempty"` MaxParallelTasks int `json:"max_parallel_tasks"` PublicKey *string `json:"public_key,omitempty"` } type jobLogRecord struct { taskID int record LogRecord } type job struct { username string incomingVersion *string alias string // job presents remote or local job information job *tasks.LocalJob status task_logger.TaskStatus } ================================================ FILE: services/schedules/SchedulePool.go ================================================ package schedules import ( "strconv" "sync" "time" "github.com/semaphoreui/semaphore/pkg/common_errors" "github.com/semaphoreui/semaphore/services/server" "github.com/semaphoreui/semaphore/util" "github.com/robfig/cron/v3" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/db_lib" "github.com/semaphoreui/semaphore/services/tasks" log "github.com/sirupsen/logrus" ) type ScheduleRunner struct { projectID int scheduleID int pool *SchedulePool encryptionService server.AccessKeyEncryptionService keyInstaller db_lib.AccessKeyInstaller } type oneTimeSchedule struct { runAt time.Time ran bool } func (s *oneTimeSchedule) Next(t time.Time) time.Time { if s.ran { return time.Time{} } if !t.Before(s.runAt) { s.ran = true return time.Time{} } return s.runAt } func CreateScheduleRunner( projectID int, scheduleID int, pool *SchedulePool, encryptionService server.AccessKeyEncryptionService, keyInstaller db_lib.AccessKeyInstaller, ) ScheduleRunner { return ScheduleRunner{ projectID: projectID, scheduleID: scheduleID, pool: pool, encryptionService: encryptionService, keyInstaller: keyInstaller, } } func (r ScheduleRunner) tryUpdateScheduleCommitHash(schedule db.Schedule) (updated bool, err error) { repo, err := r.pool.store.GetRepository(schedule.ProjectID, *schedule.RepositoryID) if err != nil { return } err = r.pool.encryptionService.DeserializeSecret(&repo.SSHKey) if err != nil { return } remoteHash, err := db_lib.GitRepository{ Logger: nil, TemplateID: schedule.TemplateID, Repository: repo, Client: db_lib.CreateDefaultGitClient(r.keyInstaller), }.GetLastRemoteCommitHash() if err != nil { return } if schedule.LastCommitHash != nil && remoteHash == *schedule.LastCommitHash { return } err = r.pool.store.SetScheduleCommitHash(schedule.ProjectID, schedule.ID, remoteHash) if err != nil { return } updated = true return } func (r ScheduleRunner) Run() { if !r.pool.store.PermanentConnection() { r.pool.store.Connect("schedule " + strconv.Itoa(r.scheduleID)) defer r.pool.store.Close("schedule " + strconv.Itoa(r.scheduleID)) } schedule, err := r.pool.store.GetSchedule(r.projectID, r.scheduleID) if err != nil { log.WithError(err).WithFields(log.Fields{ "context": common_errors.GetErrorContext(), "project_id": r.projectID, "schedule_id": r.scheduleID, }).Error("failed to get schedule") return } scheduleType := schedule.Type if scheduleType == "" { scheduleType = db.ScheduleTypeCron } if schedule.RepositoryID != nil { var updated bool updated, err = r.tryUpdateScheduleCommitHash(schedule) if err != nil { log.WithError(err).WithFields(log.Fields{ "context": common_errors.GetErrorContext(), "project_id": r.projectID, "schedule_id": r.scheduleID, }).Error("failed to update schedule commit hash") return } if !updated { return } } tpl, err := r.pool.store.GetTemplate(schedule.ProjectID, schedule.TemplateID) if err != nil { log.WithError(err).WithFields(log.Fields{ "context": common_errors.GetErrorContext(), "project_id": schedule.ProjectID, "schedule_id": schedule.ID, "template_id": schedule.TemplateID, }).Error("failed to get template") return } // In HA mode, ensure only one node fires this schedule occurrence. if r.pool.dedup != nil && !r.pool.dedup.TryLockExecution(r.scheduleID) { log.WithFields(log.Fields{ "project_id": r.projectID, "schedule_id": r.scheduleID, }).Debug("schedule already executed by another node") // For one-time schedules the winning node deactivates/deletes // the schedule in the DB after execution. Refresh so this // node's cron picks up that change and drops the stale entry. if scheduleType == db.ScheduleTypeRunAt { r.pool.Refresh() } return } var task db.Task if schedule.TaskParams != nil { task = schedule.TaskParams.CreateTask(schedule.TemplateID) } else { task = db.Task{ ProjectID: schedule.ProjectID, TemplateID: schedule.TemplateID, } } task.ScheduleID = &schedule.ID _, err = r.pool.taskPool.AddTask( task, nil, "", schedule.ProjectID, tpl.App.NeedTaskAlias(), ) if err != nil { log.WithError(err).WithFields(log.Fields{ "context": common_errors.GetErrorContext(), "project_id": schedule.ProjectID, "schedule_id": schedule.ID, "template_id": schedule.TemplateID, }).Error("failed to add task") } // For "RunAt" schedules, the schedule should only trigger once at the specified time and be deactivated afterwards. // Calling Refresh here ensures that after the job has fired, the pool reloads the active schedules // from the database (where this run-at schedule may now be disabled) so it is not executed again. if scheduleType == db.ScheduleTypeRunAt { r.pool.Refresh() } } // ScheduleDeduplicator prevents the same schedule from being executed on // multiple nodes simultaneously in an HA cluster. When configured, each // ScheduleRunner calls TryLockExecution before creating a task. // // The deduplication lock is intended to cover a *single execution attempt* // of a schedule occurrence: a node should acquire the lock immediately // before creating a task and release it once the attempt has either // completed or failed. Implementations are free to choose the underlying // mechanism (in‑memory, database, distributed store, etc.), but they should // be robust to node failures and process restarts (for example by using // leases with automatic expiry). // // Callers MUST treat the lock as advisory and best‑effort: if the // implementation becomes unavailable or releases the lock early, at‑most‑once // execution across the cluster is not guaranteed. type ScheduleDeduplicator interface { // TryLockExecution attempts to acquire an execution lock for the given // schedule occurrence. // // Lock duration: // - The lock is expected to remain held for the duration of the current // schedule execution attempt (from just before task creation until // the attempt finishes or fails). // - Implementations will typically release the lock explicitly when the // attempt ends and/or rely on a lease with automatic expiry to avoid // permanent deadlocks. // // Timeouts and crash behavior: // - If the node that acquired the lock crashes or loses connectivity, // the behavior is implementation‑specific. Recommended practice is to // use a finite TTL/lease so that the lock eventually expires and // future executions can proceed. // // Idempotency: // - TryLockExecution may be called multiple times for the same // scheduleID (for example, after retries or rescheduling). The // implementation SHOULD behave idempotently such that, for a single // schedule occurrence, at most one call across the cluster returns // true. // // Returns true if this node successfully acquired the lock and should // execute the schedule, and false otherwise. TryLockExecution(scheduleID int) bool } type SchedulePool struct { cron *cron.Cron locker sync.Locker dedup ScheduleDeduplicator store db.Store taskPool *tasks.TaskPool encryptionService server.AccessKeyEncryptionService keyInstaller db_lib.AccessKeyInstaller } // SetDeduplicator configures a distributed schedule deduplicator for HA mode. // When set, only one node in the cluster fires each schedule occurrence. func (p *SchedulePool) SetDeduplicator(d ScheduleDeduplicator) { p.dedup = d } func (p *SchedulePool) init() { loc, err := time.LoadLocation(util.Config.Schedule.Timezone) if err != nil { panic(err) } p.cron = cron.New(cron.WithLocation(loc)) p.locker = &sync.Mutex{} } func (p *SchedulePool) Refresh() { schedules, err := p.store.GetSchedules() if err != nil { log.WithError(err).WithFields(log.Fields{ "context": common_errors.GetErrorContext(), }).Error("failed to get schedules") return } p.locker.Lock() defer p.locker.Unlock() p.clear() now := time.Now().In(p.cron.Location()) for _, schedule := range schedules { scheduleType := schedule.Type if scheduleType == "" { scheduleType = db.ScheduleTypeCron } if schedule.RepositoryID == nil && !schedule.Active { continue } runner := CreateScheduleRunner( schedule.ProjectID, schedule.ID, p, p.encryptionService, p.keyInstaller, ) switch scheduleType { case db.ScheduleTypeRunAt: if schedule.RunAt == nil { log.WithFields(log.Fields{ "project_id": schedule.ProjectID, "schedule_id": schedule.ID, }).Warn("run_at schedule has no run_at value") continue } runAt := schedule.RunAt.In(p.cron.Location()) if !runAt.After(now) { if schedule.DeleteAfterRun { err = p.store.DeleteSchedule(schedule.ProjectID, schedule.ID) if err != nil { log.WithError(err).WithFields(log.Fields{ "context": common_errors.GetErrorContext(), "project_id": schedule.ProjectID, "schedule_id": schedule.ID, }).Warn("failed to delete past run_at schedule") } } else if schedule.Active { err = p.store.SetScheduleActive(schedule.ProjectID, schedule.ID, false) if err != nil { log.WithError(err).WithFields(log.Fields{ "context": common_errors.GetErrorContext(), "project_id": schedule.ProjectID, "schedule_id": schedule.ID, }).Warn("failed to deactivate past run_at schedule") } } continue } _, err = p.addOneTimeRunner(runner, runAt) case db.ScheduleTypeCron: if schedule.CronFormat == "" { continue } _, err = p.addRunner(runner, schedule.CronFormat) default: log.WithFields(log.Fields{ "project_id": schedule.ProjectID, "schedule_id": schedule.ID, "type": schedule.Type, }).Warn("schedule has unsupported type") continue } if err != nil { log.WithError(err).WithFields(log.Fields{ "context": common_errors.GetErrorContext(), "project_id": schedule.ProjectID, "schedule_id": schedule.ID, }).Errorf("failed to add schedule") } } } func (p *SchedulePool) addRunner(runner ScheduleRunner, cronFormat string) (int, error) { id, err := p.cron.AddJob(cronFormat, runner) if err != nil { return 0, err } return int(id), nil } func (p *SchedulePool) addOneTimeRunner(runner ScheduleRunner, runAt time.Time) (int, error) { id := p.cron.Schedule(&oneTimeSchedule{runAt: runAt}, runner) return int(id), nil } func (p *SchedulePool) Run() { p.cron.Run() } func (p *SchedulePool) clear() { runners := p.cron.Entries() for _, r := range runners { p.cron.Remove(r.ID) } } func (p *SchedulePool) Destroy() { p.locker.Lock() defer p.locker.Unlock() p.cron.Stop() p.clear() p.cron = nil } func CreateSchedulePool( store db.Store, taskPool *tasks.TaskPool, keyInstaller db_lib.AccessKeyInstaller, encryptionService server.AccessKeyEncryptionService, ) SchedulePool { pool := SchedulePool{ store: store, taskPool: taskPool, keyInstaller: keyInstaller, encryptionService: encryptionService, } pool.init() pool.Refresh() return pool } func ValidateCronFormat(cronFormat string) error { _, err := cron.ParseStandard(cronFormat) return err } ================================================ FILE: services/schedules/SchedulePool_test.go ================================================ package schedules import ( "sync" "testing" "time" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/db/bolt" "github.com/semaphoreui/semaphore/pkg/ssh" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/services/tasks" "github.com/semaphoreui/semaphore/util" "github.com/stretchr/testify/assert" ) // mockEncryptionService is a test implementation of AccessKeyEncryptionService type mockEncryptionService struct{} func (m *mockEncryptionService) SerializeSecret(key *db.AccessKey) error { return nil } func (m *mockEncryptionService) DeserializeSecret(key *db.AccessKey) error { return nil } func (m *mockEncryptionService) FillEnvironmentSecrets(env *db.Environment, deserializeSecret bool) error { return nil } func (m *mockEncryptionService) DeleteSecret(key *db.AccessKey) error { return nil } func TestValidateCronFormat(t *testing.T) { err := ValidateCronFormat("* * * *") if err == nil { t.Fatal("") } err = ValidateCronFormat("* * 1 * *") if err != nil { t.Fatal(err.Error()) } } func TestOneTimeSchedule(t *testing.T) { future := time.Now().Add(time.Hour) schedule := oneTimeSchedule{runAt: future} if schedule.Next(time.Now()) != future { t.Fatalf("expected next run at %v", future) } if !schedule.Next(future).IsZero() { t.Fatalf("expected schedule to stop after run time") } } // mockDeduplicator is a test implementation of ScheduleDeduplicator type mockDeduplicator struct { mu sync.Mutex allowExecution map[int]bool lockAttempts map[int]int } func newMockDeduplicator() *mockDeduplicator { return &mockDeduplicator{ allowExecution: make(map[int]bool), lockAttempts: make(map[int]int), } } func (m *mockDeduplicator) TryLockExecution(scheduleID int) bool { m.mu.Lock() defer m.mu.Unlock() m.lockAttempts[scheduleID]++ return m.allowExecution[scheduleID] } func (m *mockDeduplicator) setAllowExecution(scheduleID int, allow bool) { m.mu.Lock() defer m.mu.Unlock() m.allowExecution[scheduleID] = allow } func (m *mockDeduplicator) getLockAttempts(scheduleID int) int { m.mu.Lock() defer m.mu.Unlock() return m.lockAttempts[scheduleID] } // mockAccessKeyInstaller is a test implementation of AccessKeyInstaller type mockAccessKeyInstaller struct{} func (m *mockAccessKeyInstaller) Install(key db.AccessKey, usage db.AccessKeyRole, logger task_logger.Logger) (installation ssh.AccessKeyInstallation, err error) { return ssh.AccessKeyInstallation{}, nil } func setupTestSchedulePool(t *testing.T) (*SchedulePool, db.Store) { store := bolt.CreateTestStore() // Store original config and restore after test originalSchedule := util.Config.Schedule t.Cleanup(func() { util.Config.Schedule = originalSchedule }) // Ensure util.Config Schedule is set (CreateTestStore doesn't set this) util.Config.Schedule = &util.ScheduleConfig{ Timezone: "UTC", } pool := CreateSchedulePool( store, &tasks.TaskPool{}, &mockAccessKeyInstaller{}, &mockEncryptionService{}, ) t.Cleanup(func() { pool.Destroy() }) return &pool, store } // TestSetDeduplicator verifies that SetDeduplicator properly configures the deduplicator func TestSetDeduplicator(t *testing.T) { pool, _ := setupTestSchedulePool(t) // Initially no deduplicator should be set assert.Nil(t, pool.dedup, "deduplicator should be nil initially") // Set a deduplicator dedup := newMockDeduplicator() pool.SetDeduplicator(dedup) // Verify it's set assert.NotNil(t, pool.dedup, "deduplicator should be set after calling SetDeduplicator") assert.Equal(t, dedup, pool.dedup, "deduplicator should be the one we set") } // TestScheduleExecutesNormallyWithoutDeduplicator verifies schedules execute when no deduplicator is set func TestScheduleExecutesNormallyWithoutDeduplicator(t *testing.T) { pool, _ := setupTestSchedulePool(t) // Ensure no deduplicator is set pool.SetDeduplicator(nil) // Verify that the deduplicator is nil (schedule would execute normally) assert.Nil(t, pool.dedup, "deduplicator should be nil, allowing normal execution") } // TestScheduleSkippedWhenTryLockExecutionReturnsFalse verifies schedules are skipped when TryLockExecution returns false func TestScheduleSkippedWhenTryLockExecutionReturnsFalse(t *testing.T) { pool, _ := setupTestSchedulePool(t) // Set up deduplicator to deny execution dedup := newMockDeduplicator() scheduleID := 123 dedup.setAllowExecution(scheduleID, false) pool.SetDeduplicator(dedup) // Simulate the deduplication check that happens in ScheduleRunner.Run() shouldSkip := pool.dedup != nil && !pool.dedup.TryLockExecution(scheduleID) // Verify the deduplicator was called and returned false assert.True(t, shouldSkip, "schedule should be skipped when TryLockExecution returns false") assert.Equal(t, 1, dedup.getLockAttempts(scheduleID), "TryLockExecution should be called once") } // TestScheduleProceedsWhenTryLockExecutionReturnsTrue verifies schedules proceed when TryLockExecution returns true func TestScheduleProceedsWhenTryLockExecutionReturnsTrue(t *testing.T) { pool, _ := setupTestSchedulePool(t) // Set up deduplicator to allow execution dedup := newMockDeduplicator() scheduleID := 456 dedup.setAllowExecution(scheduleID, true) pool.SetDeduplicator(dedup) // Simulate the deduplication check that happens in ScheduleRunner.Run() shouldSkip := pool.dedup != nil && !pool.dedup.TryLockExecution(scheduleID) // Verify the deduplicator was called and returned true (schedule proceeds) assert.False(t, shouldSkip, "schedule should proceed when TryLockExecution returns true") assert.Equal(t, 1, dedup.getLockAttempts(scheduleID), "TryLockExecution should be called once") } ================================================ FILE: services/server/AccessKey_test.go ================================================ package server import ( "encoding/base64" "testing" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/util" ) func TestSetSecret(t *testing.T) { accessKey := db.AccessKey{ Type: db.AccessKeySSH, Name: "test", SshKey: db.SshKey{ PrivateKey: "qerphqeruqoweurqwerqqeuiqwpavqr", }, } encryptionService := NewAccessKeyEncryptionService(nil, nil, nil) util.Config = &util.ConfigType{} err := encryptionService.SerializeSecret(&accessKey) if err != nil { t.Fatal(err) } secret, err := base64.StdEncoding.DecodeString(*accessKey.Secret) if err != nil { t.Error(err) } if string(secret) != "{\"login\":\"\",\"passphrase\":\"\",\"private_key\":\"qerphqeruqoweurqwerqqeuiqwpavqr\"}" { t.Error("invalid secret") } } func TestGetSecret(t *testing.T) { secret := base64.StdEncoding.EncodeToString([]byte(`{ "passphrase": "123456", "private_key": "qerphqeruqoweurqwerqqeuiqwpavqr" }`)) util.Config = &util.ConfigType{} encryptionService := NewAccessKeyEncryptionService(nil, nil, nil) accessKey := db.AccessKey{ Secret: &secret, Type: db.AccessKeySSH, } err := encryptionService.DeserializeSecret(&accessKey) if err != nil { t.Error(err) } if accessKey.SshKey.Passphrase != "123456" { t.Errorf("") } if accessKey.SshKey.PrivateKey != "qerphqeruqoweurqwerqqeuiqwpavqr" { t.Errorf("") } } func TestSetGetSecretWithEncryption(t *testing.T) { encryptionService := NewAccessKeyEncryptionService(nil, nil, nil) accessKey := db.AccessKey{ Name: "test", Type: db.AccessKeySSH, SshKey: db.SshKey{ PrivateKey: "qerphqeruqoweurqwerqqeuiqwpavqr", }, } util.Config = &util.ConfigType{ AccessKeyEncryption: "hHYgPrhQTZYm7UFTvcdNfKJMB3wtAXtJENUButH+DmM=", } err := encryptionService.SerializeSecret(&accessKey) if err != nil { t.Error(err) } //accessKey.ClearSecret() err = encryptionService.DeserializeSecret(&accessKey) if err != nil { t.Error(err) } if accessKey.SshKey.PrivateKey != "qerphqeruqoweurqwerqqeuiqwpavqr" { t.Error("invalid secret") } } ================================================ FILE: services/server/access_key_encryption_svc.go ================================================ package server import ( "encoding/json" "errors" "fmt" "strings" "github.com/semaphoreui/semaphore/db" pro "github.com/semaphoreui/semaphore/pro/services/server" ) const RekeyBatchSize = 100 var ErrReadOnlyStorage = errors.New("cannot modify secret in read-only storage") type AccessKeyEncryptionService interface { SerializeSecret(key *db.AccessKey) error DeserializeSecret(key *db.AccessKey) error FillEnvironmentSecrets(env *db.Environment, deserializeSecret bool) error DeleteSecret(key *db.AccessKey) error } func NewAccessKeyEncryptionService( accessKeyRepo db.AccessKeyManager, environmentRepo db.EnvironmentManager, secretStorageRepo db.SecretStorageRepository, ) AccessKeyEncryptionService { return &accessKeyEncryptionServiceImpl{ accessKeyRepo: accessKeyRepo, environmentRepo: environmentRepo, secretStorageRepo: secretStorageRepo, } } func unmarshalAppropriateField(key *db.AccessKey, secret []byte) (err error) { switch key.Type { case db.AccessKeyString: key.String = string(secret) case db.AccessKeySSH: sshKey := db.SshKey{} err = json.Unmarshal(secret, &sshKey) if err == nil { key.SshKey = sshKey } case db.AccessKeyLoginPassword: loginPass := db.LoginPassword{} err = json.Unmarshal(secret, &loginPass) if err == nil { key.LoginPassword = loginPass } } return } type accessKeyEncryptionServiceImpl struct { accessKeyRepo db.AccessKeyManager environmentRepo db.EnvironmentManager secretStorageRepo db.SecretStorageRepository } func (s *accessKeyEncryptionServiceImpl) getDeserializer(key *db.AccessKey) (AccessKeyDeserializer, bool, error) { if key.SourceStorageType == nil { return &LocalAccessKeyDeserializer{}, false, nil } switch *key.SourceStorageType { case db.AccessKeySourceStorageEnv, db.AccessKeySourceStorageFile: return &LocalAccessKeyDeserializer{}, true, nil case db.AccessKeySourceStorageVault: if key.SourceStorageID == nil { return &LocalAccessKeyDeserializer{}, false, errors.New("vault storage id is required") } default: return nil, false, fmt.Errorf("unsupported secret storage type '%s'", *key.SourceStorageType) } storage, err := s.secretStorageRepo.GetSecretStorage(*key.ProjectID, *key.SourceStorageID) if err != nil { return nil, false, err } switch storage.Type { case db.SecretStorageTypeVault: return pro.NewVaultAccessKeyDeserializer(s.accessKeyRepo, s.secretStorageRepo, s), storage.ReadOnly, nil case db.SecretStorageTypeDvls: return pro.NewDvlsAccessKeyDeserializer(s.accessKeyRepo, s.secretStorageRepo, s), storage.ReadOnly, nil } return nil, false, fmt.Errorf("unsupported secret storage type '%s'", storage.Type) } func (s *accessKeyEncryptionServiceImpl) DeleteSecret(key *db.AccessKey) error { d, _, err := s.getDeserializer(key) if err != nil { return err } return d.DeleteSecret(key) } func (s *accessKeyEncryptionServiceImpl) SerializeSecret(key *db.AccessKey) error { d, readonly, err := s.getDeserializer(key) if err != nil { return err } if readonly { return nil } err = key.Validate(true) if err != nil { return err } return d.SerializeSecret(key) } func (s *accessKeyEncryptionServiceImpl) DeserializeSecret(key *db.AccessKey) error { d, _, err := s.getDeserializer(key) if err != nil { return err } ciphertext, err := d.DeserializeSecret(key) if err != nil { return err } err = unmarshalAppropriateField(key, []byte(ciphertext)) var syntaxError *json.SyntaxError if errors.As(err, &syntaxError) { err = fmt.Errorf("secret must be valid json in key '%s'", key.Name) } return err } func (s *accessKeyEncryptionServiceImpl) FillEnvironmentSecrets(env *db.Environment, deserializeSecret bool) error { keys, err := s.environmentRepo.GetEnvironmentSecrets(env.ProjectID, env.ID) if err != nil { return err } for _, k := range keys { var secretName string var secretType db.EnvironmentSecretType if k.Owner == db.AccessKeyVariable { secretType = db.EnvironmentSecretVar secretName = strings.TrimPrefix(k.Name, string(db.EnvironmentSecretVar)+".") } else if k.Owner == db.AccessKeyEnvironment { secretType = db.EnvironmentSecretEnv secretName = strings.TrimPrefix(k.Name, string(db.EnvironmentSecretEnv)+".") } else { secretType = db.EnvironmentSecretVar secretName = k.Name } if deserializeSecret { err = s.DeserializeSecret(&k) if err != nil { return err } } env.Secrets = append(env.Secrets, db.EnvironmentSecret{ ID: k.ID, Name: secretName, Type: secretType, Secret: k.String, }) } return nil } func (s *accessKeyEncryptionServiceImpl) RekeyAccessKeys(oldKey string) (err error) { //var globalProps = db.AccessKeyProps //globalProps.IsGlobal = true // //for i := 0; ; i++ { // // var keys []db.AccessKey // err = d.getObjects(-1, globalProps, db.RetrieveQueryParams{Count: RekeyBatchSize, Offset: i * RekeyBatchSize}, nil, &keys) // // if err != nil { // return // } // // if len(keys) == 0 { // break // } // // for _, key := range keys { // // err = s.DeserializeSecret(oldKey) // err = key.DeserializeSecret2(oldKey) // // if err != nil { // return err // } // // key.OverrideSecret = true // err = s.accessKeyRepo.UpdateAccessKey(key) // // if err != nil && !errors.Is(err, db.ErrNotFound) { // return err // } // } //} return } ================================================ FILE: services/server/access_key_installation_svc.go ================================================ package server import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/ssh" "github.com/semaphoreui/semaphore/pkg/task_logger" ) type AccessKeyInstallationService interface { Install(key db.AccessKey, usage db.AccessKeyRole, logger task_logger.Logger) (installation ssh.AccessKeyInstallation, err error) } func NewAccessKeyInstallationService(encryptionService AccessKeyEncryptionService) AccessKeyInstallationService { return &AccessKeyInstallationServiceImpl{ encryptionService: encryptionService, } } type AccessKeyInstallationServiceImpl struct { encryptionService AccessKeyEncryptionService } func (s *AccessKeyInstallationServiceImpl) Install(key db.AccessKey, usage db.AccessKeyRole, logger task_logger.Logger) (installation ssh.AccessKeyInstallation, err error) { if key.Type == db.AccessKeyNone { return } err = s.encryptionService.DeserializeSecret(&key) if err != nil { return } installation, err = ssh.KeyInstaller{}.Install(key, usage, logger) return } ================================================ FILE: services/server/access_key_serializer.go ================================================ package server import ( "github.com/semaphoreui/semaphore/db" ) type AccessKeyDeserializer interface { DeserializeSecret(key *db.AccessKey) (string, error) SerializeSecret(key *db.AccessKey) error DeleteSecret(key *db.AccessKey) error } ================================================ FILE: services/server/access_key_serializer_local.go ================================================ package server import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "encoding/json" "fmt" "io" "os" "path/filepath" "strings" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/common_errors" "github.com/semaphoreui/semaphore/util" ) type LocalAccessKeyDeserializer struct { } func NewLocalAccessKeyDeserializer() *LocalAccessKeyDeserializer { return &LocalAccessKeyDeserializer{} } func (d *LocalAccessKeyDeserializer) DeleteSecret(key *db.AccessKey) error { // No-op for local deserializer return nil } func (d *LocalAccessKeyDeserializer) SerializeSecret(key *db.AccessKey) error { var plaintext []byte var err error switch key.Type { case db.AccessKeyString: if key.String == "" { key.Secret = nil return nil } plaintext = []byte(key.String) case db.AccessKeySSH: if key.SshKey.PrivateKey == "" { if key.SshKey.Login != "" || key.SshKey.Passphrase != "" { return fmt.Errorf("invalid ssh key") } key.Secret = nil return nil } plaintext, err = json.Marshal(key.SshKey) if err != nil { return err } case db.AccessKeyLoginPassword: if key.LoginPassword.Password == "" { if key.LoginPassword.Login != "" { return fmt.Errorf("invalid password key") } key.Secret = nil return nil } plaintext, err = json.Marshal(key.LoginPassword) if err != nil { return err } case db.AccessKeyNone: key.Secret = nil return nil default: return fmt.Errorf("invalid access token type") } encryptionString := util.Config.AccessKeyEncryption if encryptionString == "" { secret := base64.StdEncoding.EncodeToString(plaintext) key.Secret = &secret return nil } encryption, err := base64.StdEncoding.DecodeString(encryptionString) if err != nil { return err } c, err := aes.NewCipher(encryption) if err != nil { return err } gcm, err := cipher.NewGCM(c) if err != nil { return err } nonce := make([]byte, gcm.NonceSize()) if _, err = io.ReadFull(rand.Reader, nonce); err != nil { return err } secret := base64.StdEncoding.EncodeToString(gcm.Seal(nonce, nonce, plaintext, nil)) key.Secret = &secret return nil } func (d *LocalAccessKeyDeserializer) DeserializeSecret(key *db.AccessKey) (res string, err error) { return d.DeserializeSecret2(key, util.Config.AccessKeyEncryption) } func (d *LocalAccessKeyDeserializer) DeserializeSecret2(key *db.AccessKey, encryptionString string) (res string, err error) { if key.SourceStorageType != nil { if key.SourceStorageKey == nil { return "", fmt.Errorf("source storage key is required") } switch *key.SourceStorageType { case db.AccessKeySourceStorageEnv: res = os.Getenv(*key.SourceStorageKey) return case db.AccessKeySourceStorageFile: filePath := filepath.Clean(*key.SourceStorageKey) if !filepath.IsAbs(filePath) { err = common_errors.NewUserErrorS("file path must be absolute") return } for _, segment := range strings.Split(filepath.ToSlash(*key.SourceStorageKey), "/") { if segment == ".." { err = common_errors.NewUserErrorS("file path must not contain traversal segments") return } } secretsBasePath := filepath.Clean(util.Config.Dirs.SecretsPath) if !filepath.IsAbs(secretsBasePath) { err = common_errors.NewUserErrorS("secrets path must be absolute") return } var relPath string relPath, err = filepath.Rel(secretsBasePath, filePath) if err != nil { return } if relPath == ".." || strings.HasPrefix(relPath, ".."+string(os.PathSeparator)) { err = common_errors.NewUserErrorS("file path must be inside secrets path") return } var data []byte data, err = os.ReadFile(filePath) if err != nil { return } res = string(data) return } } if key.Secret == nil || *key.Secret == "" { return } ciphertext := []byte(*key.Secret) if ciphertext[len(*key.Secret)-1] == '\n' { // not encrypted private key, used for back compatibility if key.Type != db.AccessKeySSH { err = fmt.Errorf("invalid access key type") return } sshKey := db.SshKey{ PrivateKey: *key.Secret, } var marshaled []byte marshaled, err = json.Marshal(sshKey) if err != nil { return } res = string(marshaled) return } ciphertext, err = base64.StdEncoding.DecodeString(*key.Secret) if err != nil { return } if encryptionString == "" { res = string(ciphertext) return } encryption, err := base64.StdEncoding.DecodeString(encryptionString) if err != nil { return } c, err := aes.NewCipher(encryption) if err != nil { return } gcm, err := cipher.NewGCM(c) if err != nil { return } nonceSize := gcm.NonceSize() if len(ciphertext) < nonceSize { err = fmt.Errorf("ciphertext too short") return } nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] ciphertext, err = gcm.Open(nil, nonce, ciphertext, nil) if err != nil { if err.Error() == "cipher: message authentication failed" { err = fmt.Errorf("cannot decrypt access key, perhaps encryption key was changed") } return } res = string(ciphertext) return } ================================================ FILE: services/server/access_key_svc.go ================================================ package server import ( "errors" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/common_errors" ) type AccessKeyService interface { Update(key db.AccessKey) error Create(key db.AccessKey) (newKey db.AccessKey, err error) GetAll(projectID int, options db.GetAccessKeyOptions, params db.RetrieveQueryParams) ([]db.AccessKey, error) Delete(projectID int, keyID int) (err error) } type AccessKeyServiceImpl struct { accessKeyRepo db.AccessKeyManager encryptionService AccessKeyEncryptionService secretStorageRepo db.SecretStorageRepository } func NewAccessKeyService( accessKeyRepo db.AccessKeyManager, encryptionService AccessKeyEncryptionService, secretStorageRepo db.SecretStorageRepository, ) AccessKeyService { return &AccessKeyServiceImpl{ accessKeyRepo: accessKeyRepo, encryptionService: encryptionService, secretStorageRepo: secretStorageRepo, } } func (s *AccessKeyServiceImpl) Delete(projectID int, keyID int) (err error) { key, err := s.accessKeyRepo.GetAccessKey(projectID, keyID) if err != nil { return } if key.SourceStorageID != nil { var storage db.SecretStorage storage, err = s.secretStorageRepo.GetSecretStorage(projectID, *key.SourceStorageID) if err != nil { return } if !storage.ReadOnly { err = s.encryptionService.DeleteSecret(&key) if err != nil { return } } } err = s.accessKeyRepo.DeleteAccessKey(projectID, keyID) return } func (s *AccessKeyServiceImpl) GetAll(projectID int, options db.GetAccessKeyOptions, params db.RetrieveQueryParams) ([]db.AccessKey, error) { return s.accessKeyRepo.GetAccessKeys(projectID, options, params) } func (s *AccessKeyServiceImpl) Create(key db.AccessKey) (newKey db.AccessKey, err error) { err = s.encryptionService.SerializeSecret(&key) if err != nil && !errors.Is(err, ErrReadOnlyStorage) { return } newKey, err = s.accessKeyRepo.CreateAccessKey(key) return } func (s *AccessKeyServiceImpl) Update(key db.AccessKey) (err error) { if !key.OverrideSecret { err = s.accessKeyRepo.UpdateAccessKey(key) return } var oldKey db.AccessKey oldKey, err = s.accessKeyRepo.GetAccessKey(*key.ProjectID, key.ID) if err != nil { return } if oldKey.SourceStorageType != nil && !oldKey.IsNativelyReadOnly() { // validate if it is secure to override secret storage var oldSt db.SecretStorage oldSt, err = s.secretStorageRepo.GetSecretStorage(*key.ProjectID, *oldKey.SourceStorageID) if err != nil { return } if !oldSt.ReadOnly && (key.SourceStorageID == nil || *oldKey.SourceStorageID != *key.SourceStorageID) { err = common_errors.NewUserErrorS("cannot override secret storage") return } } if !key.IsNativelyReadOnly() { err = s.encryptionService.SerializeSecret(&key) if err != nil { return } } err = s.accessKeyRepo.UpdateAccessKey(key) return } ================================================ FILE: services/server/environment_svc.go ================================================ package server import ( "fmt" "github.com/semaphoreui/semaphore/db" ) type EnvironmentService interface { Delete(projectID int, environmentID int) error } func NewEnvironmentService( environmentRepo db.EnvironmentManager, encryptionService AccessKeyEncryptionService, ) EnvironmentService { return &EnvironmentServiceImpl{ environmentRepo: environmentRepo, encryptionService: encryptionService, } } type EnvironmentServiceImpl struct { environmentRepo db.EnvironmentManager encryptionService AccessKeyEncryptionService } func (s *EnvironmentServiceImpl) Delete(projectID int, environmentID int) (err error) { // Implement the logic to delete an environment // This is a placeholder implementation if projectID <= 0 || environmentID <= 0 { return fmt.Errorf("invalid project or environment ID") } secrets, err := s.environmentRepo.GetEnvironmentSecrets(projectID, environmentID) if err != nil { return } err = s.environmentRepo.DeleteEnvironment(projectID, environmentID) if err != nil { return } var errors []error for _, secret := range secrets { err = s.encryptionService.DeleteSecret(&secret) if err != nil { errors = append(errors, err) } } if len(errors) > 0 { err = fmt.Errorf("failed to delete some secrets: %v", errors) return } return } ================================================ FILE: services/server/intergration_svc.go ================================================ package server import "github.com/semaphoreui/semaphore/db" type IntegrationService interface { FillIntegration(integration *db.Integration) error } type IntegrationServiceImpl struct { accessKeyRepo db.AccessKeyManager encryptionService AccessKeyEncryptionService } func NewIntegrationService( accessKeyRepo db.AccessKeyManager, encryptionService AccessKeyEncryptionService, ) IntegrationService { return &IntegrationServiceImpl{ accessKeyRepo: accessKeyRepo, encryptionService: encryptionService, } } func (s *IntegrationServiceImpl) FillIntegration(inventory *db.Integration) (err error) { if inventory.AuthSecretID != nil { inventory.AuthSecret, err = s.accessKeyRepo.GetAccessKey(inventory.ProjectID, *inventory.AuthSecretID) } if err != nil { return } err = s.encryptionService.DeserializeSecret(&inventory.AuthSecret) return } ================================================ FILE: services/server/inventory_svc.go ================================================ package server import "github.com/semaphoreui/semaphore/db" type InventoryService interface { GetInventory(projectID int, inventoryID int) (inventory db.Inventory, err error) } func NewInventoryService( accessKeyRepo db.AccessKeyManager, repositoryRepo db.RepositoryManager, inventoryRepo db.InventoryManager, encryptionService AccessKeyEncryptionService, ) InventoryService { return &InventoryServiceImpl{ accessKeyRepo: accessKeyRepo, repositoryRepo: repositoryRepo, encryptionService: encryptionService, inventoryRepo: inventoryRepo, } } type InventoryServiceImpl struct { accessKeyRepo db.AccessKeyManager repositoryRepo db.RepositoryManager encryptionService AccessKeyEncryptionService inventoryRepo db.InventoryManager } func (s *InventoryServiceImpl) GetInventory(projectID int, inventoryID int) (inventory db.Inventory, err error) { inventory, err = s.inventoryRepo.GetInventory(projectID, inventoryID) if err != nil { return } err = s.fillInventory(&inventory) return } func (s *InventoryServiceImpl) fillInventory(inventory *db.Inventory) (err error) { if inventory.SSHKeyID != nil { inventory.SSHKey, err = s.accessKeyRepo.GetAccessKey(inventory.ProjectID, *inventory.SSHKeyID) } if err != nil { return } if inventory.BecomeKeyID != nil { inventory.BecomeKey, err = s.accessKeyRepo.GetAccessKey(inventory.ProjectID, *inventory.BecomeKeyID) } if err != nil { return } if inventory.RepositoryID != nil { var repo db.Repository repo, err = s.repositoryRepo.GetRepository(inventory.ProjectID, *inventory.RepositoryID) if err != nil { return } err = s.encryptionService.DeserializeSecret(&repo.SSHKey) if err != nil { return } inventory.Repository = &repo } return } ================================================ FILE: services/server/project_svc.go ================================================ package server import ( "github.com/semaphoreui/semaphore/db" ) type ProjectService interface { UpdateProject(project db.Project) error DeleteProject(projectID int) error } func NewProjectService( projectRepo db.ProjectStore, keyRepo db.AccessKeyManager, ) ProjectService { return &ProjectServiceImpl{ projectRepo: projectRepo, keyRepo: keyRepo, } } type ProjectServiceImpl struct { projectRepo db.ProjectStore keyRepo db.AccessKeyManager } func (s *ProjectServiceImpl) DeleteProject(projectID int) error { return s.projectRepo.DeleteProject(projectID) } func (s *ProjectServiceImpl) UpdateProject(project db.Project) (err error) { err = s.projectRepo.UpdateProject(project) return } ================================================ FILE: services/server/project_svc_test.go ================================================ package server import ( "errors" "testing" "github.com/semaphoreui/semaphore/db" ) type mockProjectStore struct { UpdateProjectFn func(project db.Project) error DeleteProjectFn func(projectID int) error } func (m *mockProjectStore) UpdateProject(project db.Project) error { if m.UpdateProjectFn != nil { return m.UpdateProjectFn(project) } return nil } func (m *mockProjectStore) DeleteProject(projectID int) error { if m.DeleteProjectFn != nil { return m.DeleteProjectFn(projectID) } return nil } // Stub methods to satisfy db.ProjectStore func (m *mockProjectStore) GetProject(projectID int) (db.Project, error) { return db.Project{}, nil } func (m *mockProjectStore) GetAllProjects() ([]db.Project, error) { return nil, nil } func (m *mockProjectStore) GetProjects(userID int) ([]db.Project, error) { return nil, nil } func (m *mockProjectStore) CreateProject(project db.Project) (db.Project, error) { return db.Project{}, nil } func (m *mockProjectStore) GetProjectUsers(projectID int, params db.RetrieveQueryParams) ([]db.UserWithProjectRole, error) { return nil, nil } func (m *mockProjectStore) CreateProjectUser(projectUser db.ProjectUser) (db.ProjectUser, error) { return db.ProjectUser{}, nil } func (m *mockProjectStore) DeleteProjectUser(projectID int, userID int) error { return nil } func (m *mockProjectStore) GetProjectUser(projectID int, userID int) (db.ProjectUser, error) { return db.ProjectUser{}, nil } func (m *mockProjectStore) UpdateProjectUser(projectUser db.ProjectUser) error { return nil } type mockAccessKeyManager struct { GetAccessKeysFn func(projectID int, opts db.GetAccessKeyOptions, params db.RetrieveQueryParams) ([]db.AccessKey, error) CreateAccessKeyFn func(key db.AccessKey) (db.AccessKey, error) DeleteAccessKeyFn func(projectID, keyID int) error UpdateAccessKeyFn func(key db.AccessKey) error } func (m *mockAccessKeyManager) GetAccessKeys(projectID int, opts db.GetAccessKeyOptions, params db.RetrieveQueryParams) ([]db.AccessKey, error) { if m.GetAccessKeysFn != nil { return m.GetAccessKeysFn(projectID, opts, params) } return nil, nil } func (m *mockAccessKeyManager) CreateAccessKey(key db.AccessKey) (db.AccessKey, error) { if m.CreateAccessKeyFn != nil { return m.CreateAccessKeyFn(key) } return db.AccessKey{}, nil } func (m *mockAccessKeyManager) DeleteAccessKey(projectID, keyID int) error { if m.DeleteAccessKeyFn != nil { return m.DeleteAccessKeyFn(projectID, keyID) } return nil } func (m *mockAccessKeyManager) UpdateAccessKey(key db.AccessKey) error { if m.UpdateAccessKeyFn != nil { return m.UpdateAccessKeyFn(key) } return nil } // Stub methods to satisfy db.AccessKeyManager func (m *mockAccessKeyManager) GetAccessKey(projectID int, accessKeyID int) (db.AccessKey, error) { return db.AccessKey{}, nil } func (m *mockAccessKeyManager) GetAccessKeyRefs(projectID int, accessKeyID int) (db.ObjectReferrers, error) { return db.ObjectReferrers{}, nil } func (m *mockAccessKeyManager) RekeyAccessKeys(oldKey string) error { return nil } func TestProjectServiceImpl_DeleteProject(t *testing.T) { mockRepo := &mockProjectStore{ DeleteProjectFn: func(projectID int) error { if projectID == 42 { return nil } return errors.New("not found") }, } service := &ProjectServiceImpl{projectRepo: mockRepo} err := service.DeleteProject(42) if err != nil { t.Errorf("expected nil, got %v", err) } err = service.DeleteProject(1) if err == nil || err.Error() != "not found" { t.Errorf("expected not found error, got %v", err) } } func TestProjectServiceImpl_UpdateProject(t *testing.T) { project := db.Project{ID: 1} t.Run("UpdateProject returns error", func(t *testing.T) { mockRepo := &mockProjectStore{ UpdateProjectFn: func(p db.Project) error { return errors.New("fail") }, } mockKey := &mockAccessKeyManager{} service := &ProjectServiceImpl{projectRepo: mockRepo, keyRepo: mockKey} err := service.UpdateProject(project) if err == nil || err.Error() != "fail" { t.Errorf("expected fail error, got %v", err) } }) } ================================================ FILE: services/server/secret_storage_svc.go ================================================ package server import ( "errors" "fmt" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/random" pro "github.com/semaphoreui/semaphore/pro/services/server" ) type SecretStorageService interface { GetSecretStorage(projectID int, storageID int) (db.SecretStorage, error) Update(storage db.SecretStorage) error Delete(projectID int, storageID int) error GetSecretStorages(projectID int) ([]db.SecretStorage, error) Create(storage db.SecretStorage) (res db.SecretStorage, err error) SyncSecrets(storage db.SecretStorage) error } func NewSecretStorageService( secretStorageRepo db.SecretStorageRepository, accessKeyRepo db.AccessKeyManager, accessKeyService AccessKeyService, encryptionService AccessKeyEncryptionService, ) SecretStorageService { return &SecretStorageServiceImpl{ secretStorageRepo: secretStorageRepo, accessKeyRepo: accessKeyRepo, accessKeyService: accessKeyService, encryptionService: encryptionService, } } type SecretStorageServiceImpl struct { secretStorageRepo db.SecretStorageRepository accessKeyRepo db.AccessKeyManager accessKeyService AccessKeyService encryptionService AccessKeyEncryptionService } func (s *SecretStorageServiceImpl) SyncSecrets(storage db.SecretStorage) error { switch storage.Type { case db.SecretStorageTypeDvls: return pro.SyncDvlsSecrets(storage, s.accessKeyRepo, s.encryptionService) default: return fmt.Errorf("sync is not supported for storage type %q", storage.Type) } } func (s *SecretStorageServiceImpl) Delete(projectID int, storageID int) (err error) { err = s.secretStorageRepo.DeleteSecretStorage(projectID, storageID) if err != nil { return } keys, err := s.accessKeyService.GetAll(projectID, db.GetAccessKeyOptions{ Owner: db.AccessKeySecretStorage, StorageID: &storageID, }, db.RetrieveQueryParams{}) if err != nil { return } for _, key := range keys { err = s.accessKeyService.Delete(projectID, key.ID) } return } func (s *SecretStorageServiceImpl) GetSecretStorage(projectID int, storageID int) (res db.SecretStorage, err error) { return s.secretStorageRepo.GetSecretStorage(projectID, storageID) } func (s *SecretStorageServiceImpl) Create(storage db.SecretStorage) (res db.SecretStorage, err error) { sourceStorageType := storage.SourceStorageType sourceStorageKey := "" if storage.Secret == "" { err = errors.New("secret must be set") return } if sourceStorageType != nil { switch *sourceStorageType { case db.AccessKeySourceStorageEnv: sourceStorageKey = storage.Secret case db.AccessKeySourceStorageFile: sourceStorageKey = storage.Secret default: err = errors.New("unsupported source storage type") return } } res, err = s.secretStorageRepo.CreateSecretStorage(storage) if err != nil { return } key := db.AccessKey{ Name: random.String(10), Type: db.AccessKeyString, ProjectID: &storage.ProjectID, Owner: db.AccessKeySecretStorage, StorageID: &res.ID, SourceStorageType: sourceStorageType, } if sourceStorageKey != "" { key.SourceStorageKey = &sourceStorageKey } else { key.String = storage.Secret } _, err = s.accessKeyService.Create(key) return } func (s *SecretStorageServiceImpl) Update(storage db.SecretStorage) (err error) { err = s.secretStorageRepo.UpdateSecretStorage(storage) if err != nil { return } keys, err := s.accessKeyService.GetAll(storage.ProjectID, db.GetAccessKeyOptions{ Owner: db.AccessKeySecretStorage, StorageID: &storage.ID, }, db.RetrieveQueryParams{}) if err != nil { return } if len(keys) == 0 { if storage.Secret == "" { // empty vault token means the user didn't set a new token, // so we don't create a new access key. return } sourceStorageType := storage.SourceStorageType sourceStorageKey := "" if sourceStorageType != nil { switch *sourceStorageType { case db.AccessKeySourceStorageEnv, db.AccessKeySourceStorageFile: sourceStorageKey = storage.Secret default: err = errors.New("unsupported source storage type") return } } newKey := db.AccessKey{ Name: random.String(10), Type: db.AccessKeyString, ProjectID: &storage.ProjectID, Owner: db.AccessKeySecretStorage, StorageID: &storage.ID, SourceStorageType: sourceStorageType, } if sourceStorageKey != "" { newKey.SourceStorageKey = &sourceStorageKey } else { newKey.String = storage.Secret } _, err = s.accessKeyService.Create(newKey) } else { vault := keys[0] if storage.Secret == "" { // Do nothing if the vault token is empty, // as it means the user haven't set a new token. //err = s.keyRepo.DeleteAccessKey(storage.ProjectID, vault.ID) return } sourceStorageType := storage.SourceStorageType sourceStorageKey := "" if sourceStorageType != nil { switch *sourceStorageType { case db.AccessKeySourceStorageEnv, db.AccessKeySourceStorageFile: sourceStorageKey = storage.Secret default: err = errors.New("unsupported source storage type") return } } vault.OverrideSecret = true vault.SourceStorageType = sourceStorageType if sourceStorageKey != "" { vault.SourceStorageKey = &sourceStorageKey vault.String = "" // Clear previously persisted encrypted secret when switching to env/file source. vault.Secret = nil } else { vault.SourceStorageKey = nil vault.String = storage.Secret } err = s.accessKeyService.Update(vault) } return } func (s *SecretStorageServiceImpl) GetSecretStorages(projectID int) (storages []db.SecretStorage, err error) { return pro.GetSecretStorages(s.secretStorageRepo, projectID) } ================================================ FILE: services/session_svc.go ================================================ package services import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" "net/http" "time" ) type SessionService interface { GetSession(cookie http.Cookie) (*db.Session, bool) } type sessionServiceImpl struct { sessionRepo db.SessionManager } func NewSessionService(sessionRepo db.SessionManager) SessionService { return &sessionServiceImpl{ sessionRepo: sessionRepo, } } func (s *sessionServiceImpl) GetSession(cookie http.Cookie) (*db.Session, bool) { var err error value := make(map[string]any) if err = util.Cookie.Decode("semaphore", cookie.Value, &value); err != nil { //w.WriteHeader(http.StatusUnauthorized) return nil, false } user, ok := value["user"] sessionVal, okSession := value["session"] if !ok || !okSession { //w.WriteHeader(http.StatusUnauthorized) return nil, false } userID := user.(int) sessionID := sessionVal.(int) // fetch session session, err := s.sessionRepo.GetSession(userID, sessionID) if err != nil { //w.WriteHeader(http.StatusUnauthorized) return nil, false } if time.Since(session.LastActive).Hours() > 7*24 { // more than week old unused session // destroy. if err = s.sessionRepo.ExpireSession(userID, sessionID); err != nil { // it is internal error, it doesn't concern the user log.Error(err) } return nil, false } return &session, true } ================================================ FILE: services/tasks/LocalJob.go ================================================ package tasks import ( "encoding/json" "fmt" "maps" "os" "strings" "github.com/semaphoreui/semaphore/pkg/ssh" "path" "strconv" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/db_lib" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/util" ) type LocalJob struct { Task db.Task Template db.Template Inventory db.Inventory Repository db.Repository Environment db.Environment Secret string // Secret contains secrets received from Survey variables Logger task_logger.Logger // Logger allows to send logs and status to the server App db_lib.LocalApp killed bool // killed means that API request to stop the job has been received Process *os.Process sshKeyInstallation ssh.AccessKeyInstallation becomeKeyInstallation ssh.AccessKeyInstallation vaultFileInstallations map[string]ssh.AccessKeyInstallation KeyInstaller db_lib.AccessKeyInstaller } func (t *LocalJob) IsKilled() bool { return t.killed } func (t *LocalJob) Kill() { t.killed = true if t.Process == nil { return } err := t.Process.Kill() if err != nil { t.Log(err.Error()) } } func (t *LocalJob) Log(msg string) { t.Logger.Log(msg) } func (t *LocalJob) SetStatus(status task_logger.TaskStatus) { t.Logger.SetStatus(status) } func (t *LocalJob) SetCommit(hash, message string) { // TODO: is this the correct place to do? t.Task.CommitHash = &hash t.Task.CommitMessage = message t.Logger.SetCommit(hash, message) } func (t *LocalJob) getTaskDetails(username string, incomingVersion *string) (taskDetails map[string]any) { taskDetails = make(map[string]any) taskDetails["id"] = t.Task.ID if t.Task.Message != "" { taskDetails["message"] = t.Task.Message } taskDetails["username"] = username taskDetails["url"] = t.Task.GetUrl() taskDetails["commit_hash"] = t.Task.CommitHash taskDetails["commit_message"] = t.Task.CommitMessage taskDetails["inventory_name"] = t.Inventory.Name taskDetails["inventory_id"] = t.Inventory.ID taskDetails["repository_name"] = t.Repository.Name taskDetails["repository_id"] = t.Repository.ID if t.Template.Type != db.TemplateTask { taskDetails["type"] = t.Template.Type if incomingVersion != nil { taskDetails["incoming_version"] = incomingVersion } if t.Template.Type == db.TemplateBuild { taskDetails["target_version"] = t.Task.Version } } return } func (t *LocalJob) getEnvironmentExtraVars(username string, incomingVersion *string) (extraVars map[string]any, err error) { extraVars = make(map[string]any) if t.Environment.JSON != "" { err = json.Unmarshal([]byte(t.Environment.JSON), &extraVars) if err != nil { return } } vars := make(map[string]any) vars["task_details"] = t.getTaskDetails(username, incomingVersion) extraVars["semaphore_vars"] = vars return } func (t *LocalJob) getEnvironmentExtraVarsJSON(username string, incomingVersion *string) (str string, err error) { extraVars := make(map[string]any) extraSecretVars := make(map[string]any) if t.Environment.JSON != "" { err = json.Unmarshal([]byte(t.Environment.JSON), &extraVars) if err != nil { return } } if t.Secret != "" { err = json.Unmarshal([]byte(t.Secret), &extraSecretVars) if err != nil { return } } t.Secret = "{}" maps.Copy(extraVars, extraSecretVars) vars := make(map[string]any) vars["task_details"] = t.getTaskDetails(username, incomingVersion) extraVars["semaphore_vars"] = vars ev, err := json.Marshal(extraVars) if err != nil { return } str = string(ev) return } func (t *LocalJob) getEnvironmentENV() (res []string, err error) { environmentVars := make(map[string]string) if t.Environment.ENV != nil { err = json.Unmarshal([]byte(*t.Environment.ENV), &environmentVars) if err != nil { return } } for key, val := range environmentVars { res = append(res, fmt.Sprintf("%s=%s", key, val)) } for _, secret := range t.Environment.Secrets { if secret.Type != db.EnvironmentSecretEnv { continue } res = append(res, fmt.Sprintf("%s=%s", secret.Name, secret.Secret)) } return } func (t *LocalJob) getShellEnvironmentExtraENV(username string, incomingVersion *string) (extraShellVars []string) { taskDetails := t.getTaskDetails(username, incomingVersion) for taskDetail, taskDetailValue := range taskDetails { envVarName := fmt.Sprintf("SEMAPHORE_TASK_DETAILS_%s", strings.ToUpper(taskDetail)) detailAsStr := "" switch taskDetailValueOfType := taskDetailValue.(type) { case string: detailAsStr = taskDetailValueOfType case *string: if taskDetailValueOfType != nil { detailAsStr = *taskDetailValueOfType } case int: detailAsStr = strconv.Itoa(taskDetailValueOfType) case *int: if taskDetailValueOfType != nil { detailAsStr = strconv.Itoa(*taskDetailValueOfType) } default: continue } if detailAsStr != "" { extraShellVars = append(extraShellVars, fmt.Sprintf("%s=%s", envVarName, util.ShellQuote(util.ShellStripUnsafe(detailAsStr)))) } } return } // nolint: gocyclo func (t *LocalJob) getShellArgs(username string, incomingVersion *string) (args []string, err error) { extraVars, err := t.getEnvironmentExtraVars(username, incomingVersion) if err != nil { t.Log(err.Error()) t.Log("Error getting environment extra vars") return } templateArgs, taskArgs, err := t.getCLIArgs() if err != nil { t.Log(err.Error()) return } // Script to run args = append(args, t.Template.Playbook) // Include Environment Secret Vars for _, secret := range t.Environment.Secrets { if secret.Type == db.EnvironmentSecretVar { args = append(args, fmt.Sprintf("%s=%s", secret.Name, secret.Secret)) } } // Include extra args from template args = append(args, templateArgs...) // Include ExtraVars and Survey Vars for name, value := range extraVars { if name != "semaphore_vars" { args = append(args, fmt.Sprintf("%s=%s", name, value)) } } // Include extra args from task args = append(args, taskArgs...) return } // nolint: gocyclo func (t *LocalJob) getTerraformArgs(username string, incomingVersion *string) (argsMap map[string][]string, err error) { argsMap = make(map[string][]string) extraVars, err := t.getEnvironmentExtraVars(username, incomingVersion) if err != nil { t.Log(err.Error()) t.Log("Could not remove command environment, if existent it will be passed to --extra-vars. This is not fatal but be aware of side effects") return } var params db.TerraformTaskParams err = t.Task.ExtractParams(¶ms) if err != nil { return } // Common args for destroy flag destroyArgs := []string{} if params.Destroy { destroyArgs = append(destroyArgs, "-destroy") } // Common args for environment variables varArgs := []string{} for name, value := range extraVars { if name == "semaphore_vars" { continue } varArgs = append(varArgs, "-var", fmt.Sprintf("%s=%s", name, value)) } templateArgsMap, taskArgsMap, err := t.getCLIArgsMap() if err != nil { t.Log(err.Error()) return } // Common args for environment secrets secretArgs := []string{} for _, secret := range t.Environment.Secrets { if secret.Type != db.EnvironmentSecretVar { continue } secretArgs = append(secretArgs, "-var", fmt.Sprintf("%s=%s", secret.Name, secret.Secret)) } // Merge template and task args maps if templateArgsMap != nil { for stage, stageArgs := range templateArgsMap { argsMap[stage] = append([]string{}, stageArgs...) } } if taskArgsMap != nil { for stage, stageArgs := range taskArgsMap { if existing, ok := argsMap[stage]; ok { argsMap[stage] = append(existing, stageArgs...) } else { argsMap[stage] = append([]string{}, stageArgs...) } } } if len(argsMap) == 0 { argsMap["default"] = []string{} } // Add common args to each stage except init for stage := range argsMap { if stage == "init" { continue } // Prepend destroy args combined := append([]string{}, destroyArgs...) combined = append(combined, argsMap[stage]...) combined = append(combined, varArgs...) combined = append(combined, secretArgs...) argsMap[stage] = combined } return } // nolint: gocyclo func (t *LocalJob) getPlaybookArgs(username string, incomingVersion *string) (args []string, inputs map[string]string, err error) { inputMap := make(map[db.AccessKeyRole]string) inputs = make(map[string]string) playbookName := t.Task.Playbook if playbookName == "" { playbookName = t.Template.Playbook } var inventoryFilename string switch t.Inventory.Type { case db.InventoryFile: if t.Inventory.RepositoryID == nil { inventoryFilename = t.Inventory.GetFilename() } else { inventoryFilename = path.Join(t.tmpInventoryFullPath(), t.Inventory.GetFilename()) } case db.InventoryStatic, db.InventoryStaticYaml: inventoryFilename = t.tmpInventoryFullPath() default: err = fmt.Errorf("invalid inventory type") return } args = []string{ "-i", inventoryFilename, } if t.Inventory.SSHKeyID != nil { switch t.Inventory.SSHKey.Type { case db.AccessKeySSH: if t.sshKeyInstallation.Login != "" { args = append(args, "--user", t.sshKeyInstallation.Login) } case db.AccessKeyLoginPassword: if t.sshKeyInstallation.Login != "" { args = append(args, "--user", t.sshKeyInstallation.Login) } if t.sshKeyInstallation.Password != "" { args = append(args, "--ask-pass") inputMap[db.AccessKeyRoleAnsibleUser] = t.sshKeyInstallation.Password } case db.AccessKeyNone: default: err = fmt.Errorf("access key does not suite for inventory's user credentials") return } } if t.Inventory.BecomeKeyID != nil { switch t.Inventory.BecomeKey.Type { case db.AccessKeyLoginPassword: if t.becomeKeyInstallation.Login != "" { args = append(args, "--become-user", t.becomeKeyInstallation.Login) } if t.becomeKeyInstallation.Password != "" { args = append(args, "--ask-become-pass") inputMap[db.AccessKeyRoleAnsibleBecomeUser] = t.becomeKeyInstallation.Password } case db.AccessKeyNone: default: err = fmt.Errorf("access key does not suite for inventory's sudo user credentials") return } } var tplParams db.AnsibleTemplateParams err = t.Template.FillParams(&tplParams) if err != nil { return } var params db.AnsibleTaskParams err = t.Task.ExtractParams(¶ms) if err != nil { return } if tplParams.AllowDebug && params.Debug { if params.DebugLevel < 1 { params.DebugLevel = 4 } if params.DebugLevel > 6 { params.DebugLevel = 6 } args = append(args, "-"+strings.Repeat("v", params.DebugLevel)) } if params.Diff { args = append(args, "--diff") } if params.DryRun { args = append(args, "--check") } for name, install := range t.vaultFileInstallations { if install.Password != "" { args = append(args, fmt.Sprintf("--vault-id=%s@prompt", name)) inputs[fmt.Sprintf("Vault password (%s):", name)] = install.Password } if install.Script != "" { args = append(args, fmt.Sprintf("--vault-id=%s@%s", name, install.Script)) } } extraVars, err := t.getEnvironmentExtraVarsJSON(username, incomingVersion) if err != nil { t.Log(err.Error()) t.Log("Could not remove command environment, if existent it will be passed to --extra-vars. This is not fatal but be aware of side effects") } else if extraVars != "" { args = append(args, "--extra-vars", extraVars) } for _, secret := range t.Environment.Secrets { if secret.Type != db.EnvironmentSecretVar { continue } args = append(args, "--extra-vars", fmt.Sprintf("%s=%s", secret.Name, secret.Secret)) } templateArgs, taskArgs, err := t.getCLIArgs() if err != nil { t.Log(err.Error()) return } var limit string var tags string var skipTags string // Fill fields from template if len(tplParams.Limit) > 0 { limit = strings.Join(tplParams.Limit, ",") } if len(tplParams.Tags) > 0 { tags = strings.Join(tplParams.Tags, ",") } if len(tplParams.SkipTags) > 0 { skipTags = strings.Join(tplParams.SkipTags, ",") } // Fill fields from task if tplParams.AllowOverrideLimit && params.Limit != nil { limit = strings.Join(params.Limit, ",") } if tplParams.AllowOverrideTags && params.Tags != nil { tags = strings.Join(params.Tags, ",") } if tplParams.AllowOverrideSkipTags && params.SkipTags != nil { skipTags = strings.Join(params.SkipTags, ",") } // Add final args if limit != "" { templateArgs = append(templateArgs, "--limit="+limit) } if tags != "" { templateArgs = append(templateArgs, "--tags="+tags) } if skipTags != "" { templateArgs = append(templateArgs, "--skip-tags="+skipTags) } args = append(args, templateArgs...) args = append(args, taskArgs...) args = append(args, playbookName) if line, ok := inputMap[db.AccessKeyRoleAnsibleUser]; ok { inputs["SSH password:"] = line } if line, ok := inputMap[db.AccessKeyRoleAnsibleBecomeUser]; ok { inputs["BECOME password"] = line } if line, ok := inputMap[db.AccessKeyRoleAnsibleBecomeUser]; ok { inputs["SUDO password"] = line } return } func (t *LocalJob) getCLIArgs() (templateArgs []string, taskArgs []string, err error) { if t.Template.Arguments != nil { err = json.Unmarshal([]byte(*t.Template.Arguments), &templateArgs) if err != nil { err = fmt.Errorf("invalid format of the template extra arguments, must be valid JSON") return } } if t.Template.AllowOverrideArgsInTask && t.Task.Arguments != nil { err = json.Unmarshal([]byte(*t.Task.Arguments), &taskArgs) if err != nil { err = fmt.Errorf("invalid format of the TaskRunner extra arguments, must be valid JSON") return } } return } // convertArgsJSONIfArray converts array format JSON to map format with "default" key and returns the parsed result func convertArgsJSONIfArray(argsJSON string) (map[string][]string, error) { if argsJSON == "" { return nil, nil } // Try to parse as array first var arr []string if err := json.Unmarshal([]byte(argsJSON), &arr); err == nil { // It's an array, convert to map format mapArgs := map[string][]string{ "default": arr, } return mapArgs, nil } // If not an array, verify it's a valid map format var mapArgs map[string][]string if err := json.Unmarshal([]byte(argsJSON), &mapArgs); err != nil { return nil, fmt.Errorf("invalid format of arguments, must be valid JSON array or map: %v", err) } return mapArgs, nil } // getCLIArgsMap returns args that support both array and map formats // Array format is automatically converted to map with "default" key for backward compatibility // Returns: templateArgsMap (map), taskArgsMap (map), err func (t *LocalJob) getCLIArgsMap() (templateArgsMap map[string][]string, taskArgsMap map[string][]string, err error) { // Convert template arguments if needed if t.Template.Arguments != nil { templateArgsMap, err = convertArgsJSONIfArray(*t.Template.Arguments) if err != nil { return nil, nil, err } } // Convert task arguments if needed if t.Template.AllowOverrideArgsInTask && t.Task.Arguments != nil { taskArgsMap, err = convertArgsJSONIfArray(*t.Task.Arguments) if err != nil { return nil, nil, err } } return } func (t *LocalJob) getTemplateParams() (any, error) { var params any switch t.Template.App { case db.AppAnsible: params = &db.AnsibleTemplateParams{} case db.AppTerraform, db.AppTofu, db.AppTerragrunt: params = &db.TerraformTemplateParams{} default: return nil, nil } err := t.Template.FillParams(params) return params, err } func (t *LocalJob) getParams() (params any, err error) { switch t.Template.App { case db.AppAnsible: params = &db.AnsibleTaskParams{} case db.AppTerraform, db.AppTofu, db.AppTerragrunt: params = &db.TerraformTaskParams{} default: params = &db.DefaultTaskParams{} } err = t.Task.ExtractParams(params) if err != nil { return } return } func (t *LocalJob) Run(username string, incomingVersion *string, alias string) (err error) { defer func() { t.destroyKeys() t.destroyInventoryFile() t.App.Clear() }() t.SetStatus(task_logger.TaskRunningStatus) // It is required for local mode. Don't delete environmentVariables, err := t.getEnvironmentENV() if err != nil { return } tplParams, err := t.getTemplateParams() if err != nil { return } params, err := t.getParams() if err != nil { return } if t.Template.App.IsTerraform() && alias != "" { environmentVariables = append(environmentVariables, "TF_HTTP_ADDRESS="+util.GetPublicAliasURL("terraform", alias)) } // For Terraform apps, get args first so we can pass init args to prepareRun var argsMap map[string][]string var inputs map[string]string if t.Template.App.IsTerraform() { argsMap, err = t.getTerraformArgs(username, incomingVersion) if err != nil { return } // Use Terraform-specific prepareRun with init args if tfApp, ok := t.App.(*db_lib.TerraformApp); ok { initArgs := []string(nil) if argsMap != nil { if stageArgs, ok := argsMap["init"]; ok { initArgs = stageArgs } } err = t.prepareRunTerraform(tfApp, db_lib.LocalAppInstallingArgs{ EnvironmentVars: environmentVariables, TplParams: tplParams, Params: params, Installer: t.KeyInstaller, }, initArgs) if err != nil { return err } } else { err = t.prepareRun(db_lib.LocalAppInstallingArgs{ EnvironmentVars: environmentVariables, TplParams: tplParams, Params: params, Installer: t.KeyInstaller, }) if err != nil { return err } } } else { err = t.prepareRun(db_lib.LocalAppInstallingArgs{ EnvironmentVars: environmentVariables, TplParams: tplParams, Params: params, Installer: t.KeyInstaller, }) if err != nil { return err } } // Get args for non-Terraform apps var args []string switch t.Template.App { case db.AppAnsible: args, inputs, err = t.getPlaybookArgs(username, incomingVersion) if err != nil { return } // Convert to map format with "default" key argsMap = map[string][]string{"default": args} case db.AppTerraform, db.AppTofu, db.AppTerragrunt: // Already got args earlier for Terraform default: args, err = t.getShellArgs(username, incomingVersion) if err != nil { return } // Convert to map format with "default" key argsMap = map[string][]string{"default": args} } // Get extra environment vars for non-Terraform apps switch t.Template.App { case db.AppAnsible: // Semaphore vars / task details were already passed // as 'extra vars' in JSON format break case db.AppTerraform, db.AppTofu, db.AppTerragrunt: break default: environmentVariables = append(environmentVariables, t.getShellEnvironmentExtraENV(username, incomingVersion)...) } if t.Inventory.SSHKey.Type == db.AccessKeySSH && t.Inventory.SSHKeyID != nil { environmentVariables = append(environmentVariables, fmt.Sprintf("SSH_AUTH_SOCK=%s", t.sshKeyInstallation.SSHAgent.SocketFile)) } if t.Template.Type != db.TemplateTask { environmentVariables = append(environmentVariables, fmt.Sprintf("SEMAPHORE_TASK_TYPE=%s", t.Template.Type)) if incomingVersion != nil { environmentVariables = append( environmentVariables, fmt.Sprintf("SEMAPHORE_TASK_INCOMING_VERSION=%s", *incomingVersion)) } if t.Template.Type == db.TemplateBuild && t.Task.Version != nil { environmentVariables = append( environmentVariables, fmt.Sprintf("SEMAPHORE_TASK_TARGET_VERSION=%s", *t.Task.Version)) } } if t.killed { t.SetStatus(task_logger.TaskStoppedStatus) return nil } return t.App.Run(db_lib.LocalAppRunningArgs{ CliArgs: argsMap, EnvironmentVars: environmentVariables, Inputs: inputs, TaskParams: params, TemplateParams: tplParams, Callback: func(p *os.Process) { t.Process = p }, }) } func (t *LocalJob) prepareRun(installingArgs db_lib.LocalAppInstallingArgs) error { t.Log("Preparing: " + strconv.Itoa(t.Task.ID)) if err := checkTmpDir(util.Config.GetProjectTmpDir(t.Template.ProjectID)); err != nil { t.Log("Creating tmp dir failed: " + err.Error()) return err } if util.Config.HomeDirMode != util.HomeDirModeProjectHome { if err := checkTmpDir(t.Repository.GetHomePath(t.Template.ID)); err != nil { t.Log("Creating task home dir failed: " + err.Error()) return err } } // Override git branch from template if set if t.Template.GitBranch != nil && *t.Template.GitBranch != "" { t.Repository.GitBranch = *t.Template.GitBranch } // Override git branch from task if set if t.Task.GitBranch != nil && *t.Task.GitBranch != "" { t.Repository.GitBranch = *t.Task.GitBranch } if t.Repository.GetType() == db.RepositoryLocal { if _, err := os.Stat(t.Repository.GitURL); err != nil { t.Log("Failed in finding static repository at " + t.Repository.GitURL + ": " + err.Error()) return err } } else { if err := t.updateRepository(); err != nil { t.Log("Failed updating repository: " + err.Error()) return err } if err := t.checkoutRepository(); err != nil { t.Log("Failed to checkout repository to required commit: " + err.Error()) return err } } if err := t.installInventory(); err != nil { t.Log("Failed to install inventory: " + err.Error()) return err } if err := t.App.InstallRequirements(installingArgs); err != nil { t.Log("Failed to install requirements: " + err.Error()) return err } if err := t.installVaultKeyFiles(); err != nil { t.Log("Failed to install vault password files: " + err.Error()) return err } return nil } func (t *LocalJob) prepareRunTerraform(tfApp *db_lib.TerraformApp, installingArgs db_lib.LocalAppInstallingArgs, initArgs []string) error { t.Log("Preparing: " + strconv.Itoa(t.Task.ID)) if err := checkTmpDir(util.Config.GetProjectTmpDir(t.Template.ProjectID)); err != nil { t.Log("Creating tmp dir failed: " + err.Error()) return err } if util.Config.HomeDirMode != util.HomeDirModeProjectHome { if err := checkTmpDir(t.Repository.GetHomePath(t.Template.ID)); err != nil { t.Log("Creating task home dir failed: " + err.Error()) return err } } // Override git branch from template if set if t.Template.GitBranch != nil && *t.Template.GitBranch != "" { t.Repository.GitBranch = *t.Template.GitBranch } // Override git branch from task if set if t.Task.GitBranch != nil && *t.Task.GitBranch != "" { t.Repository.GitBranch = *t.Task.GitBranch } if t.Repository.GetType() == db.RepositoryLocal { if _, err := os.Stat(t.Repository.GitURL); err != nil { t.Log("Failed in finding static repository at " + t.Repository.GitURL + ": " + err.Error()) return err } } else { if err := t.updateRepository(); err != nil { t.Log("Failed updating repository: " + err.Error()) return err } if err := t.checkoutRepository(); err != nil { t.Log("Failed to checkout repository to required commit: " + err.Error()) return err } } if err := t.installInventory(); err != nil { t.Log("Failed to install inventory: " + err.Error()) return err } // Call Terraform-specific install with init args if err := tfApp.InstallRequirementsWithInitArgs(installingArgs, initArgs); err != nil { t.Log("Failed to install requirements: " + err.Error()) return err } if err := t.installVaultKeyFiles(); err != nil { t.Log("Failed to install vault password files: " + err.Error()) return err } return nil } func (t *LocalJob) updateRepository() error { repo := db_lib.GitRepository{ Logger: t.Logger, TemplateID: t.Template.ID, Repository: t.Repository, Client: db_lib.CreateDefaultGitClient(t.KeyInstaller), } err := repo.ValidateRepo() if err != nil { if !os.IsNotExist(err) { err = os.RemoveAll(repo.GetFullPath()) if err != nil { return err } } return repo.Clone() } if repo.CanBePulled() { err = repo.Pull() if err == nil { return nil } } err = os.RemoveAll(repo.GetFullPath()) if err != nil { return err } return repo.Clone() } func (t *LocalJob) checkoutRepository() error { repo := db_lib.GitRepository{ Logger: t.Logger, TemplateID: t.Template.ID, Repository: t.Repository, Client: db_lib.CreateDefaultGitClient(t.KeyInstaller), } err := repo.ValidateRepo() if err != nil { return err } if t.Task.CommitHash != nil { // checkout to commit if it is provided for TaskRunner return repo.Checkout(*t.Task.CommitHash) } // store commit to TaskRunner table commitHash, err := repo.GetLastCommitHash() if err != nil { return err } commitMessage, err := repo.GetLastCommitMessage() if err != nil { t.Log(err.Error()) } t.SetCommit(commitHash, commitMessage) return nil } func (t *LocalJob) installVaultKeyFiles() (err error) { t.vaultFileInstallations = make(map[string]ssh.AccessKeyInstallation) if len(t.Template.Vaults) == 0 { return nil } for _, vault := range t.Template.Vaults { var name string if vault.Name != nil { name = *vault.Name } else { name = "default" } var install ssh.AccessKeyInstallation if vault.Type == db.TemplateVaultPassword { install, err = t.KeyInstaller.Install(*vault.Vault, db.AccessKeyRoleAnsiblePasswordVault, t.Logger) if err != nil { return } } if vault.Type == db.TemplateVaultScript && vault.Script != nil { install.Script = *vault.Script } t.vaultFileInstallations[name] = install } return } ================================================ FILE: services/tasks/LocalJob_inventory.go ================================================ package tasks import ( "os" "path" "strconv" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/db_lib" log "github.com/sirupsen/logrus" "github.com/semaphoreui/semaphore/util" ) func (t *LocalJob) installInventory() (err error) { if t.Inventory.SSHKeyID != nil { t.sshKeyInstallation, err = t.KeyInstaller.Install(t.Inventory.SSHKey, db.AccessKeyRoleAnsibleUser, t.Logger) if err != nil { return } } if t.Inventory.BecomeKeyID != nil { t.becomeKeyInstallation, err = t.KeyInstaller.Install(t.Inventory.BecomeKey, db.AccessKeyRoleAnsibleBecomeUser, t.Logger) if err != nil { return } } switch t.Inventory.Type { case db.InventoryFile: err = t.cloneInventoryRepo(t.KeyInstaller) case db.InventoryStatic, db.InventoryStaticYaml: err = t.installStaticInventory() } return } func (t *LocalJob) tmpInventoryFilename() string { if t.Inventory.Repository == nil { return "inventory_" + strconv.Itoa(t.Inventory.ID) } return t.Inventory.Repository.GetDirName(t.Template.ID) + "_inventory_" + strconv.Itoa(t.Inventory.ID) } func (t *LocalJob) tmpInventoryFullPath() string { if t.Inventory.Repository != nil && t.Inventory.Repository.GetType() == db.RepositoryLocal { return t.Inventory.Repository.GetGitURL(true) } pathname := path.Join(util.Config.GetProjectTmpDir(t.Template.ProjectID), t.tmpInventoryFilename()) if t.Inventory.Type == db.InventoryStaticYaml { pathname += ".yml" } return pathname } func (t *LocalJob) cloneInventoryRepo(keyInstaller db_lib.AccessKeyInstaller) error { if t.Inventory.Repository == nil { return nil } if t.Inventory.Repository.GetType() == db.RepositoryLocal { return nil } t.Log("cloning inventory repository") repo := db_lib.GitRepository{ Logger: t.Logger, TmpDirName: t.tmpInventoryFilename(), Repository: *t.Inventory.Repository, Client: db_lib.CreateDefaultGitClient(keyInstaller), } // Try to pull the repo before trying to clone it if repo.CanBePulled() { err := repo.Pull() if err == nil { return nil } } err := os.RemoveAll(repo.GetFullPath()) if err != nil { return err } return repo.Clone() } func (t *LocalJob) installStaticInventory() error { t.Log("installing static inventory") fullPath := t.tmpInventoryFullPath() // create inventory file return os.WriteFile(fullPath, []byte(t.Inventory.Inventory), 0664) } func (t *LocalJob) destroyInventoryFile() { if !t.Inventory.Type.IsStatic() { return } fullPath := t.tmpInventoryFullPath() if err := os.Remove(fullPath); err != nil { if os.IsNotExist(err) { return } log.WithError(err).WithFields(log.Fields{ "context": "task_running", "task_id": t.Task.ID, }).Warn("failed to remove inventory file") } } func (t *LocalJob) destroyKeys() { err := t.sshKeyInstallation.Destroy() if err != nil { t.Log("Can't destroy inventory user key, error: " + err.Error()) } err = t.becomeKeyInstallation.Destroy() if err != nil { t.Log("Can't destroy inventory become user key, error: " + err.Error()) } for _, vault := range t.vaultFileInstallations { err = vault.Destroy() if err != nil { t.Log("Can't destroy inventory vault password file, error: " + err.Error()) } } } ================================================ FILE: services/tasks/RemoteJob.go ================================================ package tasks import ( "bytes" "encoding/json" "errors" "fmt" "net/http" "time" "github.com/semaphoreui/semaphore/pkg/tz" log "github.com/sirupsen/logrus" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/util" ) // ErrAllRunnersBusy is returned when all available runners are busy var ErrAllRunnersBusy = errors.New("all runners busy") type RemoteJob struct { RunnerTag *string Task db.Task taskPool *TaskPool killed bool } type runnerWebhookPayload struct { Action string `json:"action"` ProjectID int `json:"project_id"` TaskID int `json:"task_id"` TemplateID int `json:"template_id"` RunnerID int `json:"runner_id"` } func callRunnerWebhook(runner *db.Runner, tsk *TaskRunner, action string) (err error) { if runner.Webhook == "" { return } log.WithFields(log.Fields{ "runner_id": runner.ID, "task_id": tsk.Task.ID, "action": action, }).Infof("Calling runner webhook") var jsonBytes []byte jsonBytes, err = json.Marshal(runnerWebhookPayload{ Action: action, ProjectID: tsk.Task.ProjectID, TaskID: tsk.Task.ID, TemplateID: tsk.Template.ID, RunnerID: runner.ID, }) if err != nil { return } client := &http.Client{} var req *http.Request req, err = http.NewRequest("POST", runner.Webhook, bytes.NewBuffer(jsonBytes)) if err != nil { return } req.Header.Set("Content-Type", "application/json") var resp *http.Response resp, err = client.Do(req) if err != nil { return } if resp != nil { defer resp.Body.Close() //nolint:errcheck } if resp.StatusCode != 200 && resp.StatusCode != 204 { err = fmt.Errorf("webhook returned incorrect status") return } log.WithFields(log.Fields{ "runner_id": runner.ID, "task_id": tsk.Task.ID, "action": action, }).Infof("Runner webhook returned %d", resp.StatusCode) return } func (t *RemoteJob) Run(username string, incomingVersion *string, alias string) (err error) { tsk := t.taskPool.GetTask(t.Task.ID) if tsk == nil { return fmt.Errorf("task not found") } tsk.IncomingVersion = incomingVersion tsk.Username = username tsk.Alias = alias t.taskPool.state.UpdateRuntimeFields(tsk) var runners []db.Runner db.StoreSession(t.taskPool.store, "run remote job", func() { var projectRunners []db.Runner projectRunners, err = t.taskPool.store.GetRunners(t.Task.ProjectID, true, t.RunnerTag) if err != nil { return } var globalRunners []db.Runner globalRunners, err = t.taskPool.store.GetAllRunners(true, true) if err != nil { return } runners = append(runners, projectRunners...) runners = append(runners, globalRunners...) }) if err != nil { return } if len(runners) == 0 { err = fmt.Errorf("no runners available") return } var runner *db.Runner for _, r := range runners { n := t.taskPool.GetNumberOfRunningTasksOfRunner(r.ID) if n < r.MaxParallelTasks || r.MaxParallelTasks == 0 { runner = &r break } } if runner == nil { err = ErrAllRunnersBusy return } err = callRunnerWebhook(runner, tsk, "start") if err != nil { return } tsk.RunnerID = runner.ID if t.taskPool != nil && t.taskPool.state != nil { t.taskPool.state.UpdateRuntimeFields(tsk) } startTime := tz.Now() taskTimedOut := false for { if util.Config.MaxTaskDurationSec > 0 && int(tz.Now().Sub(startTime).Seconds()) > util.Config.MaxTaskDurationSec { taskTimedOut = true break } time.Sleep(1_000_000_000) tsk = t.taskPool.GetTask(t.Task.ID) if tsk == nil { err = fmt.Errorf("task %d not found", t.Task.ID) return } if tsk.Task.Status == task_logger.TaskSuccessStatus || tsk.Task.Status == task_logger.TaskStoppedStatus || tsk.Task.Status == task_logger.TaskFailStatus { break } } err = callRunnerWebhook(runner, tsk, "finish") if err != nil { return } if tsk.Task.Status == task_logger.TaskFailStatus { err = fmt.Errorf("task failed") } else if taskTimedOut { err = fmt.Errorf("task timed out") } return } func (t *RemoteJob) Kill() { t.killed = true // Do nothing because you can't kill remote process } func (t *RemoteJob) IsKilled() bool { return t.killed } ================================================ FILE: services/tasks/TaskPool.go ================================================ package tasks import ( "fmt" "regexp" "strconv" "strings" "time" "github.com/semaphoreui/semaphore/pkg/random" "github.com/semaphoreui/semaphore/pkg/tz" "github.com/semaphoreui/semaphore/pro/pkg/stage_parsers" "github.com/semaphoreui/semaphore/pro_interfaces" "github.com/semaphoreui/semaphore/services/server" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/db_lib" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" ) type logRecord struct { task *TaskRunner output string time time.Time currentStage *db.TaskStage } type EventType uint const ( EventTypeNew EventType = 0 // EventTypeNew represents an event when a new task is created, typically sent during a periodic check or timer. EventTypeFinished EventType = 1 // EventTypeFinished represents an event when a task finishes, typically sent during a periodic check or timer. EventTypeFailed EventType = 2 // EventTypeFailed represents an event when a task fails, typically sent during a periodic check or timer. EventTypeEmpty EventType = 3 // EventTypeEmpty represents an event when the queue is empty, typically sent during a periodic check or timer. EventTypeRequeued EventType = 4 // EventTypeRequeued represents an event when a task is moved back to the waiting state for reprocessing. ) const ( TaskOutputBatchSize = 500 TaskOutputInsertIntervalMs = 500 ) type PoolEvent struct { eventType EventType task *TaskRunner } type TaskPool struct { // register channel used to put tasks to queue. register chan *TaskRunner // logger channel used to putting log records to database. logger chan logRecord store db.Store ansibleTaskRepo db.AnsibleTaskRepository logWriteService pro_interfaces.LogWriteService inventoryService server.InventoryService encryptionService server.AccessKeyEncryptionService keyInstallationService server.AccessKeyInstallationService queueEvents chan PoolEvent // state provides pluggable storage for Queue, active projects, running tasks and aliases state TaskStateStore } func CreateTaskPool( store db.Store, state TaskStateStore, ansibleTaskRepo db.AnsibleTaskRepository, inventoryService server.InventoryService, encryptionService server.AccessKeyEncryptionService, keyInstallationService server.AccessKeyInstallationService, logWriteService pro_interfaces.LogWriteService, ) TaskPool { p := TaskPool{ register: make(chan *TaskRunner), // add TaskRunner to queue logger: make(chan logRecord, 10000), // store log records to database store: store, state: state, queueEvents: make(chan PoolEvent), inventoryService: inventoryService, ansibleTaskRepo: ansibleTaskRepo, encryptionService: encryptionService, logWriteService: logWriteService, keyInstallationService: keyInstallationService, } // attempt to start HA state store (no-op for memory) _ = p.state.Start(p.hydrateTaskRunner) return p } // CreateTaskPoolWithState allows passing a custom TaskStateStore (e.g., Redis-backed) func CreateTaskPoolWithState( stateStore TaskStateStore, store db.Store, ansibleTaskRepo db.AnsibleTaskRepository, inventoryService server.InventoryService, encryptionService server.AccessKeyEncryptionService, keyInstallationService server.AccessKeyInstallationService, logWriteService pro_interfaces.LogWriteService, ) TaskPool { p := TaskPool{ register: make(chan *TaskRunner), // add TaskRunner to queue logger: make(chan logRecord, 10000), // store log records to database store: store, queueEvents: make(chan PoolEvent), state: stateStore, inventoryService: inventoryService, ansibleTaskRepo: ansibleTaskRepo, encryptionService: encryptionService, logWriteService: logWriteService, keyInstallationService: keyInstallationService, } _ = p.state.Start(p.hydrateTaskRunner) return p } func (p *TaskPool) GetNumberOfRunningTasksOfRunner(runnerID int) (res int) { for _, task := range p.state.RunningRange() { if task.RunnerID == runnerID { res++ } } return } func (p *TaskPool) GetRunningTasks() (res []*TaskRunner) { return p.state.RunningRange() } func (p *TaskPool) GetTask(id int) (task *TaskRunner) { for _, t := range p.state.QueueRange() { if t.Task.ID == id { task = t break } } if task == nil { for _, t := range p.state.RunningRange() { if t.Task.ID == id { task = t break } } } return } func (p *TaskPool) GetTaskByAlias(alias string) (task *TaskRunner) { return p.state.GetByAlias(alias) } // nolint: gocyclo func (p *TaskPool) Run() { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() go p.handleQueue() go p.handleLogs() for { select { case task := <-p.register: // new task created by API or schedule db.StoreSession(p.store, "new task", func() { //p.Queue = append(p.Queue, task) msg := "Task " + task.Template.Name + " added to queue" task.Log(msg) log.WithFields(log.Fields{ "task_id": task.Task.ID, }).Info(msg) task.saveStatus() }) p.queueEvents <- PoolEvent{EventTypeNew, task} case <-ticker.C: // timer 5 seconds p.queueEvents <- PoolEvent{EventTypeEmpty, nil} } } } func getTaskName(t *TaskRunner) string { return t.Template.Name + " " + strconv.Itoa(t.Task.ID) } func (p *TaskPool) handleQueue() { for t := range p.queueEvents { // When a task is re-queued (e.g., no remote runner available), we should // clean up its "running" bookkeeping but avoid immediately retrying it in // the same queue pass to prevent hot retry loops. skipTaskID := 0 switch t.eventType { case EventTypeRequeued: // Task was started but moved back to waiting. It must not remain in // running/active sets and must release its claim so it can be picked // up again later. p.onTaskStop(t.task) // Avoid immediate retry in this same event handling iteration; it // will be retried on the next periodic tick or when another event // triggers queue processing. skipTaskID = t.task.Task.ID case EventTypeNew: p.state.Enqueue(t.task) case EventTypeFinished: p.onTaskStop(t.task) } if p.state.QueueLen() == 0 { continue } var i = 0 for i < p.state.QueueLen() { curr := p.state.QueueGet(i) if curr == nil { // item may no longer be local, move ahead i = i + 1 continue } // When handling a requeue event, don't immediately start the same task again. if skipTaskID != 0 && curr.Task.ID == skipTaskID { i = i + 1 continue } if curr.Task.Status == task_logger.TaskFailStatus { //delete failed TaskRunner from queue _ = p.state.DequeueAt(i) log.Info("Task " + getTaskName(curr) + " removed from queue") continue } if p.blocks(curr) { i = i + 1 continue } // ensure only one instance claims the task before dequeue if !p.state.TryClaim(curr.Task.ID) { i = i + 1 continue } _ = p.state.DequeueAt(i) runTask(curr, p) } } } func (p *TaskPool) handleLogs() { logTicker := time.NewTicker(TaskOutputInsertIntervalMs * time.Millisecond) defer logTicker.Stop() logs := make([]logRecord, 0) for { select { case record := <-p.logger: logs = append(logs, record) if len(logs) >= TaskOutputBatchSize { p.flushLogs(&logs) } case <-logTicker.C: p.flushLogs(&logs) } } } func (p *TaskPool) flushLogs(logs *[]logRecord) { if len(*logs) > 0 { p.writeLogs(*logs) *logs = (*logs)[:0] } } func (p *TaskPool) writeLogs(logs []logRecord) { taskOutput := make([]db.TaskOutput, 0) for _, record := range logs { newOutput := db.TaskOutput{ TaskID: record.task.Task.ID, Output: record.output, Time: record.time, } currentOutput := record.task.currentOutput record.task.currentOutput = &newOutput db.StoreSession(p.store, "logger", func() { newStage, newState, err := stage_parsers.MoveToNextStage( p.store, p.ansibleTaskRepo, p.logWriteService, record.task.Template.App, record.task.Task.ProjectID, record.task.currentState, record.task.currentStage, currentOutput, newOutput) if err != nil { log.Error(err) return } record.task.currentState = newState if newStage != nil { record.task.currentStage = newStage } if record.task.currentStage != nil { newOutput.StageID = &record.task.currentStage.ID } }) taskOutput = append(taskOutput, newOutput) } db.StoreSession(p.store, "logger", func() { err := p.store.InsertTaskOutputBatch(taskOutput) if err != nil { log.Error(err) return } }) } func runTask(task *TaskRunner, p *TaskPool) { log.Info("Set resource locker with TaskRunner " + getTaskName(task)) p.onTaskRun(task) log.Info("Task " + getTaskName(task) + " started") go func() { time.Sleep(1 * time.Second) task.run() }() } func (p *TaskPool) onTaskRun(t *TaskRunner) { p.state.AddActive(t.Task.ProjectID, t) p.state.SetRunning(t) if t.Alias != "" { p.state.SetAlias(t.Alias, t) } } func (p *TaskPool) onTaskStop(t *TaskRunner) { p.state.RemoveActive(t.Task.ProjectID, t.Task.ID) p.state.DeleteRunning(t.Task.ID) p.state.DeleteClaim(t.Task.ID) if t.Alias != "" { p.state.DeleteAlias(t.Alias) } } // hydrateTaskRunner builds a TaskRunner for an existing task from DB without starting it func (p *TaskPool) hydrateTaskRunner(taskID int, projectID int) (*TaskRunner, error) { task, err := p.store.GetTask(projectID, taskID) if err != nil { return nil, err } tr := NewTaskRunner(task, p, "", p.keyInstallationService) if err := tr.populateDetails(); err != nil { return nil, err } // load runtime fields from HA store (e.g., Redis) if p.state != nil { p.state.LoadRuntimeFields(tr) } // set appropriate job handler for consistency (not run) var job Job if util.Config.UseRemoteRunner || tr.Template.RunnerTag != nil || tr.Inventory.RunnerTag != nil { tag := tr.Template.RunnerTag if tag == nil { tag = tr.Inventory.RunnerTag } job = &RemoteJob{RunnerTag: tag, Task: tr.Task, taskPool: p} } else { app := db_lib.CreateApp(tr.Template, tr.Repository, tr.Inventory, tr) job = &LocalJob{ Task: tr.Task, Template: tr.Template, Inventory: tr.Inventory, Repository: tr.Repository, Environment: tr.Environment, Secret: "{}", Logger: app.SetLogger(tr), App: app, KeyInstaller: p.keyInstallationService, } } tr.job = job return tr, nil } func (p *TaskPool) blocks(t *TaskRunner) bool { if util.Config.MaxParallelTasks > 0 && p.state.RunningCount() >= util.Config.MaxParallelTasks { return true } if p.state.ActiveCount(t.Task.ProjectID) == 0 { return false } for _, r := range p.state.GetActive(t.Task.ProjectID) { if r.Task.Status.IsFinished() { continue } if r.Template.ID == t.Task.TemplateID && !r.Template.AllowParallelTasks { return true } } proj, err := p.store.GetProject(t.Task.ProjectID) if err != nil { log.Error(err) return false } res := proj.MaxParallelTasks > 0 && p.state.ActiveCount(t.Task.ProjectID) >= proj.MaxParallelTasks if res { return true } return res } func (p *TaskPool) ConfirmTask(targetTask db.Task) error { tsk := p.GetTask(targetTask.ID) if tsk == nil { // task not active, but exists in database return fmt.Errorf("task is not active") } tsk.SetStatus(task_logger.TaskConfirmed) return nil } func (p *TaskPool) RejectTask(targetTask db.Task) error { tsk := p.GetTask(targetTask.ID) if tsk == nil { // task not active, but exists in database return fmt.Errorf("task is not active") } tsk.SetStatus(task_logger.TaskRejected) return nil } func (p *TaskPool) StopTask(targetTask db.Task, forceStop bool) error { tsk := p.GetTask(targetTask.ID) if tsk == nil { // task not active, but exists in database tsk = NewTaskRunner(targetTask, p, "", p.keyInstallationService) err := tsk.populateDetails() if err != nil { return err } tsk.SetStatus(task_logger.TaskStoppedStatus) tsk.createTaskEvent() } else { status := tsk.Task.Status if forceStop { tsk.SetStatus(task_logger.TaskStoppedStatus) } else { tsk.SetStatus(task_logger.TaskStoppingStatus) } if status == task_logger.TaskRunningStatus { tsk.kill() } } return nil } // StopTasksByTemplate stops all active (queued or running) tasks that belong to // the specified project and template. If forceStop is true, tasks are marked as // stopped immediately and running tasks are killed; otherwise tasks are marked // as stopping and will gracefully transition to stopped. func (p *TaskPool) StopTasksByTemplate(projectID int, templateID int, forceStop bool) { // Handle queued tasks for _, t := range p.state.QueueRange() { if t == nil { continue } if t.Task.ProjectID != projectID || t.Task.TemplateID != templateID { continue } if t.Task.Status.IsFinished() { continue } if forceStop { t.SetStatus(task_logger.TaskStoppedStatus) } else { t.SetStatus(task_logger.TaskStoppingStatus) } // Queued tasks will be dequeued and immediately finalize to Stopped in run() } // Handle running tasks for _, t := range p.state.RunningRange() { if t == nil { continue } if t.Task.ProjectID != projectID || t.Task.TemplateID != templateID { continue } if t.Task.Status.IsFinished() { continue } prevStatus := t.Task.Status if forceStop { t.SetStatus(task_logger.TaskStoppedStatus) } else { t.SetStatus(task_logger.TaskStoppingStatus) } if prevStatus == task_logger.TaskRunningStatus { t.kill() } } // Update tasks in DB that are neither queued nor running but still active // (e.g., created but not present in this instance's memory state). if tasks, err := p.store.GetTemplateTasks(projectID, templateID, db.RetrieveQueryParams{ TaskFilter: &db.TaskFilter{ Status: task_logger.UnfinishedTaskStatuses(), }, }); err == nil { for _, twt := range tasks { // if task is managed locally (queued/running), it was handled above if p.GetTask(twt.Task.ID) != nil { continue } // mark non-local task as stopped and write event for history tr := NewTaskRunner(twt.Task, p, "", p.keyInstallationService) if err := tr.populateDetails(); err != nil { log.Error(err) continue } tr.SetStatus(task_logger.TaskStoppedStatus) tr.createTaskEvent() } } else { log.Error(err) } } // GetQueuedTasks returns a snapshot of tasks currently queued func (p *TaskPool) GetQueuedTasks() []*TaskRunner { return p.state.QueueRange() } func getNextBuildVersion(startVersion string, currentVersion string) string { re := regexp.MustCompile(`^(.*[^\d])?(\d+)([^\d].*)?$`) m := re.FindStringSubmatch(startVersion) if m == nil { return startVersion } var prefix, suffix, body string switch len(m) - 1 { case 3: prefix = m[1] body = m[2] suffix = m[3] case 2: if _, err := strconv.Atoi(m[1]); err == nil { body = m[1] suffix = m[2] } else { prefix = m[1] body = m[2] } case 1: body = m[1] default: return startVersion } if !strings.HasPrefix(currentVersion, prefix) || !strings.HasSuffix(currentVersion, suffix) { return startVersion } curr, err := strconv.Atoi(currentVersion[len(prefix) : len(currentVersion)-len(suffix)]) if err != nil { return startVersion } start, err := strconv.Atoi(body) if err != nil { panic(err) } var newVer int if start > curr { newVer = start } else { newVer = curr + 1 } return prefix + strconv.Itoa(newVer) + suffix } // AddTask creates and queues a new task for execution in the task pool. // // Parameters: // - taskObj: The task object with initial configuration // - userID: Optional ID of the user initiating the task // - username: Username of the user initiating the task // - projectID: ID of the project this task belongs to // - needAlias: Whether to generate a unique alias for the task // // The method: // - Sets initial task properties (created time, waiting status, etc.) // - Validates the task against its template // - For build templates, calculates the next version number // - Creates the task record in the database // - Sets up appropriate job handler (local or remote) // - Queues the task for execution // // Returns: // - The newly created task with all properties set // - An error if task creation or validation fails func (p *TaskPool) AddTask( taskObj db.Task, userID *int, username string, projectID int, needAlias bool, ) (newTask db.Task, err error) { taskObj.Created = tz.Now() taskObj.Status = task_logger.TaskWaitingStatus taskObj.UserID = userID taskObj.ProjectID = projectID extraSecretVars := taskObj.Secret taskObj.Secret = "{}" tpl, err := p.store.GetTemplate(projectID, taskObj.TemplateID) if err != nil { return } err = taskObj.ValidateNewTask(tpl) if err != nil { return } if tpl.Type == db.TemplateBuild { // get next version for TaskRunner if it is a Build var builds []db.TaskWithTpl builds, err = p.store.GetTemplateTasks(tpl.ProjectID, tpl.ID, db.RetrieveQueryParams{Count: 1}) if err != nil { return } if len(builds) == 0 || builds[0].Version == nil { taskObj.Version = tpl.StartVersion } else { v := getNextBuildVersion(*tpl.StartVersion, *builds[0].Version) taskObj.Version = &v } } newTask, err = p.store.CreateTask(taskObj, util.Config.MaxTasksPerTemplate) if err != nil { return } taskRunner := NewTaskRunner(newTask, p, username, p.keyInstallationService) if needAlias { // A unique, randomly-generated identifier that persists throughout the task's lifecycle. taskRunner.Alias = random.String(32) } err = taskRunner.populateDetails() if err != nil { taskRunner.Log("Error: " + err.Error()) taskRunner.SetStatus(task_logger.TaskFailStatus) return } var job Job if util.Config.UseRemoteRunner || taskRunner.Template.RunnerTag != nil || taskRunner.Inventory.RunnerTag != nil { tag := taskRunner.Template.RunnerTag if tag == nil { tag = taskRunner.Inventory.RunnerTag } job = &RemoteJob{ RunnerTag: tag, Task: taskRunner.Task, taskPool: p, } } else { app := db_lib.CreateApp( taskRunner.Template, taskRunner.Repository, taskRunner.Inventory, taskRunner) job = &LocalJob{ Task: taskRunner.Task, Template: taskRunner.Template, Inventory: taskRunner.Inventory, Repository: taskRunner.Repository, Environment: taskRunner.Environment, Secret: extraSecretVars, Logger: app.SetLogger(taskRunner), App: app, KeyInstaller: p.keyInstallationService, } } taskRunner.job = job p.register <- taskRunner taskRunner.createTaskEvent() return } ================================================ FILE: services/tasks/TaskPool_test.go ================================================ package tasks import ( "sync" "testing" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/db/bolt" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/util" "github.com/stretchr/testify/assert" ) type spyTaskStateStore struct { *MemoryTaskStateStore tryClaimCalls int } func newSpyTaskStateStore() *spyTaskStateStore { return &spyTaskStateStore{ MemoryTaskStateStore: NewMemoryTaskStateStore(), } } // TryClaim returns false to ensure tests don't actually start tasks; we only want to // observe whether the queue loop attempted to claim a task. func (s *spyTaskStateStore) TryClaim(_ int) bool { s.tryClaimCalls++ return false } func TestTaskPool_RequeuedEventCleansRunningStateAndSkipsImmediateRetry(t *testing.T) { // Ensure util.Config is non-nil for p.blocks() checks. prevCfg := util.Config t.Cleanup(func() { util.Config = prevCfg }) util.Config = &util.ConfigType{MaxParallelTasks: 0} store := bolt.CreateTestStore() proj, err := store.CreateProject(db.Project{}) assert.NoError(t, err) state := newSpyTaskStateStore() pool := TaskPool{ queueEvents: make(chan PoolEvent), state: state, store: store, } tr := &TaskRunner{ Task: db.Task{ ID: 42, ProjectID: proj.ID, TemplateID: 7, Status: task_logger.TaskWaitingStatus, }, Template: db.Template{ ID: 7, Name: "Test Template", }, Alias: "alias-42", } // Simulate a task that was marked as running and then re-queued (the state that // exists right before EventTypeRequeued is handled). state.SetRunning(tr) state.AddActive(tr.Task.ProjectID, tr) state.SetAlias(tr.Alias, tr) state.Enqueue(tr) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() pool.handleQueue() }() pool.queueEvents <- PoolEvent{EventTypeRequeued, tr} close(pool.queueEvents) wg.Wait() assert.Equal(t, 0, state.RunningCount(), "requeued task must be removed from running set") assert.Equal(t, 0, state.ActiveCount(tr.Task.ProjectID), "requeued task must be removed from active-by-project set") assert.Nil(t, state.GetByAlias(tr.Alias), "requeued task alias mapping must be cleared") assert.Equal(t, 1, state.QueueLen(), "requeued task must remain queued") assert.Equal(t, 0, state.tryClaimCalls, "requeued task should not be immediately retried in the same queue pass") } ================================================ FILE: services/tasks/TaskRunner.go ================================================ package tasks import ( "encoding/json" "errors" "os" "strconv" "strings" "sync" "github.com/semaphoreui/semaphore/db_lib" "github.com/semaphoreui/semaphore/pkg/tz" "github.com/semaphoreui/semaphore/pro_interfaces" "github.com/semaphoreui/semaphore/services/tasks/hooks" "github.com/semaphoreui/semaphore/api/sockets" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" ) type Job interface { Run(username string, incomingVersion *string, alias string) error Kill() IsKilled() bool } type TaskRunner struct { Task db.Task Template db.Template Inventory db.Inventory Repository db.Repository Environment db.Environment currentStage *db.TaskStage currentOutput *db.TaskOutput currentState any users []int alert bool alertChat *string pool *TaskPool keyInstaller db_lib.AccessKeyInstaller // job executes Ansible and returns stdout to Semaphore logs job Job RunnerID int Username string IncomingVersion *string statusListeners []task_logger.StatusListener logListeners []task_logger.LogListener // Alias uses if task require an alias for run. // For example, terraform task require an alias for run. Alias string logWG sync.WaitGroup } func NewTaskRunner( newTask db.Task, p *TaskPool, username string, keyInstaller db_lib.AccessKeyInstaller, ) *TaskRunner { return &TaskRunner{ Task: newTask, pool: p, Username: username, keyInstaller: keyInstaller, } } func (t *TaskRunner) AddStatusListener(l task_logger.StatusListener) { t.statusListeners = append(t.statusListeners, l) } func (t *TaskRunner) AddLogListener(l task_logger.LogListener) { t.logListeners = append(t.logListeners, l) } func (t *TaskRunner) saveStatus() { for _, user := range t.users { b, err := json.Marshal(&map[string]any{ "type": "update", "start": t.Task.Start, "end": t.Task.End, "status": t.Task.Status, "task_id": t.Task.ID, "template_id": t.Task.TemplateID, "project_id": t.Task.ProjectID, "version": t.Task.Version, }) util.LogPanic(err) sockets.Message(user, b) } if err := t.pool.store.UpdateTask(t.Task); err != nil { t.panicOnError(err, "Failed to update TaskRunner status") } // persist runtime fields in HA store if t.pool != nil && t.pool.state != nil { t.pool.state.UpdateRuntimeFields(t) } } func (t *TaskRunner) kill() { t.job.Kill() } func (t *TaskRunner) createTaskEvent() { desc := "Task ID " + strconv.Itoa(t.Task.ID) + " (" + t.Template.Name + ")" if t.Task.Status.IsFinished() { desc += " finished with status " + strings.ToUpper(string(t.Task.Status)) hook := hooks.GetHook(t.Template.App) if hook != nil { go hook.End(t.pool.store, t.Task.ProjectID, t.Task.ID) } } else { desc += " " + strings.ToUpper(string(t.Task.Status)) } objType := db.EventTask event := db.Event{ UserID: t.Task.UserID, ProjectID: &t.Task.ProjectID, ObjectType: &objType, ObjectID: &t.Task.ID, Description: &desc, } var runnerID *int if t.RunnerID > 0 { runnerID = &t.RunnerID } if err := t.pool.logWriteService.WriteTaskLog(pro_interfaces.TaskLogRecord{ ProjectID: t.Task.ProjectID, TemplateID: t.Template.ID, TemplateName: t.Template.Name, TaskID: t.Task.ID, UserID: t.Task.UserID, Description: &desc, Username: t.Username, RunnerID: runnerID, Status: t.Task.Status, }); err != nil { log.Error(err) } _, err := t.pool.store.CreateEvent(event) if err != nil { msg := "Fatal error inserting an event" t.Log(msg) log.WithError(err).Error(msg) } } func (t *TaskRunner) run() { if !t.pool.store.PermanentConnection() { t.pool.store.Connect("run task " + strconv.Itoa(t.Task.ID)) defer t.pool.store.Close("run task " + strconv.Itoa(t.Task.ID)) } // requeued indicates task should go back to waiting state (e.g., all runners busy) requeued := false defer func() { if requeued { // Task is being re-queued, don't mark as finished log.Info("Task " + strconv.Itoa(t.Task.ID) + " re-queued (waiting for available runner)") t.pool.queueEvents <- PoolEvent{EventTypeRequeued, t} return } log.WithFields(log.Fields{ "task_id": t.Task.ID, }).Info("Stopped running task " + t.Template.Name) //log.Info("Release resource locker with " + strconv.Itoa(t.Task.ID)) now := tz.Now() t.Task.End = &now t.saveStatus() t.createTaskEvent() t.pool.queueEvents <- PoolEvent{EventTypeFinished, t} }() // Mark task as stopped if user stopped task during preparation (before task run). if t.Task.Status == task_logger.TaskStoppingStatus { t.SetStatus(task_logger.TaskStoppedStatus) return } t.SetStatus(task_logger.TaskStartingStatus) t.createTaskEvent() t.Log("Started: " + strconv.Itoa(t.Task.ID)) t.Log("Run TaskRunner with template: " + t.Template.Name + "\n") var err error var username string var incomingVersion *string if t.Task.UserID != nil { var user db.User user, err = t.pool.store.GetUser(*t.Task.UserID) if err == nil { username = user.Username } } if t.Template.Type != db.TemplateTask { incomingVersion = t.Task.GetIncomingVersion(t.pool.store) } err = t.job.Run(username, incomingVersion, t.Alias) if err != nil { if errors.Is(err, ErrAllRunnersBusy) { // No runners available right now, put task back in waiting state t.SetStatus(task_logger.TaskWaitingStatus) t.pool.state.Enqueue(t) requeued = true return } if t.job.IsKilled() { t.SetStatus(task_logger.TaskStoppedStatus) } else { log.WithError(err).WithFields(log.Fields{ "task_id": t.Task.ID, "context": "task_runner", "task_status": t.Task.Status, }).Warn("Failed to run task") t.Log("Failed to run task: " + err.Error()) t.SetStatus(task_logger.TaskFailStatus) } return } if t.Task.Status == task_logger.TaskRunningStatus { t.SetStatus(task_logger.TaskSuccessStatus) } tpls, err := t.pool.store.GetTemplates(t.Task.ProjectID, db.TemplateFilter{ BuildTemplateID: &t.Task.TemplateID, AutorunOnly: true, }, db.RetrieveQueryParams{}) if err != nil { t.Log("Running app failed: " + err.Error()) return } for _, tpl := range tpls { task := db.Task{ TemplateID: tpl.ID, ProjectID: tpl.ProjectID, BuildTaskID: &t.Task.ID, } _, err = t.pool.AddTask( task, nil, "", tpl.ProjectID, tpl.App.NeedTaskAlias(), ) if err != nil { t.Log("Running app failed: " + err.Error()) continue } } } func (t *TaskRunner) prepareError(err error, errMsg string) error { if errors.Is(err, db.ErrNotFound) { t.Log(errMsg) return err } if err != nil { t.SetStatus(task_logger.TaskFailStatus) panic(err) } return nil } func (t *TaskRunner) populateTaskEnvironment() (err error) { if t.Task.Environment == "" { return } tplEnvironment := make(map[string]any) err = json.Unmarshal([]byte(t.Environment.JSON), &tplEnvironment) if err != nil { return } taskEnvironment := make(map[string]any) err = json.Unmarshal([]byte(t.Task.Environment), &taskEnvironment) if err != nil { return } for k, v := range taskEnvironment { tplEnvironment[k] = v } var ev []byte ev, err = json.Marshal(tplEnvironment) if err != nil { return err } t.Environment.JSON = string(ev) return } // nolint: gocyclo func (t *TaskRunner) populateDetails() error { // get template var err error t.Template, err = t.pool.store.GetTemplate(t.Task.ProjectID, t.Task.TemplateID) if err != nil { return t.prepareError(err, "Template not found!") } // get project alert setting project, err := t.pool.store.GetProject(t.Template.ProjectID) if err != nil { return t.prepareError(err, "Project not found!") } t.alert = project.Alert t.alertChat = project.AlertChat // get project users projectUsers, err := t.pool.store.GetProjectUsers(t.Template.ProjectID, db.RetrieveQueryParams{}) if err != nil { return t.prepareError(err, "Users not found!") } users := make(map[int]bool) for _, user := range projectUsers { users[user.ID] = true } admins, err := t.pool.store.GetAllAdmins() if err != nil { return err } for _, admin := range admins { users[admin.ID] = true } t.users = []int{} for userID := range users { t.users = append(t.users, userID) } // get inventory canOverrideInventory, err := t.Template.CanOverrideInventory() if err != nil { return err } if canOverrideInventory && t.Task.InventoryID != nil { t.Inventory, err = t.pool.inventoryService.GetInventory(t.Template.ProjectID, *t.Task.InventoryID) if err != nil { if t.Template.InventoryID != nil { t.Inventory, err = t.pool.inventoryService.GetInventory(t.Template.ProjectID, *t.Template.InventoryID) if err != nil { return t.prepareError(err, "Template Inventory not found!") } } } } else { if t.Template.InventoryID != nil { t.Inventory, err = t.pool.inventoryService.GetInventory(t.Template.ProjectID, *t.Template.InventoryID) if err != nil { return t.prepareError(err, "Template Inventory not found!") } } } // get repository t.Repository, err = t.pool.store.GetRepository(t.Template.ProjectID, t.Template.RepositoryID) if err != nil { return err } if err = t.pool.encryptionService.DeserializeSecret(&t.Repository.SSHKey); err != nil { return err } // get environment if t.Template.EnvironmentID != nil { t.Environment, err = t.pool.store.GetEnvironment(t.Template.ProjectID, *t.Template.EnvironmentID) if err != nil { return err } err = t.pool.encryptionService.FillEnvironmentSecrets(&t.Environment, true) if err != nil { return err } } err = t.populateTaskEnvironment() return err } // checkTmpDir checks to see if the temporary directory exists // and if it does not attempts to create it func checkTmpDir(path string) error { var err error if _, err = os.Stat(path); err != nil { if os.IsNotExist(err) { return os.MkdirAll(path, 0755) } } return err } ================================================ FILE: services/tasks/TaskRunner_logging.go ================================================ package tasks import ( "bufio" "encoding/json" "fmt" "io" "os/exec" "time" "github.com/semaphoreui/semaphore/pkg/tz" "github.com/semaphoreui/semaphore/api/sockets" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/util" log "github.com/sirupsen/logrus" ) func (t *TaskRunner) Log(msg string) { t.LogWithTime(tz.Now(), msg) } func (t *TaskRunner) Logf(format string, a ...any) { t.LogfWithTime(tz.Now(), format, a...) } func (t *TaskRunner) LogWithTime(now time.Time, msg string) { t.sendToWs(now, msg) t.pool.logger <- logRecord{ task: t, output: msg, time: now, } for _, l := range t.logListeners { l(now, msg) } } func (t *TaskRunner) sendToWs(now time.Time, msg string) { for _, user := range t.users { b, err := json.Marshal(&map[string]any{ "type": "log", "output": msg, "time": now, "task_id": t.Task.ID, "project_id": t.Task.ProjectID, }) util.LogPanic(err) sockets.Message(user, b) } } func (t *TaskRunner) LogfWithTime(now time.Time, format string, a ...any) { t.LogWithTime(now, fmt.Sprintf(format, a...)) } func (t *TaskRunner) LogCmd(cmd *exec.Cmd) { stderr, _ := cmd.StderrPipe() stdout, _ := cmd.StdoutPipe() go t.logPipe(stderr) go t.logPipe(stdout) } func (t *TaskRunner) WaitLog() { t.logWG.Wait() } func (t *TaskRunner) SetCommit(hash, message string) { t.Task.CommitHash = &hash t.Task.CommitMessage = message if err := t.pool.store.UpdateTask(t.Task); err != nil { t.panicOnError(err, "Failed to update task commit") } } func (t *TaskRunner) SetStatus(status task_logger.TaskStatus) { if status == t.Task.Status { return } switch t.Task.Status { // check old status case task_logger.TaskConfirmed: if status == task_logger.TaskWaitingConfirmation { return } case task_logger.TaskRunningStatus: if status == task_logger.TaskWaitingStatus { return } case task_logger.TaskStoppingStatus: if status == task_logger.TaskWaitingStatus || status == task_logger.TaskRunningStatus { //panic("stopping TaskRunner cannot be " + status) return } case task_logger.TaskSuccessStatus: case task_logger.TaskFailStatus: case task_logger.TaskStoppedStatus: return } t.Task.Status = status if status == task_logger.TaskRunningStatus { now := tz.Now() t.Task.Start = &now } t.saveStatus() if localJob, ok := t.job.(*LocalJob); ok { localJob.SetStatus(status) } if status == task_logger.TaskFailStatus { t.sendMailAlert() } if status.IsNotifiable() { t.sendTelegramAlert() t.sendSlackAlert() t.sendRocketChatAlert() t.sendMicrosoftTeamsAlert() t.sendDingTalkAlert() t.sendGotifyAlert() } for _, l := range t.statusListeners { l(status) } } func (t *TaskRunner) panicOnError(err error, msg string) { if err == nil { return } t.Log(msg) util.LogPanicF(err, log.Fields{"error": msg}) } func (t *TaskRunner) logPipe(reader io.Reader) { t.logWG.Add(1) linesCh := make(chan string, 100000) go func() { defer t.logWG.Done() for line := range linesCh { t.Log(line) } }() scanner := bufio.NewScanner(reader) const maxCapacity = 10 * 1024 * 1024 // 10 MB buf := make([]byte, maxCapacity) scanner.Buffer(buf, maxCapacity) for scanner.Scan() { line := scanner.Text() linesCh <- line } close(linesCh) err := scanner.Err() if err != nil { msg := "Failed to read TaskRunner output" switch err.Error() { case "EOF", "os: process already finished", "read |0: file already closed": return // it is ok case "bufio.Scanner: token too long": msg = "TaskRunner output exceeds the maximum allowed size of 10MB" } t.kill() // kill the job because stdout cannot be read. log.WithError(err).WithFields(log.Fields{ "task_id": t.Task.ID, "context": "task_logger", }).Error(msg) t.Log("Fatal error: " + msg) } } ================================================ FILE: services/tasks/TaskRunner_test.go ================================================ package tasks import ( "math/rand" "os" "path" "strings" "testing" "github.com/semaphoreui/semaphore/pkg/ssh" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/pro_interfaces" "github.com/stretchr/testify/assert" "github.com/semaphoreui/semaphore/db_lib" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/db/bolt" "github.com/semaphoreui/semaphore/util" ) type KeyInstallerMock struct { } func (s *KeyInstallerMock) Install(key db.AccessKey, usage db.AccessKeyRole, logger task_logger.Logger) (installation ssh.AccessKeyInstallation, err error) { return ssh.AccessKeyInstallation{}, nil } type InventoryServiceMock struct { } func (s *InventoryServiceMock) GetInventory(projectID int, inventoryID int) (inventory db.Inventory, err error) { return db.Inventory{}, nil } type EncryptionServiceMock struct { } func (s *EncryptionServiceMock) DeleteSecret(key *db.AccessKey) error { return nil } func (s *EncryptionServiceMock) SerializeSecret(key *db.AccessKey) error { return nil } func (s *EncryptionServiceMock) DeserializeSecret(key *db.AccessKey) error { return nil } func (s *EncryptionServiceMock) FillEnvironmentSecrets(env *db.Environment, deserializeSecret bool) error { return nil } type mockLogWriteService struct { } func (l *mockLogWriteService) WriteEventLog(event pro_interfaces.EventLogRecord) error { return nil } func (l *mockLogWriteService) WriteTaskLog(task pro_interfaces.TaskLogRecord) error { return nil } func (l *mockLogWriteService) WriteResult(task any) error { return nil } func TestTaskRunnerRun(t *testing.T) { store := bolt.CreateTestStore() keyInstaller := &KeyInstallerMock{} pool := CreateTaskPool( store, &MemoryTaskStateStore{}, nil, &InventoryServiceMock{}, nil, keyInstaller, &mockLogWriteService{}, ) go pool.Run() var task db.Task var err error db.StoreSession(store, "", func() { task, err = store.CreateTask(db.Task{}, 0) }) if err != nil { t.Fatal(err) } taskRunner := TaskRunner{ Task: task, pool: &pool, keyInstaller: keyInstaller, } taskRunner.job = &LocalJob{ Task: taskRunner.Task, Template: taskRunner.Template, Inventory: taskRunner.Inventory, Repository: taskRunner.Repository, Environment: taskRunner.Environment, Logger: &taskRunner, KeyInstaller: keyInstaller, App: &db_lib.AnsibleApp{ Template: taskRunner.Template, Repository: taskRunner.Repository, Logger: &taskRunner, Playbook: &db_lib.AnsiblePlaybook{ Logger: &taskRunner, TemplateID: taskRunner.Template.ID, Repository: taskRunner.Repository, }, }, } taskRunner.run() } func TestGetRepoPath(t *testing.T) { util.Config = &util.ConfigType{ TmpPath: "/tmp", } inventoryID := 1 tsk := TaskRunner{ Task: db.Task{}, Inventory: db.Inventory{ SSHKeyID: &inventoryID, SSHKey: db.AccessKey{ ID: 12345, Type: db.AccessKeySSH, }, Type: db.InventoryStatic, }, Template: db.Template{ Playbook: "deploy/test.yml", }, } tsk.job = &LocalJob{ Task: tsk.Task, Template: tsk.Template, Inventory: tsk.Inventory, Repository: tsk.Repository, Environment: tsk.Environment, Logger: &tsk, App: &db_lib.AnsibleApp{ Template: tsk.Template, Repository: tsk.Repository, Logger: &tsk, Playbook: &db_lib.AnsiblePlaybook{ Logger: &tsk, TemplateID: tsk.Template.ID, Repository: tsk.Repository, }, }, } dir := tsk.job.(*LocalJob).App.(*db_lib.AnsibleApp).GetPlaybookDir() if dir != "/tmp/project_0/repository_0_template_0/deploy" { t.Fatal("Invalid playbook dir: " + dir) } } func TestGetRepoPath_whenStartsWithSlash(t *testing.T) { util.Config = &util.ConfigType{ TmpPath: "/tmp", } inventoryID := 1 tsk := TaskRunner{ Task: db.Task{}, Inventory: db.Inventory{ SSHKeyID: &inventoryID, SSHKey: db.AccessKey{ ID: 12345, Type: db.AccessKeySSH, }, Type: db.InventoryStatic, }, Template: db.Template{ Playbook: "/deploy/test.yml", }, } tsk.job = &LocalJob{ Task: tsk.Task, Template: tsk.Template, Inventory: tsk.Inventory, Repository: tsk.Repository, Environment: tsk.Environment, Logger: &tsk, App: &db_lib.AnsibleApp{ Template: tsk.Template, Repository: tsk.Repository, Logger: &tsk, Playbook: &db_lib.AnsiblePlaybook{ Logger: &tsk, TemplateID: tsk.Template.ID, Repository: tsk.Repository, }, }, } dir := tsk.job.(*LocalJob).App.(*db_lib.AnsibleApp).GetPlaybookDir() if dir != "/tmp/project_0/repository_0_template_0/deploy" { t.Fatal("Invalid playbook dir: " + dir) } } func TestPopulateDetails(t *testing.T) { store := bolt.CreateTestStore() proj, err := store.CreateProject(db.Project{}) if err != nil { t.Fatal(err) } key, err := store.CreateAccessKey(db.AccessKey{ ProjectID: &proj.ID, Type: db.AccessKeyNone, }) if err != nil { t.Fatal(err) } repo, err := store.CreateRepository(db.Repository{ ProjectID: proj.ID, SSHKeyID: key.ID, Name: "Test", GitURL: "git@example.com:test/test", GitBranch: "master", }) if err != nil { t.Fatal(err) } inv, err := store.CreateInventory(db.Inventory{ ProjectID: proj.ID, }) if err != nil { t.Fatal(err) } env, err := store.CreateEnvironment(db.Environment{ ProjectID: proj.ID, Name: "test", JSON: `{"author": "Denis", "comment": "Hello, World!"}`, }) if err != nil { t.Fatal(err) } tpl, err := store.CreateTemplate(db.Template{ Name: "Test", Playbook: "test.yml", ProjectID: proj.ID, RepositoryID: repo.ID, InventoryID: &inv.ID, EnvironmentID: &env.ID, }) if err != nil { t.Fatal(err) } pool := TaskPool{ store: store, inventoryService: &InventoryServiceMock{}, encryptionService: &EncryptionServiceMock{}, } tsk := TaskRunner{ pool: &pool, Task: db.Task{ TemplateID: tpl.ID, ProjectID: proj.ID, Environment: `{"comment": "Just do it!", "time": "2021-11-02"}`, }, } tsk.job = &LocalJob{ Task: tsk.Task, Template: tsk.Template, Inventory: tsk.Inventory, Repository: tsk.Repository, Environment: tsk.Environment, Logger: &tsk, App: &db_lib.AnsibleApp{ Template: tsk.Template, Repository: tsk.Repository, Logger: &tsk, Playbook: &db_lib.AnsiblePlaybook{ Logger: &tsk, TemplateID: tsk.Template.ID, Repository: tsk.Repository, }, }, } err = tsk.populateDetails() if err != nil { t.Fatal(err) } assert.Equal(t, `{"author":"Denis","comment":"Just do it!","time":"2021-11-02"}`, tsk.Environment.JSON) } func TestPopulateDetailsInventory(t *testing.T) { store := bolt.CreateTestStore() proj, err := store.CreateProject(db.Project{}) if err != nil { t.Fatal(err) } key, err := store.CreateAccessKey(db.AccessKey{ ProjectID: &proj.ID, Type: db.AccessKeyNone, }) if err != nil { t.Fatal(err) } repo, err := store.CreateRepository(db.Repository{ ProjectID: proj.ID, SSHKeyID: key.ID, Name: "Test", GitURL: "git@example.com:test/test", GitBranch: "master", }) if err != nil { t.Fatal(err) } inv, err := store.CreateInventory(db.Inventory{ ProjectID: proj.ID, ID: 1, }) if err != nil { t.Fatal(err) } inv2, err := store.CreateInventory(db.Inventory{ ProjectID: proj.ID, ID: 2, }) if err != nil { t.Fatal(err) } env, err := store.CreateEnvironment(db.Environment{ ProjectID: proj.ID, Name: "test", JSON: `{"author": "Denis", "comment": "Hello, World!"}`, }) if err != nil { t.Fatal(err) } tpl, err := store.CreateTemplate(db.Template{ Name: "Test", Playbook: "test.yml", ProjectID: proj.ID, RepositoryID: repo.ID, InventoryID: &inv.ID, EnvironmentID: &env.ID, TaskParams: map[string]any{ "allow_override_inventory": true, }, }) if err != nil { t.Fatal(err) } pool := TaskPool{ store: store, inventoryService: &InventoryServiceMock{}, encryptionService: &EncryptionServiceMock{}, } tsk := TaskRunner{ pool: &pool, Task: db.Task{ TemplateID: tpl.ID, ProjectID: proj.ID, Environment: `{"comment": "Just do it!", "time": "2021-11-02"}`, InventoryID: &inv2.ID, }, } tsk.job = &LocalJob{ Task: tsk.Task, Template: tsk.Template, Repository: tsk.Repository, Environment: tsk.Environment, Logger: &tsk, App: &db_lib.AnsibleApp{ Template: tsk.Template, Repository: tsk.Repository, Logger: &tsk, Playbook: &db_lib.AnsiblePlaybook{ Logger: &tsk, TemplateID: tsk.Template.ID, Repository: tsk.Repository, }, }, } err = tsk.populateDetails() if err != nil { t.Fatal(err) } //if tsk.Inventory.ID != 2 { // t.Fatal(err) //} } func TestPopulateDetailsInventory1(t *testing.T) { store := bolt.CreateTestStore() proj, err := store.CreateProject(db.Project{}) if err != nil { t.Fatal(err) } key, err := store.CreateAccessKey(db.AccessKey{ ProjectID: &proj.ID, Type: db.AccessKeyNone, }) if err != nil { t.Fatal(err) } repo, err := store.CreateRepository(db.Repository{ ProjectID: proj.ID, SSHKeyID: key.ID, Name: "Test", GitURL: "git@example.com:test/test", GitBranch: "master", }) if err != nil { t.Fatal(err) } inv, err := store.CreateInventory(db.Inventory{ ProjectID: proj.ID, ID: 1, }) if err != nil { t.Fatal(err) } env, err := store.CreateEnvironment(db.Environment{ ProjectID: proj.ID, Name: "test", JSON: `{"author": "Denis", "comment": "Hello, World!"}`, }) if err != nil { t.Fatal(err) } tpl, err := store.CreateTemplate(db.Template{ Name: "Test", Playbook: "test.yml", ProjectID: proj.ID, RepositoryID: repo.ID, InventoryID: &inv.ID, EnvironmentID: &env.ID, }) if err != nil { t.Fatal(err) } pool := TaskPool{ store: store, inventoryService: &InventoryServiceMock{}, encryptionService: &EncryptionServiceMock{}, } tsk := TaskRunner{ pool: &pool, Task: db.Task{ TemplateID: tpl.ID, ProjectID: proj.ID, Environment: `{"comment": "Just do it!", "time": "2021-11-02"}`, }, } tsk.job = &LocalJob{ Task: tsk.Task, Template: tsk.Template, Repository: tsk.Repository, Environment: tsk.Environment, Logger: &tsk, App: &db_lib.AnsibleApp{ Template: tsk.Template, Repository: tsk.Repository, Logger: &tsk, Playbook: &db_lib.AnsiblePlaybook{ Logger: &tsk, TemplateID: tsk.Template.ID, Repository: tsk.Repository, }, }, } err = tsk.populateDetails() if err != nil { t.Fatal(err) } //if tsk.Inventory.ID != 1 { // t.Fatal(err) //} } func TestTaskGetPlaybookArgs(t *testing.T) { util.Config = &util.ConfigType{ TmpPath: "/tmp", } inventoryID := 1 tsk := TaskRunner{ Task: db.Task{}, Inventory: db.Inventory{ SSHKeyID: &inventoryID, SSHKey: db.AccessKey{ ID: 12345, Type: db.AccessKeySSH, }, Type: db.InventoryStatic, }, Template: db.Template{ Playbook: "test.yml", }, } tsk.job = &LocalJob{ Task: tsk.Task, Template: tsk.Template, Inventory: tsk.Inventory, Repository: tsk.Repository, Environment: tsk.Environment, Logger: &tsk, App: &db_lib.AnsibleApp{ Template: tsk.Template, Repository: tsk.Repository, Logger: &tsk, Playbook: &db_lib.AnsiblePlaybook{ Logger: &tsk, TemplateID: tsk.Template.ID, Repository: tsk.Repository, }, }, } args, _, err := tsk.job.(*LocalJob).getPlaybookArgs("", nil) if err != nil { t.Fatal(err) } res := strings.Join(args, " ") if res != "-i /tmp/project_0/inventory_0 --extra-vars {\"semaphore_vars\":{\"task_details\":{\"commit_hash\":null,\"commit_message\":\"\",\"id\":0,\"inventory_id\":0,\"inventory_name\":\"\",\"repository_id\":0,\"repository_name\":\"\",\"url\":null,\"username\":\"\"}}} test.yml" { t.Fatal("incorrect result") } } func TestTaskGetPlaybookArgs2(t *testing.T) { util.Config = &util.ConfigType{ TmpPath: "/tmp", } inventoryID := 1 tsk := TaskRunner{ Task: db.Task{}, Inventory: db.Inventory{ Type: db.InventoryStatic, SSHKeyID: &inventoryID, SSHKey: db.AccessKey{ ID: 12345, Type: db.AccessKeyLoginPassword, LoginPassword: db.LoginPassword{ Password: "123456", Login: "root", }, }, }, Template: db.Template{ Playbook: "test.yml", }, } tsk.job = &LocalJob{ Task: tsk.Task, Template: tsk.Template, Inventory: tsk.Inventory, Repository: tsk.Repository, Environment: tsk.Environment, Logger: &tsk, App: &db_lib.AnsibleApp{ Template: tsk.Template, Repository: tsk.Repository, Logger: &tsk, Playbook: &db_lib.AnsiblePlaybook{ Logger: &tsk, TemplateID: tsk.Template.ID, Repository: tsk.Repository, }, }, } args, _, err := tsk.job.(*LocalJob).getPlaybookArgs("", nil) if err != nil { t.Fatal(err) } res := strings.Join(args, " ") if res != "-i /tmp/project_0/inventory_0 --extra-vars {\"semaphore_vars\":{\"task_details\":{\"commit_hash\":null,\"commit_message\":\"\",\"id\":0,\"inventory_id\":0,\"inventory_name\":\"\",\"repository_id\":0,\"repository_name\":\"\",\"url\":null,\"username\":\"\"}}} test.yml" { t.Fatal("incorrect result") } } func TestTaskGetPlaybookArgs3(t *testing.T) { util.Config = &util.ConfigType{ TmpPath: "/tmp", } inventoryID := 1 tsk := TaskRunner{ Task: db.Task{}, Inventory: db.Inventory{ Type: db.InventoryStatic, BecomeKeyID: &inventoryID, BecomeKey: db.AccessKey{ ID: 12345, Type: db.AccessKeyLoginPassword, LoginPassword: db.LoginPassword{ Password: "123456", Login: "root", }, }, }, Template: db.Template{ Playbook: "test.yml", }, } tsk.job = &LocalJob{ Task: tsk.Task, Template: tsk.Template, Inventory: tsk.Inventory, Repository: tsk.Repository, Environment: tsk.Environment, Logger: &tsk, App: &db_lib.AnsibleApp{ Template: tsk.Template, Repository: tsk.Repository, Logger: &tsk, Playbook: &db_lib.AnsiblePlaybook{ Logger: &tsk, TemplateID: tsk.Template.ID, Repository: tsk.Repository, }, }, } args, _, err := tsk.job.(*LocalJob).getPlaybookArgs("", nil) if err != nil { t.Fatal(err) } res := strings.Join(args, " ") if res != "-i /tmp/project_0/inventory_0 --extra-vars {\"semaphore_vars\":{\"task_details\":{\"commit_hash\":null,\"commit_message\":\"\",\"id\":0,\"inventory_id\":0,\"inventory_name\":\"\",\"repository_id\":0,\"repository_name\":\"\",\"url\":null,\"username\":\"\"}}} test.yml" { t.Fatal("incorrect result") } } func TestCheckTmpDir(t *testing.T) { //It should be able to create a random dir in /tmp dirName := path.Join(os.TempDir(), util.RandString(rand.Intn(10-4)+4)) err := checkTmpDir(dirName) if err != nil { t.Fatal(err) } //checking again for this directory should return no error, as it exists err = checkTmpDir(dirName) if err != nil { t.Fatal(err) } err = os.Chmod(dirName, os.FileMode(int(0550))) if err != nil { t.Fatal(err) } //nolint: vetshadow if stat, err := os.Stat(dirName); err != nil { t.Fatal(err) } else if stat.Mode() != os.FileMode(int(0550)) { // File System is not support 0550 mode, skip this test return } err = checkTmpDir(dirName + "/noway") if err == nil { t.Fatal("You should not be able to write in this folder, causing an error") } err = os.Remove(dirName) if err != nil { t.Log(err) } } func TestTaskRunner_populateTaskEnvironment(t *testing.T) { tsk := TaskRunner{ Task: db.Task{ Environment: "{\"a\":11, \"b\": 22, \"c\": 33}", }, Environment: db.Environment{ JSON: "{\"a\":1, \"d\": 4}", }, } err := tsk.populateTaskEnvironment() if err != nil { t.Fatal(err) } assert.Equal(t, tsk.Environment.JSON, "{\"a\":11,\"b\":22,\"c\":33,\"d\":4}") } ================================================ FILE: services/tasks/alert.go ================================================ package tasks import ( "bytes" "embed" "fmt" "net/http" "strconv" "text/template" "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/task_logger" "github.com/semaphoreui/semaphore/util" "github.com/semaphoreui/semaphore/util/mailer" ) //go:embed templates/*.tmpl var templates embed.FS // Alert represents an alert that will be templated and sent to the appropriate service type Alert struct { Name string Author string Color string Task alertTask Chat alertChat } type alertTask struct { ID string URL string Result string Desc string Version string } type alertChat struct { ID string } func (t *TaskRunner) sendMailAlert() { if !util.Config.EmailAlert || !t.alert { return } body := bytes.NewBufferString("") author, version := t.alertInfos() alert := Alert{ Name: t.Template.Name, Author: author, Color: t.alertColor("email"), Task: alertTask{ ID: strconv.Itoa(t.Task.ID), URL: t.taskLink(), Result: t.Task.Status.Format(), Version: version, Desc: t.Task.Message, }, } tpl, err := template.ParseFS(templates, "templates/email.tmpl") if err != nil { t.Log("Can't parse email alert template!") panic(err) } if err := tpl.Execute(body, alert); err != nil { t.Log("Can't generate email alert template!") panic(err) } if body.Len() == 0 { t.Log("Buffer for email alert is empty") return } for _, uid := range t.users { user, err := t.pool.store.GetUser(uid) if err != nil { util.LogError(err) continue } if !user.Alert { continue } t.Logf("Attempting to send email alert to %s", user.Email) if err := mailer.Send( util.Config.EmailSecure, util.Config.EmailTls, util.Config.EmailHost, util.Config.EmailPort, util.Config.EmailUsername, util.Config.EmailPassword, util.Config.EmailSender, user.Email, fmt.Sprintf("Task '%s' failed", t.Template.Name), body.String(), ); err != nil { util.LogError(err) continue } t.Logf("Sent successfully email alert to %s", user.Email) } } func (t *TaskRunner) sendTelegramAlert() { if !util.Config.TelegramAlert || !t.alert { return } if t.Template.SuppressSuccessAlerts && t.Task.Status == task_logger.TaskSuccessStatus { return } chatID := util.Config.TelegramChat if t.alertChat != nil && *t.alertChat != "" { chatID = *t.alertChat } if chatID == "" { return } body := bytes.NewBufferString("") author, version := t.alertInfos() alert := Alert{ Name: t.Template.Name, Author: author, Color: t.alertColor("telegram"), Task: alertTask{ ID: strconv.Itoa(t.Task.ID), URL: t.taskLink(), Result: t.Task.Status.Format(), Version: version, Desc: t.Task.Message, }, Chat: alertChat{ ID: chatID, }, } tpl, err := template.ParseFS(templates, "templates/telegram.tmpl") if err != nil { t.Log("Can't parse telegram alert template!") panic(err) } if err := tpl.Execute(body, alert); err != nil { t.Log("Can't generate telegram alert template!") panic(err) } if body.Len() == 0 { t.Log("Buffer for telegram alert is empty") return } t.Log("Attempting to send telegram alert") resp, err := http.Post( fmt.Sprintf( "https://api.telegram.org/bot%s/sendMessage", util.Config.TelegramToken, ), "application/json", body, ) if err != nil { t.Log("Can't send telegram alert! Error: " + err.Error()) } else if resp.StatusCode != 200 { t.Log("Can't send telegram alert! Response code: " + strconv.Itoa(resp.StatusCode)) } else { t.Log("Sent successfully telegram alert") } if resp != nil { defer resp.Body.Close() //nolint:errcheck } } func (t *TaskRunner) sendSlackAlert() { if !util.Config.SlackAlert || !t.alert { return } if t.Template.SuppressSuccessAlerts && t.Task.Status == task_logger.TaskSuccessStatus { return } body := bytes.NewBufferString("") author, version := t.alertInfos() alert := Alert{ Name: t.Template.Name, Author: author, Color: t.alertColor("slack"), Task: alertTask{ ID: strconv.Itoa(t.Task.ID), URL: t.taskLink(), Result: t.Task.Status.Format(), Version: version, Desc: t.Task.Message, }, } tpl, err := template.ParseFS(templates, "templates/slack.tmpl") if err != nil { t.Log("Can't parse slack alert template!") panic(err) } if err := tpl.Execute(body, alert); err != nil { t.Log("Can't generate slack alert template!") panic(err) } if body.Len() == 0 { t.Log("Buffer for slack alert is empty") return } t.Log("Attempting to send slack alert") resp, err := http.Post( util.Config.SlackUrl, "application/json", body, ) if err != nil { t.Log("Can't send slack alert! Error: " + err.Error()) } else if resp.StatusCode != 200 { t.Log("Can't send slack alert! Response code: " + strconv.Itoa(resp.StatusCode)) } else { t.Log("Sent successfully slack alert") } if resp != nil { defer resp.Body.Close() //nolint:errcheck } } func (t *TaskRunner) sendRocketChatAlert() { if !util.Config.RocketChatAlert || !t.alert { return } if t.Template.SuppressSuccessAlerts && t.Task.Status == task_logger.TaskSuccessStatus { return } body := bytes.NewBufferString("") author, version := t.alertInfos() alert := Alert{ Name: t.Template.Name, Author: author, Color: t.alertColor("rocketchat"), Task: alertTask{ ID: strconv.Itoa(t.Task.ID), URL: t.taskLink(), Result: t.Task.Status.Format(), Version: version, Desc: t.Task.Message, }, } tpl, err := template.ParseFS(templates, "templates/rocketchat.tmpl") if err != nil { t.Log("Can't parse rocketchat alert template!") panic(err) } if err := tpl.Execute(body, alert); err != nil { t.Log("Can't generate rocketchat alert template!") panic(err) } if body.Len() == 0 { t.Log("Buffer for rocketchat alert is empty") return } t.Log("Attempting to send rocketchat alert") resp, err := http.Post( util.Config.RocketChatUrl, "application/json", body, ) if err != nil { t.Log("Can't send rocketchat alert! Error: " + err.Error()) } else if resp.StatusCode != 200 { t.Log("Can't send rocketchat alert! Response code: " + strconv.Itoa(resp.StatusCode)) } else { t.Log("Sent successfully rocketchat alert") } if resp != nil { defer resp.Body.Close() //nolint:errcheck } } func (t *TaskRunner) sendMicrosoftTeamsAlert() { if !util.Config.MicrosoftTeamsAlert || !t.alert { return } if t.Template.SuppressSuccessAlerts && t.Task.Status == task_logger.TaskSuccessStatus { return } body := bytes.NewBufferString("") author, version := t.alertInfos() alert := Alert{ Name: t.Template.Name, Author: author, Color: t.alertColor("microsoft-teams"), Task: alertTask{ ID: strconv.Itoa(t.Task.ID), URL: t.taskLink(), Result: t.Task.Status.Format(), Version: version, Desc: t.Task.Message, }, } tpl, err := template.ParseFS(templates, "templates/microsoft-teams.tmpl") if err != nil { t.Log("Can't parse microsoft teams alert template!") panic(err) } if err := tpl.Execute(body, alert); err != nil { t.Log("Can't generate microsoft teams alert template!") panic(err) } if body.Len() == 0 { t.Log("Buffer for microsoft teams alert is empty") return } t.Log("Attempting to send microsoft teams alert") resp, err := http.Post( util.Config.MicrosoftTeamsUrl, "application/json", body, ) if err != nil { t.Log("Can't send microsoft teams alert! Error: " + err.Error()) } else if resp.StatusCode != 200 && resp.StatusCode != 202 { t.Log("Can't send microsoft teams alert! Response code: " + strconv.Itoa(resp.StatusCode)) } else { t.Log("Sent successfully microsoft teams alert") } if resp != nil { defer resp.Body.Close() //nolint:errcheck } } func (t *TaskRunner) sendDingTalkAlert() { if !util.Config.DingTalkAlert || !t.alert { return } if t.Template.SuppressSuccessAlerts && t.Task.Status == task_logger.TaskSuccessStatus { return } body := bytes.NewBufferString("") author, version := t.alertInfos() alert := Alert{ Name: t.Template.Name, Author: author, Color: t.alertColor("dingtalk"), Task: alertTask{ ID: strconv.Itoa(t.Task.ID), URL: t.taskLink(), Result: t.Task.Status.Format(), Version: version, Desc: t.Task.Message, }, } tpl, err := template.ParseFS(templates, "templates/dingtalk.tmpl") if err != nil { t.Log("Can't parse dingtalk alert template!") panic(err) } if err := tpl.Execute(body, alert); err != nil { t.Log("Can't generate dingtalk alert template!") panic(err) } if body.Len() == 0 { t.Log("Buffer for dingtalk alert is empty") return } t.Log("Attempting to send dingtalk alert") resp, err := http.Post( util.Config.DingTalkUrl, "application/json", body, ) if err != nil { t.Log("Can't send dingtalk alert! Error: " + err.Error()) } else if resp.StatusCode != 200 { t.Log("Can't send dingtalk alert! Response code: " + strconv.Itoa(resp.StatusCode)) } else { t.Log("Sent successfully dingtalk alert") } if resp != nil { defer resp.Body.Close() //nolint:errcheck } } func (t *TaskRunner) sendGotifyAlert() { if !util.Config.GotifyAlert || !t.alert { return } if t.Template.SuppressSuccessAlerts && t.Task.Status == task_logger.TaskSuccessStatus { return } body := bytes.NewBufferString("") author, version := t.alertInfos() alert := Alert{ Name: t.Template.Name, Author: author, Color: t.alertColor("gotify"), Task: alertTask{ ID: strconv.Itoa(t.Task.ID), URL: t.taskLink(), Result: t.Task.Status.Format(), Version: version, Desc: t.Task.Message, }, } tpl, err := template.ParseFS(templates, "templates/gotify.tmpl") if err != nil { t.Log("Can't parse gotify alert template!") panic(err) } if err := tpl.Execute(body, alert); err != nil { t.Log("Can't generate gotify alert template!") panic(err) } if body.Len() == 0 { t.Log("Buffer for gotify alert is empty") return } t.Log("Attempting to send gotify alert") resp, err := http.Post( fmt.Sprintf( "%s/message?token=%s", util.Config.GotifyUrl, util.Config.GotifyToken), "application/json", body, ) if err != nil { t.Log("Can't send gotify alert! Error: " + err.Error()) } else if resp.StatusCode != 200 { t.Log("Can't send gotify alert! Response code: " + strconv.Itoa(resp.StatusCode)) } else { t.Log("Sent successfully gotify alert") } if resp != nil { defer resp.Body.Close() //nolint:errcheck } } func (t *TaskRunner) alertInfos() (string, string) { version := "" if t.Task.Version != nil { version = *t.Task.Version } else if t.Template.Type != db.TemplateTask { v := t.Task.GetIncomingVersion(t.pool.store) if v != nil { version = "build " + *v } else { version = "" } } else { version = "" } author := "—" if t.Task.UserID != nil { user, err := t.pool.store.GetUser(*t.Task.UserID) if err != nil { panic(err) } author = user.Name } return author, version } func (t *TaskRunner) alertColor(kind string) string { switch kind { case "slack": switch t.Task.Status { case task_logger.TaskSuccessStatus: return "good" case task_logger.TaskFailStatus: return "danger" case task_logger.TaskRunningStatus: return "#333CFF" case task_logger.TaskWaitingStatus: return "#FFFC33" case task_logger.TaskStoppingStatus: return "#BEBEBE" case task_logger.TaskStoppedStatus: return "#5B5B5B" } case "rocketchat": switch t.Task.Status { case task_logger.TaskSuccessStatus: return "#00EE00" case task_logger.TaskFailStatus: return "#EE0000" case task_logger.TaskRunningStatus: return "#333CFF" case task_logger.TaskWaitingStatus: return "#FFFC33" case task_logger.TaskStoppingStatus: return "#BEBEBE" case task_logger.TaskStoppedStatus: return "#5B5B5B" } } return "" } func (t *TaskRunner) taskLink() string { return fmt.Sprintf( "%s/project/%d/templates/%d?t=%d", util.Config.WebHost, t.Template.ProjectID, t.Template.ID, t.Task.ID, ) } ================================================ FILE: services/tasks/alert_test_sender.go ================================================ package tasks import ( "github.com/semaphoreui/semaphore/db" "github.com/semaphoreui/semaphore/pkg/task_logger" ) // SendProjectTestAlerts sends test alerts to all enabled notifiers for the given project. func SendProjectTestAlerts(project db.Project, store db.Store) (err error) { projectUsers, err := store.GetProjectUsers(project.ID, db.RetrieveQueryParams{}) if err != nil { return } var userIDs []int for _, u := range projectUsers { userIDs = append(userIDs, u.ID) } tr := &TaskRunner{ Task: db.Task{ ProjectID: project.ID, TemplateID: 0, Status: task_logger.TaskSuccessStatus, Message: "This is a test notification", }, Template: db.Template{ ID: 0, ProjectID: project.ID, Name: "Test Notification", Type: db.TemplateTask, }, users: userIDs, alert: project.Alert, alertChat: project.AlertChat, pool: &TaskPool{ logger: make(chan logRecord, 100), store: store, }, } tr.sendTelegramAlert() tr.sendSlackAlert() tr.sendRocketChatAlert() tr.sendMicrosoftTeamsAlert() tr.sendDingTalkAlert() tr.sendGotifyAlert() tr.sendMailAlert() return } ================================================ FILE: services/tasks/hooks/ansible.go ================================================ package hooks import ( "github.com/semaphoreui/semaphore/db" ) type AnsibleHook struct { } func (h *AnsibleHook) End(store db.Store, projectID int, taskID int) { } ================================================ FILE: services/tasks/hooks/common.go ================================================ package hooks import "github.com/semaphoreui/semaphore/db" type Hook interface { End(store db.Store, projectID int, taskID int) } ================================================ FILE: services/tasks/hooks/factory.go ================================================ package hooks import ( "github.com/semaphoreui/semaphore/db" ) func GetHook(app db.TemplateApp) Hook { switch app { case db.AppAnsible: return &AnsibleHook{} default: return nil } } ================================================ FILE: services/tasks/http_test.go ================================================ package tasks import ( "testing" ) func TestGetNextBuildVersion(t *testing.T) { s := getNextBuildVersion("new-1.4-patch", "new-1.5-patch") if s != "new-1.6-patch" { t.Fatal() } s = getNextBuildVersion("new-1.4", "new-1.5") if s != "new-1.6" { t.Fatal() } s = getNextBuildVersion("1.4-patch", "1.5-patch") if s != "1.6-patch" { t.Fatal() } s = getNextBuildVersion("1.4.8", "1.4.9") if s != "1.4.10" { t.Fatal() } s = getNextBuildVersion("0", "7") if s != "8" { t.Fatal() } } ================================================ FILE: services/tasks/task_state_store.go ================================================ package tasks import "sync" // TaskRunnerHydrator constructs a TaskRunner for an existing task // identified by taskID and projectID without starting it. type TaskRunnerHydrator func(taskID int, projectID int) (*TaskRunner, error) // TaskStateStore defines pluggable storage for task pool state type TaskStateStore interface { // Start allows the store to initialize, restore its in-memory // pointers from the underlying backend and start background // sync listeners (e.g., Redis Pub/Sub). Implementations may no-op. Start(hydrator TaskRunnerHydrator) error // Queue operations Enqueue(task *TaskRunner) DequeueAt(index int) error QueueRange() []*TaskRunner QueueGet(index int) *TaskRunner QueueLen() int // Running tasks map operations SetRunning(task *TaskRunner) DeleteRunning(taskID int) RunningRange() []*TaskRunner RunningCount() int // Active-by-project operations AddActive(projectID int, task *TaskRunner) RemoveActive(projectID int, taskID int) GetActive(projectID int) []*TaskRunner ActiveCount(projectID int) int // Aliases operations SetAlias(alias string, task *TaskRunner) GetByAlias(alias string) *TaskRunner DeleteAlias(alias string) // Distributed claim to ensure single runner starts a task TryClaim(taskID int) bool DeleteClaim(taskID int) // UpdateRuntimeFields persists transient fields of TaskRunner so // they can be restored after restart in HA mode. UpdateRuntimeFields(task *TaskRunner) // LoadRuntimeFields fills runtime fields (RunnerID, Username, IncomingVersion, Alias) // from the backend into the provided task. No-op if not supported. LoadRuntimeFields(task *TaskRunner) } // MemoryTaskStateStore is an in-memory implementation of TaskStateStore type MemoryTaskStateStore struct { mu sync.RWMutex queue []*TaskRunner running map[int]*TaskRunner activeProj map[int]map[int]*TaskRunner // projectID -> taskID -> task aliases map[string]*TaskRunner } func NewMemoryTaskStateStore() *MemoryTaskStateStore { return &MemoryTaskStateStore{ queue: make([]*TaskRunner, 0), running: make(map[int]*TaskRunner), activeProj: make(map[int]map[int]*TaskRunner), aliases: make(map[string]*TaskRunner), } } // Start is a no-op for the in-memory store func (s *MemoryTaskStateStore) Start(_ TaskRunnerHydrator) error { return nil } // Claims always succeed in memory single-process mode func (s *MemoryTaskStateStore) TryClaim(_ int) bool { return true } func (s *MemoryTaskStateStore) DeleteClaim(_ int) {} func (s *MemoryTaskStateStore) UpdateRuntimeFields(_ *TaskRunner) {} func (s *MemoryTaskStateStore) LoadRuntimeFields(_ *TaskRunner) {} // Queue func (s *MemoryTaskStateStore) Enqueue(task *TaskRunner) { s.mu.Lock() s.queue = append(s.queue, task) s.mu.Unlock() } func (s *MemoryTaskStateStore) DequeueAt(index int) error { s.mu.Lock() if index < 0 || index >= len(s.queue) { s.mu.Unlock() return nil } s.queue = append(s.queue[:index], s.queue[index+1:]...) s.mu.Unlock() return nil } func (s *MemoryTaskStateStore) QueueRange() []*TaskRunner { s.mu.RLock() out := make([]*TaskRunner, len(s.queue)) copy(out, s.queue) s.mu.RUnlock() return out } func (s *MemoryTaskStateStore) QueueGet(index int) *TaskRunner { s.mu.RLock() defer s.mu.RUnlock() if index < 0 || index >= len(s.queue) { return nil } return s.queue[index] } func (s *MemoryTaskStateStore) QueueLen() int { s.mu.RLock() l := len(s.queue) s.mu.RUnlock() return l } // Running func (s *MemoryTaskStateStore) SetRunning(task *TaskRunner) { s.mu.Lock() s.running[task.Task.ID] = task s.mu.Unlock() } func (s *MemoryTaskStateStore) DeleteRunning(taskID int) { s.mu.Lock() delete(s.running, taskID) s.mu.Unlock() } func (s *MemoryTaskStateStore) RunningRange() []*TaskRunner { s.mu.RLock() res := make([]*TaskRunner, 0, len(s.running)) for _, t := range s.running { res = append(res, t) } s.mu.RUnlock() return res } func (s *MemoryTaskStateStore) RunningCount() int { s.mu.RLock() l := len(s.running) s.mu.RUnlock() return l } // Active by project func (s *MemoryTaskStateStore) AddActive(projectID int, task *TaskRunner) { s.mu.Lock() m, ok := s.activeProj[projectID] if !ok { m = make(map[int]*TaskRunner) s.activeProj[projectID] = m } m[task.Task.ID] = task s.mu.Unlock() } func (s *MemoryTaskStateStore) RemoveActive(projectID int, taskID int) { s.mu.Lock() if s.activeProj[projectID] != nil { delete(s.activeProj[projectID], taskID) if len(s.activeProj[projectID]) == 0 { delete(s.activeProj, projectID) } } s.mu.Unlock() } func (s *MemoryTaskStateStore) GetActive(projectID int) []*TaskRunner { s.mu.RLock() res := make([]*TaskRunner, 0) if s.activeProj[projectID] != nil { for _, t := range s.activeProj[projectID] { res = append(res, t) } } s.mu.RUnlock() return res } func (s *MemoryTaskStateStore) ActiveCount(projectID int) int { s.mu.RLock() l := 0 if s.activeProj[projectID] != nil { l = len(s.activeProj[projectID]) } s.mu.RUnlock() return l } // Aliases func (s *MemoryTaskStateStore) SetAlias(alias string, task *TaskRunner) { s.mu.Lock() s.aliases[alias] = task s.mu.Unlock() } func (s *MemoryTaskStateStore) GetByAlias(alias string) *TaskRunner { s.mu.RLock() t := s.aliases[alias] s.mu.RUnlock() return t } func (s *MemoryTaskStateStore) DeleteAlias(alias string) { s.mu.Lock() delete(s.aliases, alias) s.mu.Unlock() } ================================================ FILE: services/tasks/templates/dingtalk.tmpl ================================================ { "msgtype": "markdown", "markdown": { "title": "Task: {{ .Name }}", "text": "#### Task: {{ .Name }}\nExecution #: {{ .Task.ID }} \nStatus: {{ .Task.Result }} \nAuthor: {{ .Author }} \n{{ if .Task.Version }}Version: {{ .Task.Version }} \n{{ end }}[Task Link]({{ .Task.URL }})" } } ================================================ FILE: services/tasks/templates/email.tmpl ================================================

Task {{ .Task.ID }} with template '{{ .Name }}' has failed!

Task Log: Link

================================================ FILE: services/tasks/templates/gotify.tmpl ================================================ { "extras": { "client::display": { "contentType": "text/markdown" } }, "message": "Execution #: {{ .Task.ID }} \nStatus: {{ .Task.Result }} \nAuthor: {{ .Author }} \n{{ if .Task.Version }}Version: {{ .Task.Version }} \n{{ end }}[Task Link]({{ .Task.URL }})", "title": "Task: {{ .Name }}" } ================================================ FILE: services/tasks/templates/microsoft-teams.tmpl ================================================ { "type": "message", "attachments": [ { "contentType": "application/vnd.microsoft.card.adaptive", "content": { "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", "type": "AdaptiveCard", "version": "1.4", "body": [ { "type": "TextBlock", "text": "Ansible Task Template Execution by: {{ .Author }}" }, { "type": "FactSet", "facts": [ { "title": "Task:", "value": "{{ .Name }}" }, { "title": "Status:", "value": "{{ .Task.Result }}" }, { "title": "Task ID:", "value": "{{ .Task.ID }}" } ], "separator": true } ], "actions": [ { "type": "Action.OpenUrl", "title": "Task URL", "url": "{{ .Task.URL }}" } ], "msteams": { "width": "Full" }, "backgroundImage": { "horizontalAlignment": "Center", "url": "data:image/jpg;base64,iVBORw0KGgoAAAANSUhEUgAABSgAAAAFCAYAAABGmwLHAAAARklEQVR4nO3YMQEAIBDEsANPSMC/AbzwMm5JJHTseuf+AAAAAAAUbNEBAAAAgBaDEgAAAACoMSgBAAAAgBqDEgAAAADoSDL8RAJfcbcsoQAAAABJRU5ErkJggg==", "fillMode": "RepeatHorizontally" } } } ] } ================================================ FILE: services/tasks/templates/rocketchat.tmpl ================================================ { "text": "execution #{{ .Task.ID }}, status: {{ .Task.Result }}!", "attachments": [ { "title": "Task: {{ .Name }}", "title_link": "{{ .Task.URL }}", "text": "execution #{{ .Task.ID }}, status: {{ .Task.Result }}!", "color": "{{ .Color }}" } ] } ================================================ FILE: services/tasks/templates/slack.tmpl ================================================ { "attachments": [ { "title": "Task: {{ .Name }}", "title_link": "{{ .Task.URL }}", "text": "execution #{{ .Task.ID }}, status: {{ .Task.Result }}!", "color": "{{ .Color }}", "mrkdwn_in": [ "text" ], "fields": [ { "title": "Author", "value": "{{ .Author }}", "short": true {{ if .Task.Version }} }, { "title": "Version", "value": "{{ .Task.Version }}", "short": true {{ end }} } ] } ] } ================================================ FILE: services/tasks/templates/telegram.tmpl ================================================ { "chat_id": "{{ .Chat.ID }}", "parse_mode": "HTML", "text": "{{ .Name }}\n#{{ .Task.ID }} {{ .Task.Result }} {{ .Task.Version }} - {{ .Task.Desc }}\nby {{ .Author }}\n{{ .Task.URL }}" } ================================================ FILE: test/e2e/.gitignore ================================================ # Playwright node_modules/ /test-results/ /playwright-report/ /blob-report/ /playwright/.cache/ /.env ================================================ FILE: test/e2e/package.json ================================================ { "name": "dredd", "version": "1.0.0", "description": "", "main": "index.js", "scripts": {}, "keywords": [], "author": "", "license": "ISC", "devDependencies": { "@playwright/test": "^1.52.0", "@types/node": "^24.0.0" }, "dependencies": { "dotenv": "^17.0.0", "playwright": "^1.56.1" } } ================================================ FILE: test/e2e/playwright.config.ts ================================================ import { defineConfig, devices } from '@playwright/test'; /** * Read environment variables from file. * https://github.com/motdotla/dotenv */ // @ts-ignore import dotenv from 'dotenv'; // @ts-ignore import path from 'path'; dotenv.config({ path: path.resolve(__dirname, '.env') }); /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ testDir: './tests', /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ workers: 3, // process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: 'http://localhost:8080', // video: 'retain-on-failure', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ // trace: 'on-first-retry', }, /* Configure projects for major browsers */ projects: [ { name: 'chromium', use: { ...devices['Desktop Chrome'], channel: 'chromium' }, }, // { // name: 'firefox', // use: { ...devices['Desktop Firefox'] }, // }, // // { // name: 'webkit', // use: { ...devices['Desktop Safari'] }, // }, /* Test against mobile viewports. */ // { // name: 'Mobile Chrome', // use: { ...devices['Pixel 5'] }, // }, // { // name: 'Mobile Safari', // use: { ...devices['iPhone 12'] }, // }, /* Test against branded browsers. */ // { // name: 'Microsoft Edge', // use: { ...devices['Desktop Edge'], channel: 'msedge' }, // }, // { // name: 'Google Chrome', // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, // }, ], /* Run your local dev server before starting the tests */ // webServer: { // command: 'npm run start', // url: 'http://127.0.0.1:3000', // reuseExistingServer: !process.env.CI, // }, }); ================================================ FILE: test/e2e/tests/fixtures.ts ================================================ import { test as base } from "@playwright/test"; export const test = base.extend<{ login: (asAdmin: boolean) => Promise; project: { create: ( role?: "owner" | "manager" | "task_runner" | "guest", demo?: boolean, name?: string ) => Promise; delete: () => Promise; }; }>({ login: async ({ page }, use) => { await use(async (asAdmin: boolean) => { await page.goto("/auth/login"); const username = asAdmin ? process.env.TEST_ADMIN_LOGIN : process.env.TEST_USER_LOGIN; if (!username) { throw new Error("TEST_ADMIN_LOGIN or TEST_USER_LOGIN is not set"); } const password = asAdmin ? process.env.TEST_ADMIN_PASSWORD : process.env.TEST_USER_PASSWORD; if (!password) { throw new Error("TEST_ADMIN_PASSWORD or TEST_USER_PASSWORD is not set"); } await page.getByTestId("auth-username").fill(username); await page.getByTestId("auth-password").fill(password); await page.getByTestId("auth-signin").click(); }); }, project: async ({ page }, use) => { await use({ /** * Create a new project. * @param role One of 'owner'|'manager'|'task_runner'|'guest'; defaults to 'owner' * @param demo Whether to enable the "Demo" flag; defaults to false * @param name Optional custom project name; if omitted, a timestamped name is generated * @returns The name of the newly created project */ create: async (role = "owner", demo = false, name?: string) => { const projectName = name ?? `test-${role}-${Date.now()}`; // open new-project dialog await page.getByTestId("sidebar-currentProject").click(); await page.getByTestId("sidebar-newProject").click(); // fill in details await page.getByTestId("newProject-name").fill(projectName); if (demo) { await page.getByRole("dialog").getByText("Demo").click(); } // (optional) select role if your UI supports it: // await page.getByRole('combobox', { name: 'Role' }).selectOption(role); await page .getByRole("dialog") .getByRole("button", { name: "Create" }) .click(); await page.getByText(`Project ${projectName} created`).waitFor(); // wait for the project to appear in the sidebar await page .getByTestId("sidebar-currentProject") .getByText(projectName) .waitFor(); await page.waitForTimeout(500); return projectName; }, /** * Delete an existing project by name. * @param name The exact project name to delete */ delete: async () => { await page.getByTestId("sidebar-dashboard").click(); await page.getByTestId("dashboard-settings").click(); await page.getByTestId("settings-deleteProject").click(); await page .getByRole("dialog") .getByRole("button", { name: "Yes" }) .click(); }, }); }, }); export { expect } from "@playwright/test"; ================================================ FILE: test/e2e/tests/task.spec.ts ================================================ import { test, expect } from "./fixtures"; test.describe("task running", () => { test.beforeEach(async ({ page, login, project }) => { await login(true); await project.create("task_runner", true); await page.getByTestId("sidebar-templates").click(); await page.getByRole("link", { name: "Build demo app" }).click(); await page.getByTestId("template-run").click(); await page .getByTestId("newTaskDialog") .getByRole("textbox", { name: "Message (Optional)" }) .fill("Test"); await page .getByTestId("newTaskDialog") .getByTestId("editDialog-save") .click(); test.setTimeout(90000); }); test.afterEach(async ({ page, project }) => { await page .getByTestId("taskLogDialog") .getByTestId("editDialog-close") .click(); await project.delete(); }); test("run task from demo project", async ({ page }) => { await page.getByTestId("task-rawLog").waitFor({ timeout: 90000 }); await expect(page.getByTestId("task-status")).toHaveText("Success"); }); test("stop task on waiting", async ({ page }) => { await page .getByRole("dialog") .getByRole("button", { name: "Stop" }) .click(); await page.getByTestId("task-rawLog").waitFor({ timeout: 600000 }); await expect(page.getByTestId("task-status")).toHaveText("Stopped"); }); test("stop task on cloning", async ({ page }) => { await page .getByRole("dialog") .getByText("Get current commit hash") .waitFor(); await page .getByRole("dialog") .getByRole("button", { name: "Stop" }) .click(); await page.getByTestId("task-rawLog").waitFor({ timeout: 60000 }); await expect(page.getByTestId("task-status")).toHaveText("Stopped"); }); test("stop task on running", async ({ page }) => { await page .getByRole("dialog") .getByText( "TASK [Gathering Facts] *********************************************************" ) .waitFor({ timeout: 100000 }); await page .getByRole("dialog") .getByRole("button", { name: "Stop" }) .click(); await page.getByTestId("task-rawLog").waitFor({ timeout: 60000 }); await expect(page.getByTestId("task-status")).toHaveText("Stopped"); }); }); ================================================ FILE: test/e2e/tests/variable-group.spec.ts ================================================ import { test, expect } from "./fixtures"; test.describe("Variable Groups", () => { test.beforeEach(async ({ page, login, project }) => { await login(true); await project.create("manager", true); }); test.afterEach(async ({ project, page }) => { await page .getByTestId("varGroupDialog") .getByTestId("editDialog-close") .click(); await project.delete(); }); test("saving variables with empty names is prohibited", async ({ page, login, }) => { await page.getByTestId("sidebar-environment").click(); await page.getByRole("button", { name: "New Group" }).click(); await page.getByRole("textbox", { name: "Group Name" }).fill("Test"); await page.getByTestId("varGroup-addEnv").click(); await page.getByRole("textbox", { name: "Value" }).fill("Test"); await page.getByRole("button", { name: "Save" }).click(); await page.getByTestId("varGroup-error").waitFor({ timeout: 1000 }); await expect(page.getByTestId("varGroup-error")).toHaveText( "Environment variables key can not be empty" ); }); }); ================================================ FILE: test/mcp/api/AGENT.md ================================================ # Manual QA Agent Guide (MCP) This directory is intended to be executed by an LLM acting as a **manual QA engineer**. The LLM should use **MCP tools** (Semaphore) to execute the cases in `test_plan.md` and write a clear, reproducible test report. Run test cases simultaneously (in parallel). 1. For each test case create a new project with demo flag. 2. Use this project for tests. 3. After each test case delete the project. ## Goals (what "good" looks like) - Execute each test case end-to-end (or mark it **BLOCKED** with a precise reason). - Prefer deterministic verification (API/MCP) and capture evidence (IDs, logs, screenshots). - Never damage real user data: use **ephemeral test data**, and **clean up** what you create. - Produce a single report file: `artifacts/results-.md`. ## Safety rules (must-follow) - **Do not delete or modify** any pre-existing resources you did not create for this run. - Create all resources with a unique prefix: `llm-qa--...`. - If you are unsure whether something is "test-only", treat it as production and do not touch it. - Prefer **read-only** actions first (list/get) before any create/update/delete. - If a step would be destructive and you cannot prove it is safe, mark the test **BLOCKED** and explain. ## Run workflow (recommended) 1. **Preflight** - Verify MCP connectivity (at minimum): list projects, list templates in a project, list tasks. - Record environment context in the report (date/time, host/base URL if known, git commit if available). 2. **Execute test cases** in `test_plan.md` in order. 3. **Capture evidence** - For each created resource, record: name, ID, and the API/UI location where it can be found. - For tasks: record template name/id, task id, final status, and output excerpt. 4. **Cleanup** - Delete resources created by this run (projects/environments/inventory/tasks as applicable). 5. **Write report**: save as `tests/mcp-api/results-.md`. ## Handling missing prerequisites If the environment does not contain the preconditions needed to run a test case (e.g. no templates exist, or no failures exist for TC3), do **not** fabricate results. Instead: - Mark the test **BLOCKED**. - State exactly what is missing. - Include the discovery evidence (e.g. "`list_templates` returned 0 templates for project X"). - Suggest the minimal setup to unblock. ## Test-case playbooks (how to execute `test_plan.md`) Use these as the "default implementation" of each test case. If a required MCP capability does not exist in your environment, mark **BLOCKED**. ## Status definitions - **PASS**: All steps completed and expected results met. - **FAIL**: Steps completed but at least one expected result not met (include bug report). - **BLOCKED**: Cannot execute due to missing prerequisite/tooling/access. - **SKIPPED**: Intentionally not executed (must include explicit reason). ## Reporting template (copy into `artifacts/results-.md`) ### Run metadata - **Run ID**: `` - **Date/time**: `` - **Environment**: `` - **Semaphore context**: `` - **MCP servers used**: `semaphore`, `playwright` (as applicable) ### Executive summary - **Overall**: `` - **Highlights**: `<1–5 bullets>` - **Key risks**: `<1–5 bullets>` ### Results table | Test Case | Status | Evidence | Notes | |---|---|---|---| | TC1 Project Lifecycle Management | | | | | TC2 Template Execution and Task Monitoring | | | | | TC3 Failed Task Analysis | | | | | TC4 Environment and Inventory Management | | | | | TC5 Bulk Task Operations and Filtering | | | | ### Detailed execution notes For each test case include: - **What you did**: concise step list (include MCP calls and important parameters) - **What you observed**: key outputs/IDs/log excerpts - **Pass/Fail rationale**: map to “Expected Results” - **Cleanup**: what you deleted/left behind (should be “none left behind”) ### Bugs found If any test case FAILS, include at least one bug entry: #### Bug: - **Severity**: `<blocker/critical/major/minor/trivial>` - **Area**: `<UI/API/Tasks/Templates/Auth/...>` - **Environment**: `<dev/staging/...>` - **Repro rate**: `<100% / flaky / once>` - **Steps to reproduce**: 1. ... - **Expected**: ... - **Actual**: ... - **Evidence**: - Task IDs: `<id list>` - Logs: `<link/embedded excerpt>` - Screenshots/snapshots: `<paths if saved>` - **Notes / suspected cause** (optional): ... ================================================ FILE: test/mcp/api/data/case4/test.sh ================================================ echo Hello World ================================================ FILE: test/mcp/api/run.sh ================================================ cursor-agent --approve-mcps --force --print "$(cat test_plan.md)" ================================================ FILE: test/mcp/api/test_plan.md ================================================ # Semaphore UI Test Plan > For LLM/MCP execution instructions, safety rules, and reporting templates, see `AGENT.md`. ## Test Case 1: Project Lifecycle Management **Objective**: Verify complete project creation, update, and deletion workflow **Steps**: 1. Verify project creation and retrieve project details 2. Update project properties (name, max_parallel_tasks) 3. Verify updates were applied correctly **Expected Results**: - Project created successfully with correct initial values - Project details retrieved accurately - Updates applied and reflected in project data - Project deleted successfully ## Test Case 2: Template Execution and Task Monitoring **Objective**: Execute a template task and monitor its execution lifecycle **Steps**: 1. Execute the template "Ping semaphoreui.com" 2. Monitor task execution status 3. Retrieve task details and verify completion 4. Verify task output/logs are accessible **Expected Results**: - Task starts successfully - Task status transitions correctly (running → success/error) - Task details are accurate and complete - Task output is accessible ## Test Case 3: Template simple pipeline **Objective**: Verify that build task triggers deploy task **Steps**: 1. Run "Build demo app" template 2. Wait until it finished and check if it is ok 3. Check if template "Deploy demo app to Dev" automatically triggered after "Build demo app" complete in 10 seconds. 4. Wait until it finished and check if it is ok. **Expected Results**: - "Deploy demo app to Dev" successfully executed ## Test Case 4: Test bash script **Objective**: Verify running bash scripts **Steps**: 1. Add repository "Semaphore" https://github.com/semaphoreui/semaphore with branch `develop`. 2. Create a template with following options: * Type: Bash script * Script path test/mcp/api/data/case4/test.sh 3. Run the template 4. Wait until it finished and check if it is ok. **Expected Results**: - Task complate successfully ================================================ FILE: test/mcp/e2e/.gitignore ================================================ # Playwright node_modules/ /test-results/ /playwright-report/ /blob-report/ /playwright/.cache/ /playwright/.auth/ ================================================ FILE: test/mcp/e2e/AGENT.md ================================================ # Playwright QA Agent's Guide (MCP) This directory is intended to be executed by an LLM acting as a **manual QA engineer**. The LLM must use **Playwright MCP** to execute the cases in `test_plan.md` and write a clear, reproducible test report. Run test cases simultaneously (in parallel). 1. Use Playwright MCP (NOT Semaphore MCP!) to execute the cases in `test_plan.md` in **headless mode** (do not show a browser window). 2. Open page http://localhost:8080/auth/login and login using **fiftin** with password **150986** 3. Use the UI http://localhost:8080/ to execute the test cases. 4. For each test case create a new project. 5. Use this project for tests. 6. After each test case delete the project. ## Goals (what "good" looks like) - Execute each test case end-to-end (or mark it **BLOCKED** with a precise reason). - Prefer deterministic verification (API/MCP) and capture evidence (IDs, logs, screenshots). - Never damage real user data: use **ephemeral test data**, and **clean up** what you create. - Produce a single report file: `artifacts/results-<run-id>.md`. ## Safety rules (must-follow) - **Do not delete or modify** any pre-existing resources you did not create for this run. - Create all resources with a unique prefix: `llm-qa-<run-id>-...`. - If you are unsure whether something is "test-only", treat it as production and do not touch it. - Prefer **read-only** actions first (list/get) before any create/update/delete. - If a step would be destructive and you cannot prove it is safe, mark the test **BLOCKED** and explain. ## Run workflow (recommended) 1. **Preflight** - Verify MCP connectivity (at minimum): list projects, list templates in a project, list tasks. - Record environment context in the report (date/time, host/base URL if known, git commit if available). 2. **Execute test cases** in `test_plan.md` in order. 3. **Capture evidence** - For each created resource, record: name, ID, and the API/UI location where it can be found. - For tasks: record template name/id, task id, final status, and output excerpt. 4. **Cleanup** - Delete resources created by this run (projects/environments/inventory/tasks as applicable). 5. **Write report**: save as `tests/mcp-ui/results-<run-id>.md`. ## Handling missing prerequisites If the environment does not contain the preconditions needed to run a test case (e.g. no templates exist, or no failures exist for TC3), do **not** fabricate results. Instead: - Mark the test **BLOCKED**. - State exactly what is missing. - Include the discovery evidence (e.g. "`list_templates` returned 0 templates for project X"). - Suggest the minimal setup to unblock. ## Test-case playbooks (how to execute `test_plan.md`) Use these as the "default implementation" of each test case. If a required MCP capability does not exist in your environment, mark **BLOCKED**. ## Status definitions - **PASS**: All steps completed and expected results met. - **FAIL**: Steps completed but at least one expected result not met (include bug report). - **BLOCKED**: Cannot execute due to missing prerequisite/tooling/access. - **SKIPPED**: Intentionally not executed (must include explicit reason). ## Reporting template (copy into `artifacts/results-<run-id>.md`) ### Run metadata - **Run ID**: `<run-id>` - **Date/time**: `<iso8601>` - **Environment**: `<dev/staging/prod?>` - **Semaphore context**: `<base URL / instance name / version if known>` - **MCP servers used**: `semaphore`, `playwright` (as applicable) ### Executive summary - **Overall**: `<PASS/FAIL/BLOCKED>` - **Highlights**: `<1–5 bullets>` - **Key risks**: `<1–5 bullets>` ### Results table | Test Case | Status | Evidence | Notes | |---|---|---|---| | TC1 Project Lifecycle Management | | | | | TC2 Template Execution and Task Monitoring | | | | | TC3 Failed Task Analysis | | | | | TC4 Environment and Inventory Management | | | | | TC5 Bulk Task Operations and Filtering | | | | ### Detailed execution notes For each test case include: - **What you did**: concise step list (include MCP calls and important parameters) - **What you observed**: key outputs/IDs/log excerpts - **Pass/Fail rationale**: map to “Expected Results” - **Cleanup**: what you deleted/left behind (should be “none left behind”) ### Bugs found If any test case FAILS, include at least one bug entry: #### Bug: <title> - **Severity**: `<blocker/critical/major/minor/trivial>` - **Area**: `<UI/API/Tasks/Templates/Auth/...>` - **Environment**: `<dev/staging/...>` - **Repro rate**: `<100% / flaky / once>` - **Steps to reproduce**: 1. ... - **Expected**: ... - **Actual**: ... - **Evidence**: - Task IDs: `<id list>` - Logs: `<link/embedded excerpt>` - Screenshots/snapshots: `<paths if saved>` - **Notes / suspected cause** (optional): ... ================================================ FILE: test/mcp/e2e/package.json ================================================ { "name": "mcp-ui", "version": "1.0.0", "description": "", "main": "index.js", "scripts": {}, "keywords": [], "author": "", "license": "ISC", "type": "commonjs", "devDependencies": { "@playwright/test": "^1.57.0", "@types/node": "^25.0.2" } } ================================================ FILE: test/mcp/e2e/playwright.config.ts ================================================ import { defineConfig, devices } from '@playwright/test'; /** * Read environment variables from file. * https://github.com/motdotla/dotenv */ // import dotenv from 'dotenv'; // import path from 'path'; // dotenv.config({ path: path.resolve(__dirname, '.env') }); /** * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ // testDir: './tests', /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { headless: true, /* Base URL to use in actions like `await page.goto('')`. */ baseURL: 'http://localhost:8080', /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: 'on-first-retry', }, /* Configure projects for major browsers */ // projects: [ // { // name: 'chromium', // use: { ...devices['Desktop Chrome'] }, // }, // // { // name: 'firefox', // use: { ...devices['Desktop Firefox'] }, // }, // // ], }); ================================================ FILE: test/mcp/e2e/run.sh ================================================ cursor-agent --approve-mcps --force --print "$(cat test_plan.md)" ================================================ FILE: test/mcp/e2e/test_plan.md ================================================ # Test Plan for Semaphore UI using Playwright MCP > For LLM/MCP execution instructions, safety rules, and reporting templates, see `AGENT.md`. ## Test Case 1: Project Lifecycle Management **Objective**: Verify complete project creation, update, and deletion workflow **Steps**: 1. Verify project creation and retrieve project details 2. Update project properties 3. Verify updates were applied correctly **Expected Results**: - Project created successfully with correct initial values - Project details retrieved accurately - Updates applied and reflected in project data - Project deleted successfully ## Test Case 2: Create a new user **Objective**: Verify complete user creation, update, and deletion workflow **Steps**: 1. Go to /users 2. Click the button "New User" to open the form 3. Fill required fields in the form 4. Click "Save" 5. Verify user created successfully 6. Delete the user **Expected Results**: - User created successfully with correct initial values - User details retrieved accurately - Updates applied and reflected in user data - User deleted successfully ================================================ FILE: util/App.go ================================================ package util type App struct { Active bool `json:"active"` Priority int `json:"priority"` Title string `json:"title"` Icon string `json:"icon"` Color string `json:"color"` DarkColor string `json:"dark_color"` AppPath string `json:"path"` AppArgs []string `json:"args"` } ================================================ FILE: util/OdbcProvider.go ================================================ package util type OidcProvider struct { ClientID string `json:"client_id"` ClientIDFile string `json:"client_id_file"` ClientSecret string `json:"client_secret"` ClientSecretFile string `json:"client_secret_file"` RedirectURL string `json:"redirect_url"` Scopes []string `json:"scopes"` DisplayName string `json:"display_name"` Color string `json:"color"` Icon string `json:"icon"` AutoDiscovery string `json:"provider_url"` Endpoint oidcEndpoint `json:"endpoint"` UsernameClaim string `json:"username_claim" default:"preferred_username"` NameClaim string `json:"name_claim" default:"preferred_username"` EmailClaim string `json:"email_claim" default:"email"` Order int `json:"order"` // ReturnViaState when true, passes the return path via the OAuth state parameter instead of the redirect URL path. This is useful for OAuth providers that have strict redirect URL validation. ReturnViaState bool `json:"return_via_state" default:"true"` } type ClaimsProvider interface { GetUsernameClaim() string GetEmailClaim() string GetNameClaim() string } func (p *OidcProvider) GetUsernameClaim() string { return p.UsernameClaim } func (p *OidcProvider) GetEmailClaim() string { return p.EmailClaim } func (p *OidcProvider) GetNameClaim() string { return p.NameClaim } ================================================ FILE: util/ansi.go ================================================ package util import ( "regexp" ) // ansiCodeRE is a regex to remove ANSI escape sequences from a string. // ANSI escape sequences are typically in the form: \x1b[<parameters><letter> var ansiCodeRE = regexp.MustCompile("\x1b\\[[0-9;]*[a-zA-Z]") func ClearFromAnsiCodes(s string) string { return ansiCodeRE.ReplaceAllString(s, "") } ================================================ FILE: util/config.go ================================================ package util import ( "context" "crypto/rand" "encoding/base32" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/url" "os" "os/exec" "path" "path/filepath" "reflect" "regexp" "strconv" "strings" "golang.org/x/crypto/bcrypt" "gopkg.in/natefinch/lumberjack.v2" "github.com/google/go-github/github" "github.com/gorilla/securecookie" ) // Cookie is a runtime generated secure cookie used for authentication var Cookie *securecookie.SecureCookie // WebHostURL is the public route to the semaphore server var WebHostURL *url.URL const ( DbDriverMySQL = "mysql" DbDriverBolt = "bolt" // Deprecated: replaced with sqlite DbDriverPostgres = "postgres" DbDriverSQLite = "sqlite" ) const ( // HomeDirModeUserHome does not override HOME. // Sets ANSIBLE_HOME per template to isolate .ansible/ across parallel tasks. HomeDirModeUserHome = "user_home" // HomeDirModeProjectHome sets HOME to the project temp directory. // This is the legacy behavior. Parallel ansible-galaxy runs may conflict. HomeDirModeProjectHome = "project_home" // HomeDirModeTemplateDir does not override HOME. // Sets ANSIBLE_HOME to a per-template "_home/.ansible" directory // (e.g. repository_15_template_114_home/.ansible) to isolate // .ansible/ artifacts across parallel tasks. HomeDirModeTemplateDir = "template_dir" ) type DbConfig struct { Dialect string `json:"-"` Hostname string `json:"host,omitempty" env:"SEMAPHORE_DB_HOST" default:"0.0.0.0"` Username string `json:"user,omitempty" env:"SEMAPHORE_DB_USER"` Password string `json:"pass,omitempty" env:"SEMAPHORE_DB_PASS"` DbName string `json:"name,omitempty" env:"SEMAPHORE_DB" default:"semaphore"` Options map[string]string `json:"options,omitempty" env:"SEMAPHORE_DB_OPTIONS"` } type LdapMappings struct { DN string `json:"dn" env:"SEMAPHORE_LDAP_MAPPING_DN" default:"dn"` Mail string `json:"mail" env:"SEMAPHORE_LDAP_MAPPING_MAIL" default:"mail"` UID string `json:"uid" env:"SEMAPHORE_LDAP_MAPPING_UID" default:"uid"` CN string `json:"cn" env:"SEMAPHORE_LDAP_MAPPING_CN" default:"cn"` } func (p *LdapMappings) GetUsernameClaim() string { return p.UID } func (p *LdapMappings) GetEmailClaim() string { return p.Mail } func (p *LdapMappings) GetNameClaim() string { return p.CN } type oidcEndpoint struct { IssuerURL string `json:"issuer"` AuthURL string `json:"auth"` TokenURL string `json:"token"` UserInfoURL string `json:"userinfo"` JWKSURL string `json:"jwks"` Algorithms []string `json:"algorithms"` } const ( // GoGitClientId is builtin Git client. It is not require external dependencies and is preferred. // Use it if you don't need external SSH authorization. GoGitClientId = "go_git" // CmdGitClientId is external Git client. // Default Git client. It is use external Git binary to clone repositories. CmdGitClientId = "cmd_git" ) // // basic config validation using regex // /* NOTE: other basic regex could be used: // // ipv4: ^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$ // ipv6: ^(?:[A-Fa-f0-9]{1,4}:|:){3,7}[A-Fa-f0-9]{1,4}$ // domain: ^([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}$ // path+filename: ^([\\/[a-zA-Z0-9_\\-${}:~]*]*\\/)?[a-zA-Z0-9\\.~_${}\\-:]*$ // email address: ^(|.*@[A-Za-z0-9-\\.]*)$ // // */ type RunnerConfig struct { RegistrationToken string `json:"-" env:"SEMAPHORE_RUNNER_REGISTRATION_TOKEN"` Token string `json:"token,omitempty" env:"SEMAPHORE_RUNNER_TOKEN"` TokenFile string `json:"token_file,omitempty" env:"SEMAPHORE_RUNNER_TOKEN_FILE"` PrivateKeyFile string `json:"private_key_file,omitempty" env:"SEMAPHORE_RUNNER_PRIVATE_KEY_FILE"` // OneOff indicates than runner runs only one job and exit. It is very useful for dynamic runners. // How it works? // Example: // 1) User starts the task. // 2) Semaphore found runner for task and calls runner's webhook if it provided. // 3) Your server or lambda handling the call and starts the one-off runner. // 4) The runner connects to the Semaphore server and handles the enqueued task(s). OneOff bool `json:"one_off,omitempty" env:"SEMAPHORE_RUNNER_ONE_OFF"` Webhook string `json:"webhook,omitempty" env:"SEMAPHORE_RUNNER_WEBHOOK"` MaxParallelTasks int `json:"max_parallel_tasks,omitempty" default:"1" env:"SEMAPHORE_RUNNER_MAX_PARALLEL_TASKS"` } type TLSConfig struct { Enabled bool `json:"enabled" env:"SEMAPHORE_TLS_ENABLED"` CertFile string `json:"cert_file" env:"SEMAPHORE_TLS_CERT_FILE"` KeyFile string `json:"key_file" env:"SEMAPHORE_TLS_KEY_FILE"` HTTPRedirectPort *int `json:"http_redirect_port,omitempty" env:"SEMAPHORE_TLS_HTTP_REDIRECT_PORT"` } type TotpConfig struct { Enabled bool `json:"enabled" env:"SEMAPHORE_TOTP_ENABLED"` AllowRecovery bool `json:"allow_recovery" env:"SEMAPHORE_TOTP_ALLOW_RECOVERY"` Issuer string `json:"app_name" env:"SEMAPHORE_TOTP_ISSUER"` } type EventLogType struct { Format string `json:"format,omitempty" env:"SEMAPHORE_EVENT_LOG_FORMAT"` Enabled bool `json:"enabled" env:"SEMAPHORE_EVENT_LOG_ENABLED"` Logger *lumberjack.Logger `json:"logger,omitempty" env:"SEMAPHORE_EVENT_LOGGER"` } const ( FileLogJSON string = "json" FileLogRaw string = "" ) type TaskLogType struct { Enabled bool `json:"enabled" env:"SEMAPHORE_TASK_LOG_ENABLED"` Format string `json:"format,omitempty" env:"SEMAPHORE_TASK_LOG_FORMAT"` Logger *lumberjack.Logger `json:"logger,omitempty" env:"SEMAPHORE_TASK_LOGGER"` ResultLogger *lumberjack.Logger `json:"result_logger,omitempty" env:"SEMAPHORE_TASK_RESULT_LOGGER"` } type ConfigLog struct { Events *EventLogType `json:"events,omitempty"` Tasks *TaskLogType `json:"tasks,omitempty"` } type SyslogFormat string const ( SyslogDefault SyslogFormat = "" SyslogRFC5424 SyslogFormat = "rfc5424" ) type SyslogConfig struct { Enabled bool `json:"enabled" env:"SEMAPHORE_SYSLOG_ENABLED"` Network string `json:"network,omitempty" env:"SEMAPHORE_SYSLOG_NETWORK"` Address string `json:"address,omitempty" env:"SEMAPHORE_SYSLOG_ADDRESS"` Tag string `json:"tag,omitempty" env:"SEMAPHORE_SYSLOG_TAG"` Format SyslogFormat `json:"format,omitempty" env:"SEMAPHORE_SYSLOG_FORMAT"` } type ConfigProcess struct { User string `json:"user,omitempty" env:"SEMAPHORE_PROCESS_USER"` UID *int `json:"uid,omitempty" env:"SEMAPHORE_PROCESS_UID"` Chroot string `json:"chroot,omitempty" env:"SEMAPHORE_PROCESS_CHROOT"` GID *int `json:"gid,omitempty" env:"SEMAPHORE_PROCESS_GID"` } type ScheduleConfig struct { Timezone string `json:"timezone,omitempty" env:"SEMAPHORE_SCHEDULE_TIMEZONE" default:"UTC"` } type DebuggingConfig struct { ApiDelay string `json:"api_delay,omitempty" env:"SEMAPHORE_API_DELAY"` PprofDumpDir string `json:"pprof_dump_dir,omitempty" env:"SEMAPHORE_PPROF_DUMP_DIR"` } type HARedisConfig struct { Addr string `json:"addr,omitempty" env:"SEMAPHORE_HA_REDIS_ADDR"` DB int `json:"db,omitempty" env:"SEMAPHORE_HA_REDIS_DB"` Pass string `json:"pass,omitempty" env:"SEMAPHORE_HA_REDIS_PASS"` User string `json:"user,omitempty" env:"SEMAPHORE_HA_REDIS_USER"` TLS bool `json:"tls,omitempty" env:"SEMAPHORE_HA_REDIS_TLS"` TLSSkipVerify bool `json:"tls_skip_verify,omitempty" env:"SEMAPHORE_HA_REDIS_TLS_SKIP_VERIFY"` } type HAConfig struct { Enabled bool `json:"enabled" env:"SEMAPHORE_HA_ENABLED"` NodeID string `json:"node_id,omitempty" env:"SEMAPHORE_HA_NODE_ID"` // auto-generated if empty Redis *HARedisConfig `json:"redis,omitempty"` } // HAEnabled returns true when high-availability mode is configured. func HAEnabled() bool { return Config.HA != nil && Config.HA.Enabled } // InitHANodeID generates a unique node identifier for this instance if one // was not explicitly configured. Must be called after ConfigInit. func InitHANodeID() { if Config.HA == nil { return } if Config.HA.NodeID == "" { Config.HA.NodeID = RandString(16) } } type TeamInviteType string const ( TeamInviteEmail TeamInviteType = "email" TeamInviteUsername TeamInviteType = "username" TeamInviteBoth TeamInviteType = "both" ) type TeamsConfig struct { InvitesEnabled bool `json:"invites_enabled,omitempty" env:"SEMAPHORE_TEAMS_INVITES_ENABLED"` InviteType TeamInviteType `json:"invite_type,omitempty" env:"SEMAPHORE_TEAMS_INVITE_TYPE" default:"username"` MembersCanLeave bool `json:"members_can_leave,omitempty" env:"SEMAPHORE_TEAMS_MEMBERS_CAN_LEAVE"` } type ConfigDirs struct { SecretsPath string `json:"secrets_path,omitempty" env:"SEMAPHORE_SECRETS_PATH" default:"/tmp/semaphore"` ReposPath string `json:"repos_path,omitempty" env:"SEMAPHORE_REPOS_PATH"` } // ConfigType mapping between Config and the json file that sets it type ConfigType struct { MySQL *DbConfig `json:"mysql,omitempty"` BoltDb *DbConfig `json:"bolt,omitempty"` // Deprecated Postgres *DbConfig `json:"postgres,omitempty"` SQLite *DbConfig `json:"sqlite,omitempty"` Dialect string `json:"dialect,omitempty" default:"bolt" rule:"^mysql|bolt|postgres|sqlite$" env:"SEMAPHORE_DB_DIALECT"` // Format `:port_num` eg, :3000 // if : is missing it will be corrected Port string `json:"port,omitempty" default:":3000" rule:"^:?([0-9]{1,5})$" env:"SEMAPHORE_PORT"` TLS *TLSConfig `json:"tls,omitempty"` Auth *AuthConfig `json:"auth,omitempty"` // Interface ip, put in front of the port. // defaults to empty Interface string `json:"interface,omitempty" env:"SEMAPHORE_INTERFACE"` // semaphore stores ephemeral projects here TmpPath string `json:"tmp_path,omitempty" default:"/tmp/semaphore" env:"SEMAPHORE_TMP_PATH"` // HomeDirMode controls how the HOME environment variable is set for tasks. // "template_home" (default) — HOME is set to a per-template directory, // isolating .ansible/ across parallel tasks. Repo is cloned into a // "src" subdirectory under HOME. // "project_home" — HOME is set to the project temp directory (legacy // behavior). Parallel ansible-galaxy runs in the same project may conflict. // "user_home" — HOME is not overridden (keeps the real user HOME). // ANSIBLE_HOME is set per template to isolate .ansible/ for Ansible tasks. HomeDirMode string `json:"home_dir_mode,omitempty" rule:"^(user_home|project_home|template_dir)?$" env:"SEMAPHORE_HOME_DIR_MODE" default:"template_dir"` // SshConfigPath is a path to the custom SSH config file. // Default path is ~/.ssh/config. SshConfigPath string `json:"ssh_config_path,omitempty" env:"SEMAPHORE_SSH_PATH"` GitClientId string `json:"git_client,omitempty" rule:"^go_git|cmd_git$" env:"SEMAPHORE_GIT_CLIENT" default:"cmd_git"` // web host WebHost string `json:"web_host,omitempty" env:"SEMAPHORE_WEB_ROOT"` // cookie hashing & encryption CookieHash string `json:"cookie_hash,omitempty" env:"SEMAPHORE_COOKIE_HASH"` CookieEncryption string `json:"cookie_encryption,omitempty" env:"SEMAPHORE_COOKIE_ENCRYPTION"` // AccessKeyEncryption is BASE64 encoded byte array used // for encrypting and decrypting access keys stored in database. AccessKeyEncryption string `json:"access_key_encryption,omitempty" env:"SEMAPHORE_ACCESS_KEY_ENCRYPTION"` // email alerting EmailAlert bool `json:"email_alert,omitempty" env:"SEMAPHORE_EMAIL_ALERT"` EmailSender string `json:"email_sender,omitempty" env:"SEMAPHORE_EMAIL_SENDER"` EmailHost string `json:"email_host,omitempty" env:"SEMAPHORE_EMAIL_HOST"` EmailPort string `json:"email_port,omitempty" rule:"^(|[0-9]{1,5})$" env:"SEMAPHORE_EMAIL_PORT"` EmailUsername string `json:"email_username,omitempty" env:"SEMAPHORE_EMAIL_USERNAME"` EmailPassword string `json:"email_password,omitempty" env:"SEMAPHORE_EMAIL_PASSWORD"` EmailSecure bool `json:"email_secure,omitempty" env:"SEMAPHORE_EMAIL_SECURE"` EmailTls bool `json:"email_tls,omitempty" env:"SEMAPHORE_EMAIL_TLS"` EmailTlsMinVersion string `json:"email_tls_min_version,omitempty" default:"1.2" rule:"^(1\\.[0123])$" env:"SEMAPHORE_EMAIL_TLS_MIN_VERSION"` // ldap settings LdapEnable bool `json:"ldap_enable,omitempty" env:"SEMAPHORE_LDAP_ENABLE"` LdapBindDN string `json:"ldap_binddn,omitempty" env:"SEMAPHORE_LDAP_BIND_DN"` LdapBindPassword string `json:"ldap_bindpassword,omitempty" env:"SEMAPHORE_LDAP_BIND_PASSWORD"` LdapServer string `json:"ldap_server,omitempty" env:"SEMAPHORE_LDAP_SERVER"` LdapSearchDN string `json:"ldap_searchdn,omitempty" env:"SEMAPHORE_LDAP_SEARCH_DN"` LdapSearchFilter string `json:"ldap_searchfilter,omitempty" env:"SEMAPHORE_LDAP_SEARCH_FILTER"` LdapMappings *LdapMappings `json:"ldap_mappings,omitempty"` LdapNeedTLS bool `json:"ldap_needtls,omitempty" env:"SEMAPHORE_LDAP_NEEDTLS"` // Telegram, Slack, Rocket.Chat, Microsoft Teams, DingTalk, and Gotify alerting TelegramAlert bool `json:"telegram_alert,omitempty" env:"SEMAPHORE_TELEGRAM_ALERT"` TelegramChat string `json:"telegram_chat,omitempty" env:"SEMAPHORE_TELEGRAM_CHAT"` TelegramToken string `json:"telegram_token,omitempty" env:"SEMAPHORE_TELEGRAM_TOKEN"` SlackAlert bool `json:"slack_alert,omitempty" env:"SEMAPHORE_SLACK_ALERT"` SlackUrl string `json:"slack_url,omitempty" env:"SEMAPHORE_SLACK_URL"` RocketChatAlert bool `json:"rocketchat_alert,omitempty" env:"SEMAPHORE_ROCKETCHAT_ALERT"` RocketChatUrl string `json:"rocketchat_url,omitempty" env:"SEMAPHORE_ROCKETCHAT_URL"` MicrosoftTeamsAlert bool `json:"microsoft_teams_alert,omitempty" env:"SEMAPHORE_MICROSOFT_TEAMS_ALERT"` MicrosoftTeamsUrl string `json:"microsoft_teams_url,omitempty" env:"SEMAPHORE_MICROSOFT_TEAMS_URL"` DingTalkAlert bool `json:"dingtalk_alert,omitempty" env:"SEMAPHORE_DINGTALK_ALERT"` DingTalkUrl string `json:"dingtalk_url,omitempty" env:"SEMAPHORE_DINGTALK_URL"` GotifyAlert bool `json:"gotify_alert,omitempty" env:"SEMAPHORE_GOTIFY_ALERT"` GotifyUrl string `json:"gotify_url,omitempty" env:"SEMAPHORE_GOTIFY_URL"` GotifyToken string `json:"gotify_token,omitempty" env:"SEMAPHORE_GOTIFY_TOKEN"` // oidc settings OidcProviders map[string]OidcProvider `json:"oidc_providers,omitempty" env:"SEMAPHORE_OIDC_PROVIDERS"` MaxTaskDurationSec int `json:"max_task_duration_sec,omitempty" env:"SEMAPHORE_MAX_TASK_DURATION_SEC"` MaxTasksPerTemplate int `json:"max_tasks_per_template,omitempty" env:"SEMAPHORE_MAX_TASKS_PER_TEMPLATE"` // task concurrency MaxParallelTasks int `json:"max_parallel_tasks,omitempty" default:"10" rule:"^[0-9]{1,10}$" env:"SEMAPHORE_MAX_PARALLEL_TASKS"` RunnerRegistrationToken string `json:"runner_registration_token,omitempty" env:"SEMAPHORE_RUNNER_REGISTRATION_TOKEN"` // feature switches PasswordLoginDisable bool `json:"password_login_disable,omitempty" env:"SEMAPHORE_PASSWORD_LOGIN_DISABLED"` NonAdminCanCreateProject bool `json:"non_admin_can_create_project,omitempty" env:"SEMAPHORE_NON_ADMIN_CAN_CREATE_PROJECT"` UseRemoteRunner bool `json:"use_remote_runner,omitempty" env:"SEMAPHORE_USE_REMOTE_RUNNER"` IntegrationAlias string `json:"global_integration_alias,omitempty" env:"SEMAPHORE_INTEGRATION_ALIAS"` Apps map[string]App `json:"apps,omitempty" env:"SEMAPHORE_APPS"` Runner *RunnerConfig `json:"runner,omitempty"` EnvVars map[string]string `json:"env_vars,omitempty" env:"SEMAPHORE_ENV_VARS"` ForwardedEnvVars []string `json:"forwarded_env_vars,omitempty" env:"SEMAPHORE_FORWARDED_ENV_VARS"` Teams *TeamsConfig `json:"teams,omitempty"` Syslog *SyslogConfig `json:"syslog,omitempty"` Log *ConfigLog `json:"log,omitempty"` Process *ConfigProcess `json:"process,omitempty"` Schedule *ScheduleConfig `json:"schedule,omitempty"` Debugging *DebuggingConfig `json:"debugging,omitempty"` HA *HAConfig `json:"ha,omitempty"` // SubscriptionKey is a subscription key or token that can be set via config. // When this is set, subscription activation from the web interface is disabled. SubscriptionKey string `json:"subscription_key,omitempty" db:"-" env:"SEMAPHORE_SUBSCRIPTION_KEY"` SubscriptionKeyFile string `json:"subscription_key_file,omitempty" db:"-" env:"SEMAPHORE_SUBSCRIPTION_KEY_FILE"` Dirs *ConfigDirs `json:"dirs,omitempty"` } func NewConfigType() *ConfigType { return &ConfigType{ LdapMappings: &LdapMappings{}, } } // Config exposes the application configuration storage for use in the application var Config *ConfigType func ClearDir(dir string, preserveFiles bool, prefix string) error { d, err := os.Open(dir) if os.IsNotExist(err) { return nil } if err != nil { return err } defer d.Close() //nolint:errcheck files, err := d.ReadDir(0) if err != nil { return err } for _, f := range files { if preserveFiles && !f.IsDir() { continue } if prefix != "" && !strings.HasPrefix(f.Name(), prefix) { continue } err = os.RemoveAll(path.Join(dir, f.Name())) if err != nil { return err } } return nil } func (conf *ConfigType) ClearTmpDir() error { return ClearDir(conf.TmpPath, false, "") } func (conf *ConfigType) GetProjectTmpDir(projectID int) string { return path.Join(conf.TmpPath, fmt.Sprintf("project_%d", projectID)) } func (conf *ConfigType) ClearProjectTmpDir(projectID int) error { return ClearDir(conf.GetProjectTmpDir(projectID), false, "") } // ToJSON returns a JSON string of the config func (conf *ConfigType) ToJSON() ([]byte, error) { return json.MarshalIndent(&conf, " ", "\t") } // ConfigInit reads in cli flags, and switches actions appropriately on them func ConfigInit(configPath string, noConfigFile bool) (usedConfigPath *string) { //fmt.Println("Loading config") Config = NewConfigType() Config.Apps = map[string]App{} if !noConfigFile { usedConfigPath = loadConfigFile(configPath) } loadConfigEnvironment() loadConfigDefaults() //fmt.Println("Validating config") validateConfig() var encryption []byte hash, _ := base64.StdEncoding.DecodeString(Config.CookieHash) if len(Config.CookieEncryption) > 0 { encryption, _ = base64.StdEncoding.DecodeString(Config.CookieEncryption) } Cookie = securecookie.New(hash, encryption) if Config.WebHost != "" { var err error WebHostURL, err = url.Parse(Config.WebHost) if err != nil { panic(err) } if len(WebHostURL.String()) == 0 { WebHostURL = nil } } else { WebHostURL = nil } if Config.Runner != nil && Config.Runner.TokenFile != "" { runnerTokenBytes, err := os.ReadFile(Config.Runner.TokenFile) if err == nil { Config.Runner.Token = strings.TrimSpace(string(runnerTokenBytes)) } } if Config.SubscriptionKeyFile != "" { subscriptionKeyBytes, err := os.ReadFile(Config.SubscriptionKeyFile) if err != nil { panic(err) } Config.SubscriptionKey = strings.TrimSpace(string(subscriptionKeyBytes)) } return } func loadConfigFile(configPath string) (usedConfigPath *string) { if configPath == "" { configPath = os.Getenv("SEMAPHORE_CONFIG_PATH") } // If the configPath option has been set try to load and decode it // var usedPath string if configPath == "" { cwd, err := os.Getwd() exitOnConfigFileError(err) paths := []string{ path.Join(cwd, "config.json"), "/usr/local/etc/semaphore/config.json", "/etc/semaphore/config.json", } for _, p := range paths { _, err = os.Stat(p) if err != nil { continue } var file *os.File file, err = os.Open(p) if err != nil { continue } decodeConfig(file) usedConfigPath = &p break } exitOnConfigFileError(err) } else { p := configPath file, err := os.Open(p) exitOnConfigFileError(err) usedConfigPath = &p decodeConfig(file) } return } func loadDefaultsToObject(obj any) error { t := reflect.TypeOf(obj) v := reflect.ValueOf(obj) if t.Kind() == reflect.Ptr { t = t.Elem() v = reflect.Indirect(v) } for i := 0; i < t.NumField(); i++ { fieldInfo := t.Field(i) fieldValue := v.Field(i) if !fieldInfo.IsExported() { continue } fieldKind := fieldInfo.Type.Kind() isPtrToStruct := fieldKind == reflect.Ptr && fieldInfo.Type.Elem().Kind() == reflect.Struct if !fieldValue.IsZero() && fieldKind != reflect.Struct && fieldKind != reflect.Map && !isPtrToStruct { continue } if fieldKind == reflect.Struct { err := loadDefaultsToObject(fieldValue.Addr().Interface()) if err != nil { return err } continue } else if isPtrToStruct { if fieldValue.IsNil() { continue } err := loadDefaultsToObject(fieldValue.Interface()) if err != nil { return err } continue } else if fieldKind == reflect.Map { for _, key := range fieldValue.MapKeys() { val := fieldValue.MapIndex(key) if val.Type().Kind() != reflect.Struct { continue } newVal := reflect.New(val.Type()) pointerValue := newVal.Elem() pointerValue.Set(val) err := loadDefaultsToObject(newVal.Interface()) if err != nil { return err } fieldValue.SetMapIndex(key, newVal.Elem()) } continue } defaultVar := fieldInfo.Tag.Get("default") if defaultVar == "" { continue } setConfigValue(fieldValue, defaultVar) // defaultVar always string!!! } return nil } func loadConfigDefaults() { err := loadDefaultsToObject(Config) if err != nil { panic(err) } } func castStringToInt(value string) int { valueInt, err := strconv.Atoi(value) if err != nil { panic(err) } return valueInt } func castStringToBool(value string) bool { var valueBool bool if value == "1" || strings.ToLower(value) == "true" || strings.ToLower(value) == "yes" { valueBool = true } else { valueBool = false } return valueBool } func AssignMapToStruct[P *S, S any](m map[string]any, s P) error { v := reflect.ValueOf(s).Elem() return assignMapToStructRecursive(m, v) } func cloneStruct(origValue reflect.Value) reflect.Value { // Create a new instance of the same type as the original struct cloneValue := reflect.New(origValue.Type()).Elem() // Iterate over the fields of the struct for i := 0; i < origValue.NumField(); i++ { // Get the field value fieldValue := origValue.Field(i) // Set the field value in the clone cloneValue.Field(i).Set(fieldValue) } // Return the cloned struct return cloneValue } func assignMapToStructRecursive(m map[string]any, structValue reflect.Value) error { structType := structValue.Type() for i := 0; i < structType.NumField(); i++ { field := structType.Field(i) // Skip fields with db:"-" tag dbTag := field.Tag.Get("db") if dbTag == "-" { continue } jsonTag := field.Tag.Get("json") if jsonTag == "" { jsonTag = field.Name } else { jsonTag = strings.Split(jsonTag, ",")[0] } if value, ok := m[jsonTag]; ok { fieldValue := structValue.FieldByName(field.Name) if fieldValue.CanSet() { val := reflect.ValueOf(value) switch fieldValue.Kind() { case reflect.Struct: if val.Kind() != reflect.Map { return fmt.Errorf("expected map for nested struct field %s but got %T", field.Name, value) } mapValue, ok := value.(map[string]any) if !ok { return fmt.Errorf("cannot assign value of type %T to field %s of type %s", value, field.Name, field.Type) } err := assignMapToStructRecursive(mapValue, fieldValue) if err != nil { return err } case reflect.Slice: // Handle slice assignment fieldElemType := fieldValue.Type().Elem() var sourceSlice reflect.Value if val.Kind() == reflect.Slice || val.Kind() == reflect.Array { sourceSlice = val } else if val.Kind() == reflect.String { // Try to parse JSON array from string str := val.String() // First, try to unmarshal into []any var anyArr []any if err := json.Unmarshal([]byte(str), &anyArr); err == nil { sourceSlice = reflect.ValueOf(anyArr) } else if fieldElemType.Kind() == reflect.String { // Fallback: treat as single element string sourceSlice = reflect.MakeSlice(reflect.SliceOf(reflect.TypeOf("")), 1, 1) sourceSlice.Index(0).SetString(str) } else { return fmt.Errorf("expected slice or json array string for field %s but got %T", field.Name, value) } } else { return fmt.Errorf("expected slice for field %s but got %T", field.Name, value) } // Build destination slice newSlice := reflect.MakeSlice(fieldValue.Type(), 0, sourceSlice.Len()) for i := 0; i < sourceSlice.Len(); i++ { srcElemVal := sourceSlice.Index(i) // When source is []any, elements come as interface{}, unwrap reflect.Value if srcElemVal.Kind() == reflect.Interface && !srcElemVal.IsNil() { srcElemVal = reflect.ValueOf(srcElemVal.Interface()) } var dstElem reflect.Value // Prepare destination element if fieldElemType.Kind() == reflect.Struct { dstElem = reflect.New(fieldElemType).Elem() if srcElemVal.Kind() == reflect.Map { // Expect map[string]any mIface, ok := srcElemVal.Interface().(map[string]any) if !ok { return fmt.Errorf("cannot assign element of type %T to slice element of type %s", srcElemVal.Interface(), fieldElemType) } if err := assignMapToStructRecursive(mIface, dstElem); err != nil { return err } } else if srcElemVal.Type().ConvertibleTo(fieldElemType) { dstElem = srcElemVal.Convert(fieldElemType) } else { return fmt.Errorf("cannot assign element of type %s to slice element of type %s", srcElemVal.Type(), fieldElemType) } } else { // Primitive or other kinds if srcElemVal.Type().ConvertibleTo(fieldElemType) { dstElem = srcElemVal.Convert(fieldElemType) } else { newVal, converted := CastValueToKind(srcElemVal.Interface(), fieldElemType.Kind()) if !converted { return fmt.Errorf("cannot assign element of type %s to slice element of type %s", srcElemVal.Type(), fieldElemType) } dstElem = reflect.ValueOf(newVal) } } newSlice = reflect.Append(newSlice, dstElem) } fieldValue.Set(newSlice) case reflect.Map: if fieldValue.IsNil() { mapValue := reflect.MakeMap(fieldValue.Type()) fieldValue.Set(mapValue) } // Handle map if val.Kind() != reflect.Map { return fmt.Errorf("expected map for field %s but got %T", field.Name, value) } for _, key := range val.MapKeys() { mapElemValue := val.MapIndex(key) mapElemType := fieldValue.Type().Elem() srcVal := fieldValue.MapIndex(key) var mapElem reflect.Value if srcVal.IsValid() { mapElem = cloneStruct(srcVal) } else { mapElem = reflect.New(mapElemType).Elem() } if mapElemType.Kind() == reflect.Struct { if err := assignMapToStructRecursive(mapElemValue.Interface().(map[string]any), mapElem); err != nil { return err } } else { if mapElemValue.Type().ConvertibleTo(mapElemType) { mapElem.Set(mapElemValue.Convert(mapElemType)) } else { newVal, converted := CastValueToKind(mapElemValue.Interface(), mapElemType.Kind()) if !converted { return fmt.Errorf("cannot assign value of type %s to map element of type %s", mapElemValue.Type(), mapElemType) } mapElem.Set(reflect.ValueOf(newVal)) } } fieldValue.SetMapIndex(key, mapElem) } default: // Handle simple types if val.Type().ConvertibleTo(fieldValue.Type()) { fieldValue.Set(val.Convert(fieldValue.Type())) } else { newVal, converted := CastValueToKind(val.Interface(), fieldValue.Type().Kind()) if !converted { return fmt.Errorf("cannot assign value of type %s to map element of type %s", val.Type(), val) } fieldValue.Set(reflect.ValueOf(newVal)) } } } } } return nil } func CastValueToKind(value any, kind reflect.Kind) (res any, ok bool) { res = value switch kind { case reflect.String: // strings are always acceptable as-is, or will be coerced upstream ok = true case reflect.Int: if reflect.ValueOf(value).Kind() == reflect.Int { ok = true } else { res = castStringToInt(fmt.Sprintf("%v", reflect.ValueOf(value))) ok = true } case reflect.Bool: if reflect.ValueOf(value).Kind() == reflect.Bool { ok = true } else { res = castStringToBool(fmt.Sprintf("%v", reflect.ValueOf(value))) ok = true } default: } return } func setConfigValue(attribute reflect.Value, value string) { if attribute.IsValid() { kind := attribute.Kind() switch kind { case reflect.Slice: var arr []string err := json.Unmarshal([]byte(value), &arr) if err != nil { panic(err) } attribute.Set(reflect.ValueOf(arr)) case reflect.Map: mapType := attribute.Type() mapValue := reflect.New(mapType) err := json.Unmarshal([]byte(value), mapValue.Interface()) if err != nil { panic(err) } attribute.Set(mapValue.Elem()) default: newValue, _ := CastValueToKind(value, kind) convertedValue := reflect.ValueOf(newValue) if convertedValue.Type().AssignableTo(attribute.Type()) { attribute.Set(convertedValue) } else if convertedValue.Type().ConvertibleTo(attribute.Type()) { attribute.Set(convertedValue.Convert(attribute.Type())) } else { panic(fmt.Errorf("cannot assign value of type %s to field of type %s", convertedValue.Type(), attribute.Type())) } } } else { panic(fmt.Errorf("got non-existent config attribute")) } } func getConfigValue(path string) string { attribute := reflect.ValueOf(Config) nested_path := strings.Split(path, ".") for i, nested := range nested_path { attribute = reflect.Indirect(attribute).FieldByName(nested) lastDepth := len(nested_path) == i+1 if !lastDepth && attribute.Kind() != reflect.Struct && attribute.Kind() != reflect.Pointer || lastDepth && attribute.Kind() == reflect.Invalid { panic(fmt.Errorf("got non-existent config attribute '%v'", path)) } } return fmt.Sprintf("%v", attribute) } func validate(value any) error { t := reflect.TypeOf(value) v := reflect.ValueOf(value) if t.Kind() == reflect.Ptr { t = t.Elem() v = reflect.Indirect(v) } for i := 0; i < t.NumField(); i++ { fieldType := t.Field(i) fieldValue := v.Field(i) rule := fieldType.Tag.Get("rule") if rule == "" { continue } var strVal string if fieldType.Type.Kind() == reflect.Int { strVal = strconv.FormatInt(fieldValue.Int(), 10) } else if fieldType.Type.Kind() == reflect.Uint { strVal = strconv.FormatUint(fieldValue.Uint(), 10) } else { strVal = fieldValue.String() } match, _ := regexp.MatchString(rule, strVal) if match { continue } fieldName := strings.ToLower(fieldType.Name) if strings.Contains(fieldName, "password") || strings.Contains(fieldName, "secret") || strings.Contains(fieldName, "key") { strVal = "***" } return fmt.Errorf( "value of field '%v' is not valid: %v (Must match regex: '%v')", fieldType.Name, strVal, rule, ) } return nil } func validateConfig() { err := validate(Config) if err != nil { panic(err) } } func loadEnvironmentToObject(obj any) error { t := reflect.TypeOf(obj) v := reflect.ValueOf(obj) if t.Kind() == reflect.Ptr { t = t.Elem() v = reflect.Indirect(v) } for i := 0; i < t.NumField(); i++ { fieldType := t.Field(i) fieldValue := v.Field(i) if !fieldType.IsExported() { continue } if fieldType.Type.Kind() == reflect.Struct { err := loadEnvironmentToObject(fieldValue.Addr().Interface()) if err != nil { return err } continue } else if fieldType.Type.Kind() == reflect.Ptr && fieldType.Type.Elem().Kind() == reflect.Struct { if fieldValue.IsZero() { newValue := reflect.New(fieldType.Type.Elem()) fieldValue.Set(newValue) } envVar := fieldType.Tag.Get("env") if envVar != "" { if envValue, exists := os.LookupEnv(envVar); exists { newValue := reflect.New(fieldType.Type.Elem()) err := json.Unmarshal([]byte(envValue), newValue.Interface()) if err != nil { return err } fieldValue.Set(newValue) } } err := loadEnvironmentToObject(fieldValue.Interface()) if err != nil { return err } continue } envVar := fieldType.Tag.Get("env") if envVar == "" { continue } envValue, exists := os.LookupEnv(envVar) if !exists { continue } setConfigValue(fieldValue, envValue) // envValue always string!!! } return nil } func loadConfigEnvironment() { err := loadEnvironmentToObject(Config) if err != nil { panic(err) } } func exitOnConfigError(msg string) { fmt.Println(msg) os.Exit(1) } func exitOnConfigFileError(err error) { if err != nil { exitOnConfigError("Cannot Find configuration! Use --config parameter to point to a JSON file generated by `semaphore setup`.") } } func decodeConfig(file io.Reader) { if err := json.NewDecoder(file).Decode(&Config); err != nil { fmt.Println("Could not decode configuration!") panic(err) } } func mapToQueryString(m map[string]string) (str string) { for option, value := range m { if str != "" { str += "&" } str += option + "=" + value } if str != "" { str = "?" + str } return } // FindSemaphore looks in the PATH for the semaphore variable // if not found it will attempt to find the absolute path of the first // os argument, the semaphore command, and return it func FindSemaphore() string { cmdPath, _ := exec.LookPath("semaphore") //nolint: gas if len(cmdPath) == 0 { cmdPath, _ = filepath.Abs(os.Args[0]) // nolint: gas } return cmdPath } func AnsibleVersion() string { bytes, err := exec.Command("ansible", "--version").Output() if err != nil { return "" } return string(bytes) } // CheckUpdate uses the GitHub client to check for new tags in the semaphore repo func CheckUpdate() (updateAvailable *github.RepositoryRelease, err error) { // fetch releases gh := github.NewClient(nil) releases, _, err := gh.Repositories.ListReleases(context.TODO(), "semaphoreui", "semaphore", nil) if err != nil { return } updateAvailable = nil if (*releases[0].TagName)[1:] != Version() { updateAvailable = releases[0] } return } func (d *DbConfig) IsPresent() bool { return d.GetHostname() != "" } func (d *DbConfig) HasSupportMultipleDatabases() bool { return true } func (d *DbConfig) GetDbName() string { dbName := os.Getenv("SEMAPHORE_DB_NAME") if dbName != "" { return dbName } return d.DbName } func (d *DbConfig) GetUsername() string { username := os.Getenv("SEMAPHORE_DB_USER") if username != "" { return username } return d.Username } func (d *DbConfig) GetPassword() string { password := os.Getenv("SEMAPHORE_DB_PASS") if password != "" { return password } return d.Password } func (d *DbConfig) GetHostname() string { hostname := os.Getenv("SEMAPHORE_DB_HOST") if hostname != "" { return hostname } return d.Hostname } // GetConnectionString constructs the database connection string based on the current configuration. // It supports MySQL, BoltDB, and PostgreSQL dialects. // If the dialect is unsupported, it returns an error. // // Parameters: // - includeDbName: a boolean indicating whether to include the database name in the connection string. // // Returns: // - connectionString: the constructed database connection string. // - err: an error if the dialect is unsupported. func (d *DbConfig) GetConnectionString(includeDbName bool) (connectionString string, err error) { dbName := d.GetDbName() dbUser := d.GetUsername() dbPass := d.GetPassword() dbHost := d.GetHostname() switch d.Dialect { case DbDriverBolt: connectionString = dbHost case DbDriverMySQL: if includeDbName { connectionString = fmt.Sprintf( "%s:%s@tcp(%s)/%s", dbUser, dbPass, dbHost, dbName) } else { connectionString = fmt.Sprintf( "%s:%s@tcp(%s)/", dbUser, dbPass, dbHost) } options := map[string]string{ "parseTime": "true", "interpolateParams": "true", } for v, k := range d.Options { options[v] = k } connectionString += mapToQueryString(options) case DbDriverPostgres: if includeDbName { connectionString = fmt.Sprintf( "postgres://%s:%s@%s/%s", dbUser, url.QueryEscape(dbPass), dbHost, dbName) } else { connectionString = fmt.Sprintf( "postgres://%s:%s@%s/postgres", dbUser, url.QueryEscape(dbPass), dbHost) } connectionString += mapToQueryString(d.Options) case DbDriverSQLite: connectionString = "file:" + dbHost connectionString += mapToQueryString(d.Options) default: err = fmt.Errorf("unsupported database driver: %s", d.Dialect) } return } // PrintDbInfo prints the database connection information based on the current configuration. // It retrieves the database dialect and prints the corresponding connection details. // If the dialect is not found, it panics with an error message. func (conf *ConfigType) PrintDbInfo() { // Get the database dialect dialect, err := conf.GetDialect() if err != nil { panic(err) } // Print database connection information based on the dialect switch dialect { case DbDriverMySQL: fmt.Printf("MySQL %v@%v %v\n", conf.MySQL.GetUsername(), conf.MySQL.GetHostname(), conf.MySQL.GetDbName()) case DbDriverBolt: fmt.Printf("BoltDB %v\n", conf.BoltDb.GetHostname()) case DbDriverPostgres: fmt.Printf("Postgres %v@%v %v\n", conf.Postgres.GetUsername(), conf.Postgres.GetHostname(), conf.Postgres.GetDbName()) case DbDriverSQLite: fmt.Printf("SQLite %v@%v %v\n", conf.SQLite.GetUsername(), conf.SQLite.GetHostname(), conf.SQLite.GetDbName()) default: panic(fmt.Errorf("database configuration not found")) } } func (conf *ConfigType) GetDialect() (dialect string, err error) { if conf.Dialect == "" { switch { case conf.MySQL.IsPresent(): dialect = DbDriverMySQL case conf.BoltDb.IsPresent(): dialect = DbDriverBolt case conf.Postgres.IsPresent(): dialect = DbDriverPostgres case conf.SQLite.IsPresent(): dialect = DbDriverSQLite default: err = errors.New("database configuration not found") } return } dialect = conf.Dialect return } func (conf *ConfigType) GetDBConfig() (dbConfig DbConfig, err error) { var dialect string dialect, err = conf.GetDialect() if err != nil { return } switch dialect { case DbDriverBolt: dbConfig = *conf.BoltDb case DbDriverPostgres: dbConfig = *conf.Postgres case DbDriverSQLite: dbConfig = *conf.SQLite case DbDriverMySQL: dbConfig = *conf.MySQL default: err = errors.New("database configuration not found") } dbConfig.Dialect = dialect return } // GenerateSecrets generates cookie secret during setup func (conf *ConfigType) GenerateSecrets() { hash := securecookie.GenerateRandomKey(32) encryption := securecookie.GenerateRandomKey(32) accessKeyEncryption := securecookie.GenerateRandomKey(32) conf.CookieHash = base64.StdEncoding.EncodeToString(hash) conf.CookieEncryption = base64.StdEncoding.EncodeToString(encryption) conf.AccessKeyEncryption = base64.StdEncoding.EncodeToString(accessKeyEncryption) } var appCommands = map[string]string{ "ansible": "ansible-playbook", "terraform": "terraform", "tofu": "tofu", "terragrunt": "terragrunt", "bash": "bash", } var appPriorities = map[string]int{ "ansible": 1000, "terraform": 900, "tofu": 800, "terragrunt": 850, "bash": 700, "powershell": 600, "python": 500, } func LookupDefaultApps() { for appID, cmd := range appCommands { if _, ok := Config.Apps[appID]; ok { continue } _, err := exec.LookPath(cmd) if err != nil { continue } if Config.Apps == nil { Config.Apps = make(map[string]App) } Config.Apps[appID] = App{ Active: true, } } for k, v := range appPriorities { app := Config.Apps[k] if app.Priority <= 0 { app.Priority = v } Config.Apps[k] = app } } func GetPublicHost() string { aliasURL := Config.WebHost port := Config.Port if port == "" { port = "3000" } if strings.HasPrefix(port, ":") { port = port[1:] } if aliasURL == "" { aliasURL = "http://localhost:" + port } return aliasURL } func GetPublicAliasURL(scope string, alias string) string { aliasURL := GetPublicHost() if !strings.HasSuffix(aliasURL, "/") { aliasURL += "/" } aliasURL += "api/" + scope + "/" + alias return aliasURL } func GenerateRecoveryCode() (code string, hash string, err error) { buf := make([]byte, 10) _, err = io.ReadFull(rand.Reader, buf) if err != nil { return } code = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf) hashBytes, err := bcrypt.GenerateFromPassword([]byte(code), bcrypt.DefaultCost) if err != nil { return } hash = string(hashBytes) return } func VerifyRecoveryCode(inputCode, storedHash string) bool { err := bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(inputCode)) return err == nil } ================================================ FILE: util/config_assign_test.go ================================================ package util import ( "encoding/json" "reflect" "testing" ) func TestAssignMapToStruct_SlicesAndConversions(t *testing.T) { type Item struct { K string `json:"k"` V int `json:"v"` } type Sample struct { Names []string `json:"names"` Numbers []int `json:"numbers"` Objects []Item `json:"objects"` Enabled bool `json:"enabled"` Count int `json:"count"` Settings map[string]Item `json:"settings"` } t.Run("primitive slice from json string and fallback single string", func(t *testing.T) { var s Sample m := map[string]any{ "names": "[\"a\",\"b\"]", } if err := AssignMapToStruct(m, &s); err != nil { t.Fatalf("unexpected error: %v", err) } if !reflect.DeepEqual(s.Names, []string{"a", "b"}) { t.Fatalf("names mismatch: %+v", s.Names) } // fallback: non-JSON string becomes single element when elem type is string s = Sample{} m = map[string]any{"names": "hello"} if err := AssignMapToStruct(m, &s); err != nil { t.Fatalf("unexpected error: %v", err) } if !reflect.DeepEqual(s.Names, []string{"hello"}) { t.Fatalf("names fallback mismatch: %+v", s.Names) } }) t.Run("int slice with mixed string/int and coercion", func(t *testing.T) { var s Sample // input as a real slice ([]any) with mixed types src := []any{"1", 2, "3"} m := map[string]any{"numbers": src} if err := AssignMapToStruct(m, &s); err != nil { t.Fatalf("unexpected error: %v", err) } if !reflect.DeepEqual(s.Numbers, []int{1, 2, 3}) { t.Fatalf("numbers mismatch: %+v", s.Numbers) } // input as JSON string s = Sample{} jsonStr := "[\"4\",5,\"6\"]" m = map[string]any{"numbers": jsonStr} if err := AssignMapToStruct(m, &s); err != nil { t.Fatalf("unexpected error: %v", err) } if !reflect.DeepEqual(s.Numbers, []int{4, 5, 6}) { t.Fatalf("numbers from json mismatch: %+v", s.Numbers) } }) t.Run("slice of structs from []map and JSON string of maps", func(t *testing.T) { var s Sample objs := []any{ map[string]any{"k": "a", "v": 1}, map[string]any{"k": "b", "v": "2"}, // v as string, should coerce to int } m := map[string]any{"objects": objs} if err := AssignMapToStruct(m, &s); err != nil { t.Fatalf("unexpected error: %v", err) } if len(s.Objects) != 2 || s.Objects[0].K != "a" || s.Objects[0].V != 1 || s.Objects[1].K != "b" || s.Objects[1].V != 2 { t.Fatalf("objects mismatch: %+v", s.Objects) } // JSON string input s = Sample{} arr := []map[string]any{{"k": "x", "v": 7}, {"k": "y", "v": 8}} b, _ := json.Marshal(arr) m = map[string]any{"objects": string(b)} if err := AssignMapToStruct(m, &s); err != nil { t.Fatalf("unexpected error: %v", err) } if len(s.Objects) != 2 || s.Objects[0].K != "x" || s.Objects[0].V != 7 || s.Objects[1].K != "y" || s.Objects[1].V != 8 { t.Fatalf("objects from json mismatch: %+v", s.Objects) } }) t.Run("map update preserves existing nested struct fields", func(t *testing.T) { type Detail struct{ Value string `json:"value"` Description string `json:"description"` } type Holder struct{ Details map[string]Detail `json:"details"` } h := Holder{Details: map[string]Detail{ "interests": {Value: "politics", Description: "Follows current events"}, }} m := map[string]any{"details": map[string]any{ "interests": map[string]any{"description": "Ho ho ho"}, }} if err := AssignMapToStruct(m, &h); err != nil { t.Fatalf("unexpected error: %v", err) } if h.Details["interests"].Value != "politics" || h.Details["interests"].Description != "Ho ho ho" { t.Fatalf("map preservation failed: %+v", h.Details["interests"]) } }) t.Run("primitive field conversions string->int and string->bool", func(t *testing.T) { type Conv struct { I int `json:"i"` B bool `json:"b"` } var c Conv m := map[string]any{"i": "42", "b": "true"} if err := AssignMapToStruct(m, &c); err != nil { t.Fatalf("unexpected error: %v", err) } if c.I != 42 || c.B != true { t.Fatalf("conversions mismatch: %+v", c) } }) t.Run("error cases: wrong types for struct and slices", func(t *testing.T) { // wrong nested struct source type Nested struct{ A struct{ X int } `json:"a"` } var n Nested if err := AssignMapToStruct(map[string]any{"a": 123}, &n); err == nil { t.Fatalf("expected error for non-map nested struct input") } // wrong slice: non-JSON string for []int should error type S struct{ N []int `json:"n"` } var s S if err := AssignMapToStruct(map[string]any{"n": "not-json"}, &s); err == nil { t.Fatalf("expected error for non-JSON string to []int") } }) } func TestAssignMapToStruct_MapPrimitiveConversions(t *testing.T) { type C struct{ M map[string]int `json:"m"` } var c C m := map[string]any{"m": map[string]any{"a": "1", "b": 2}} if err := AssignMapToStruct(m, &c); err != nil { t.Fatalf("unexpected error: %v", err) } if len(c.M) != 2 || c.M["a"] != 1 || c.M["b"] != 2 { t.Fatalf("map primitive conversions mismatch: %+v", c.M) } } func TestAssignMapToStruct_SkipsDbMinusTag(t *testing.T) { type Sample struct { Name string `json:"name"` Password string `json:"password" db:"-"` Age int `json:"age"` Secret string `json:"secret" db:"-"` } t.Run("fields with db:- tag should not be assigned", func(t *testing.T) { s := Sample{ Name: "original", Password: "original_password", Age: 25, Secret: "original_secret", } m := map[string]any{ "name": "updated", "password": "new_password", "age": 30, "secret": "new_secret", } if err := AssignMapToStruct(m, &s); err != nil { t.Fatalf("unexpected error: %v", err) } // Fields without db:"-" should be updated if s.Name != "updated" { t.Errorf("expected Name to be 'updated', got '%s'", s.Name) } if s.Age != 30 { t.Errorf("expected Age to be 30, got %d", s.Age) } // Fields with db:"-" should retain original values if s.Password != "original_password" { t.Errorf("expected Password to remain 'original_password', got '%s'", s.Password) } if s.Secret != "original_secret" { t.Errorf("expected Secret to remain 'original_secret', got '%s'", s.Secret) } }) t.Run("nested struct with db:- tag fields", func(t *testing.T) { type Inner struct { Public string `json:"public"` Private string `json:"private" db:"-"` } type Outer struct { Inner Inner `json:"inner"` } o := Outer{ Inner: Inner{ Public: "original_public", Private: "original_private", }, } m := map[string]any{ "inner": map[string]any{ "public": "updated_public", "private": "updated_private", }, } if err := AssignMapToStruct(m, &o); err != nil { t.Fatalf("unexpected error: %v", err) } if o.Inner.Public != "updated_public" { t.Errorf("expected Inner.Public to be 'updated_public', got '%s'", o.Inner.Public) } if o.Inner.Private != "original_private" { t.Errorf("expected Inner.Private to remain 'original_private', got '%s'", o.Inner.Private) } }) } func TestSetConfigValue_SliceAndMap(t *testing.T) { // This ensures setConfigValue (used by defaults/env) is compatible with slice/map JSON type X struct { Arr []string Mp map[string]int I int } var x X // slice setConfigValue(reflect.ValueOf(&x).Elem().FieldByName("Arr"), "[\"a\",\"b\"]") if !reflect.DeepEqual(x.Arr, []string{"a", "b"}) { t.Fatalf("setConfigValue slice mismatch: %+v", x.Arr) } // map setConfigValue(reflect.ValueOf(&x).Elem().FieldByName("Mp"), "{\"a\":1}") if x.Mp["a"] != 1 { t.Fatalf("setConfigValue map mismatch: %+v", x.Mp) } // primitive setConfigValue(reflect.ValueOf(&x).Elem().FieldByName("I"), "123") if x.I != 123 { t.Fatalf("setConfigValue primitive mismatch: %+v", x.I) } } ================================================ FILE: util/config_auth.go ================================================ package util type RecaptchaConfig struct { Enabled string `json:"enabled,omitempty" env:"SEMAPHORE_RECAPTCHA_ENABLED"` SiteKey string `json:"site_key,omitempty" env:"SEMAPHORE_RECAPTCHA_SITE_KEY"` } type EmailAuthConfig struct { Enabled bool `json:"enabled" env:"SEMAPHORE_EMAIL_2TP_ENABLED"` AllowLoginAsExternalUser bool `json:"allow_login_as_external_user" env:"SEMAPHORE_EMAIL_2TP_ALLOW_LOGIN_AS_EXTERNAL_USER"` AllowCreateExternalUsers bool `json:"allow_create_external_user" env:"SEMAPHORE_EMAIL_2TP_ALLOW_CREATE_EXTERNAL_USER"` AllowedDomains []string `json:"allowed_domains" env:"SEMAPHORE_EMAIL_2TP_ALLOWED_DOMAINS"` DisableForOidc bool `json:"disable_for_oidc" env:"SEMAPHORE_EMAIL_2TP_DISABLE_FOR_OIDC"` } type AuthConfig struct { Totp *TotpConfig `json:"totp,omitempty"` Email *EmailAuthConfig `json:"email,omitempty"` } ================================================ FILE: util/config_sysproc.go ================================================ //go:build !windows package util import ( "os/user" "strconv" "syscall" ) func (conf *ConfigType) GetSysProcAttr() (res *syscall.SysProcAttr) { if conf.Process.Chroot != "" { res = &syscall.SysProcAttr{} res.Chroot = conf.Process.Chroot } var uid *int var gid *int uid = nil gid = conf.Process.GID if conf.Process.User != "" { usr, err := user.Lookup(conf.Process.User) if err != nil { return } u, err := strconv.Atoi(usr.Uid) if err != nil { return } g, err := strconv.Atoi(usr.Gid) if err != nil { return } uid = &u gid = &g } if uid != nil && gid != nil { if res == nil { res = &syscall.SysProcAttr{} } res.Credential = &syscall.Credential{ Uid: uint32(*uid), Gid: uint32(*gid), } } return } ================================================ FILE: util/config_sysproc_windows.go ================================================ //go:build windows package util import ( "syscall" ) func (conf *ConfigType) GetSysProcAttr() (res *syscall.SysProcAttr) { return } ================================================ FILE: util/config_test.go ================================================ package util import ( "fmt" "os" "reflect" "strconv" "testing" ) func mockError(msg string) { panic(msg) } func TestValidate(t *testing.T) { var val struct { Test string `rule:"^\\d+$"` } val.Test = "45243524" err := validate(val) if err != nil { t.Error(err) } } func TestLoadEnvironmentToObject(t *testing.T) { var val struct { Flag bool `env:"TEST_FLAG"` Test string `env:"TEST_ENV_VAR"` Subfield struct { Value string `env:"TEST_VALUE_ENV_VAR"` } StringArr []string `env:"TEST_STRING_ARR"` } err := os.Setenv("TEST_FLAG", "yes") if err != nil { panic(err) } err = os.Setenv("TEST_ENV_VAR", "758478") if err != nil { panic(err) } err = os.Setenv("TEST_VALUE_ENV_VAR", "test_value") if err != nil { panic(err) } err = os.Setenv("TEST_STRING_ARR", "[\"test1\",\"test2\"]") if err != nil { panic(err) } err = loadEnvironmentToObject(&val) if err != nil { t.Error(err) } if val.Flag != true { t.Error("Invalid value") } if val.Test != "758478" { t.Error("Invalid value") } if val.Subfield.Value != "test_value" { t.Error("Invalid value") } if val.StringArr == nil { t.Error("Invalid array value") } if val.StringArr[0] != "test1" { t.Error("Invalid array item value") } if val.StringArr[1] != "test2" { t.Error("Invalid array item value") } } func TestLoadEnvironmentToObject_Arr(t *testing.T) { var val struct { StringArr []string `env:"TEST_STRING_ARR"` } err := os.Setenv("TEST_STRING_ARR", "[\"test1\",\"test2\"]") if err != nil { panic(err) } err = loadEnvironmentToObject(&val) if err != nil { t.Error(err) } if val.StringArr == nil { t.Error("Invalid array value") } if val.StringArr[0] != "test1" { t.Error("Invalid array item value") } if val.StringArr[1] != "test2" { t.Error("Invalid array item value") } } func TestLoadEnvironmentToObject_Map(t *testing.T) { type User struct { Name string `json:"name"` Age int `json:"age"` } var val struct { Users map[string]User `env:"TEST_USERS"` } err := os.Setenv("TEST_USERS", "{\"test\":{\"name\":\"test\",\"age\":5}}") if err != nil { panic(err) } err = loadEnvironmentToObject(&val) if val.Users["test"].Name != "test" { t.Error("Invalid field value") } } func TestCastStringToInt(t *testing.T) { errMsg := "Cast string => int failed" if castStringToInt("5") != 5 { t.Error(errMsg) } if castStringToInt("0") != 0 { t.Error(errMsg) } if castStringToInt("-1") != -1 { t.Error(errMsg) } if castStringToInt("999") != 999 { t.Error(errMsg) } defer func() { if r := recover(); r == nil { t.Errorf("Cast string => int did not panic on invalid input") } }() castStringToInt("xxx") } func TestCastStringToBool(t *testing.T) { errMsg := "Cast string => bool failed" if castStringToBool("1") != true { t.Error(errMsg) } if castStringToBool("0") != false { t.Error(errMsg) } if castStringToBool("true") != true { t.Error(errMsg) } if castStringToBool("false") != false { t.Error(errMsg) } if castStringToBool("xxx") != false { t.Error(errMsg) } if castStringToBool("") != false { t.Error(errMsg) } } func TestConfigInitialization(t *testing.T) { testLdapMappingsUID := "uid" Config = NewConfigType() // should not panic Config.LdapMappings.UID = testLdapMappingsUID } func TestGetConfigValue(t *testing.T) { Config = NewConfigType() testPort := "1337" testCookieHash := "0Sn+edH3doJ4EO4Rl49Y0KrxjUkXuVtR5zKHGGWerxQ=" testMaxParallelTasks := 5 testLdapNeedTls := true testDbHost := "192.168.0.1" Config.Port = testPort Config.CookieHash = testCookieHash Config.MaxParallelTasks = testMaxParallelTasks Config.LdapNeedTLS = testLdapNeedTls Config.BoltDb = &DbConfig{ Hostname: testDbHost, } if getConfigValue("Port") != testPort { t.Error("Could not get value for config attribute 'Port'!") } if getConfigValue("CookieHash") != testCookieHash { t.Error("Could not get value for config attribute 'CookieHash'!") } if getConfigValue("MaxParallelTasks") != fmt.Sprintf("%v", testMaxParallelTasks) { t.Error("Could not get value for config attribute 'MaxParallelTasks'!") } if getConfigValue("LdapNeedTLS") != fmt.Sprintf("%v", testLdapNeedTls) { t.Error("Could not get value for config attribute 'LdapNeedTLS'!") } if getConfigValue("BoltDb.Hostname") != fmt.Sprintf("%v", testDbHost) { t.Error("Could not get value for config attribute 'BoltDb.Hostname'!") } defer func() { if r := recover(); r == nil { t.Error("Did not fail on non-existent config attribute!") } }() getConfigValue("NotExistent") defer func() { if r := recover(); r == nil { t.Error("Did not fail on non-existent config attribute!") } }() getConfigValue("Not.Existent") } func TestSetConfigValue(t *testing.T) { Config = new(ConfigType) configValue := reflect.ValueOf(Config).Elem() testPort := "1337" testCookieHash := "0Sn+edH3doJ4EO4Rl49Y0KrxjUkXuVtR5zKHGGWerxQ=" testMaxParallelTasks := 5 testLdapNeedTls := true // var testDbHost string = "192.168.0.1" testEmailSecure := "1" expectEmailSecure := true setConfigValue(configValue.FieldByName("Port"), testPort) setConfigValue(configValue.FieldByName("CookieHash"), testCookieHash) setConfigValue(configValue.FieldByName("MaxParallelTasks"), strconv.Itoa(testMaxParallelTasks)) setConfigValue(configValue.FieldByName("LdapNeedTLS"), "true") // setConfigValue(configValue.FieldByName("BoltDb.Hostname"), testDbHost) setConfigValue(configValue.FieldByName("EmailSecure"), testEmailSecure) if Config.Port != testPort { t.Error("Could not set value for config attribute 'Port'!") } if Config.CookieHash != testCookieHash { t.Error("Could not set value for config attribute 'CookieHash'!") } if Config.MaxParallelTasks != testMaxParallelTasks { t.Error("Could not set value for config attribute 'MaxParallelTasks'!") } if Config.LdapNeedTLS != testLdapNeedTls { t.Error("Could not set value for config attribute 'LdapNeedTls'!") } //if Config.BoltDb.Hostname != testDbHost { // t.Error("Could not set value for config attribute 'BoltDb.Hostname'!") //} if Config.EmailSecure != expectEmailSecure { t.Error("Could not set value for config attribute 'EmailSecure'!") } defer func() { if r := recover(); r == nil { t.Error("Did not fail on non-existent config attribute!") } }() setConfigValue(configValue.FieldByName("NotExistent"), "someValue") defer func() { if r := recover(); r == nil { t.Error("Did not fail on non-existent config attribute!") } }() // setConfigValue(configValue.FieldByName("Not.Existent"), "someValue") } func TestLoadConfigEnvironmet(t *testing.T) { Config = new(ConfigType) Config.BoltDb = &DbConfig{} Config.Dialect = DbDriverBolt envPort := "1337" envCookieHash := "0Sn+edH3doJ4EO4Rl49Y0KrxjUkXuVtR5zKHGGWerxQ=" envAccessKeyEncryption := "1/wRYXQltDGwbzNZRP9ZfJb2IoWcn1hYrxA0vOdvVos=" envMaxParallelTasks := "5" expectMaxParallelTasks := 5 expectLdapNeedTls := true envLdapNeedTls := "1" envDbHost := "192.168.0.1" os.Setenv("SEMAPHORE_PORT", envPort) //nolint:errcheck os.Setenv("SEMAPHORE_COOKIE_HASH", envCookieHash) //nolint:errcheck os.Setenv("SEMAPHORE_ACCESS_KEY_ENCRYPTION", envAccessKeyEncryption) //nolint:errcheck os.Setenv("SEMAPHORE_MAX_PARALLEL_TASKS", envMaxParallelTasks) //nolint:errcheck os.Setenv("SEMAPHORE_LDAP_NEEDTLS", envLdapNeedTls) //nolint:errcheck os.Setenv("SEMAPHORE_DB_HOST", envDbHost) //nolint:errcheck loadConfigEnvironment() if Config.Port != envPort { t.Error("Setting 'Port' was not loaded from environment-vars!") } if Config.CookieHash != envCookieHash { t.Error("Setting 'CookieHash' was not loaded from environment-vars!") } if Config.AccessKeyEncryption != envAccessKeyEncryption { t.Error("Setting 'AccessKeyEncryption' was not loaded from environment-vars!") } if Config.MaxParallelTasks != expectMaxParallelTasks { t.Error("Setting 'MaxParallelTasks' was not loaded from environment-vars!") } if Config.LdapNeedTLS != expectLdapNeedTls { t.Error("Setting 'LdapNeedTLS' was not loaded from environment-vars!") } if Config.BoltDb.Hostname != envDbHost { t.Error("Setting 'BoltDb.Hostname' was not loaded from environment-vars!") } //if Config.MySQL.Hostname == envDbHost || Config.Postgres.Hostname == envDbHost { // // inactive db-dialects could be set as they share the same env-vars; but should be ignored // t.Error("DB-Hostname was loaded for inactive DB-dialects!") //} } func TestLoadConfigDefaults(t *testing.T) { Config = new(ConfigType) errMsg := "Failed to load config-default" loadConfigDefaults() if Config.Port != ":3000" { t.Error(errMsg) } if Config.TmpPath != "/tmp/semaphore" { t.Error(errMsg) } } func ensureConfigValidationFailure(t *testing.T, attribute string, value any) { defer func() { if r := recover(); r == nil { t.Errorf( "Config validation for attribute '%v' did not fail! (value '%v')", attribute, value, ) } }() validateConfig() } func TestValidateConfig(t *testing.T) { // assert := assert.New(t) Config = new(ConfigType) testPort := ":3000" testDbDialect := DbDriverBolt testCookieHash := "0Sn+edH3doJ4EO4Rl49Y0KrxjUkXuVtR5zKHGGWerxQ=" testMaxParallelTasks := 0 testEmailTlsMinVersion := "1.2" Config.Port = testPort Config.Dialect = testDbDialect Config.CookieHash = testCookieHash Config.MaxParallelTasks = testMaxParallelTasks Config.GitClientId = GoGitClientId Config.CookieEncryption = testCookieHash Config.AccessKeyEncryption = testCookieHash Config.EmailTlsMinVersion = testEmailTlsMinVersion validateConfig() Config.Port = "INVALID" ensureConfigValidationFailure(t, "Port", Config.Port) Config.Port = ":100000" ensureConfigValidationFailure(t, "Port", Config.Port) Config.Port = testPort Config.MaxParallelTasks = -1 ensureConfigValidationFailure(t, "MaxParallelTasks", Config.MaxParallelTasks) ensureConfigValidationFailure(t, "MaxParallelTasks", Config.MaxParallelTasks) Config.MaxParallelTasks = testMaxParallelTasks // Config.CookieHash = "\"0Sn+edH3doJ4EO4Rl49Y0KrxjUkXuVtR5zKHGGWerxQ=\"" // invalid with quotes (can happen when supplied as env-var) // ensureConfigValidationFailure(t, "CookieHash", Config.CookieHash) // Config.CookieHash = "!)394340" // ensureConfigValidationFailure(t, "CookieHash", Config.CookieHash) // Config.CookieHash = "" // ensureConfigValidationFailure(t, "CookieHash", Config.CookieHash) // Config.CookieHash = "TQwjDZ5fIQtaIw==" // valid b64, but too small // ensureConfigValidationFailure(t, "CookieHash", Config.CookieHash) Config.CookieHash = testCookieHash Config.Dialect = "someOtherDB" ensureConfigValidationFailure(t, "Dialect", Config.Dialect) Config.Dialect = testDbDialect } ================================================ FILE: util/debug.go ================================================ package util import ( log "github.com/sirupsen/logrus" "runtime" "strconv" "strings" ) func Goid() (int, error) { var buf [64]byte n := runtime.Stack(buf[:], false) idField := strings.Fields(strings.TrimPrefix(string(buf[:n]), "goroutine "))[0] id, err := strconv.Atoi(idField) if err != nil { log.Debug("Cannot get goroutine id: ", err) return -1, err } return id, nil } func LogGoid(msg string) { id, err := Goid() if err == nil { log.Info(msg, ", goid=", id) } } ================================================ FILE: util/encryption.go ================================================ package util import ( "bufio" "bytes" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/pem" "io" ) func GeneratePrivateKey(privateKeyFile io.Writer) (publicKey string, err error) { // 1. Generate RSA Private Key (2048 bits) privateKey, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return } // 2. Encode the private key to PKCS#1 ASN.1 PEM privateKeyBytes := x509.MarshalPKCS1PrivateKey(privateKey) privateKeyPem := &pem.Block{ Type: "RSA PRIVATE KEY", Bytes: privateKeyBytes, } // 3. Write private key to file if err = pem.Encode(privateKeyFile, privateKeyPem); err != nil { return } publicKeyBytes := x509.MarshalPKCS1PublicKey(&privateKey.PublicKey) publicKeyPem := &pem.Block{ Type: "PUBLIC KEY", Bytes: publicKeyBytes, } var b bytes.Buffer publicKeyFile := bufio.NewWriter(&b) if err = pem.Encode(publicKeyFile, publicKeyPem); err != nil { return } publicKeyFile.Flush() publicKey = b.String() return } ================================================ FILE: util/errorLogging.go ================================================ package util import ( log "github.com/sirupsen/logrus" ) // LogWarning logs a warning with arbitrary field if error func LogWarning(err error) { LogWarningF(err, log.Fields{"level": "Warn"}) } // LogDebugF logs a debug with added field context if error func LogDebugF(err error, fields log.Fields) { if err != nil { log.WithFields(fields).Debug(err.Error()) } } // LogWarningF logs a warning with added field context if error func LogWarningF(err error, fields log.Fields) { if err != nil { log.WithFields(fields).Warn(err.Error()) } } // LogError logs an error with arbitrary field if error func LogError(err error) { LogErrorF(err, log.Fields{"level": "Error"}) } // LogErrorF logs a error with added field context if error func LogErrorF(err error, fields log.Fields) { if err != nil { log.WithFields(fields).Error(err.Error()) } } // LogPanic logs and panics with arbitrary field if error func LogPanic(err error) { LogPanicF(err, log.Fields{"level": "Panic"}) } // LogPanicF logs and panics with added field context if error func LogPanicF(err error, fields log.Fields) { if err != nil { log.WithFields(fields).Panic(err.Error()) } } ================================================ FILE: util/mailer/auth.go ================================================ package mailer import ( "bytes" "errors" "fmt" "net/smtp" "slices" ) func PlainOrLoginAuth(username, password, host string) smtp.Auth { return &plainOrLoginAuth{username: username, password: password, host: host} } func isLocalhost(name string) bool { return name == "localhost" || name == "127.0.0.1" || name == "::1" } type plainOrLoginAuth struct { username string password string host string authMethod string } func (a *plainOrLoginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { // Must have TLS, or else localhost server. // Note: If TLS is not true, then we can't trust ANYTHING in ServerInfo. // In particular, it doesn't matter if the server advertises PLAIN auth. // That might just be the attacker saying // "it's ok, you can trust me with your password." if !server.TLS && !isLocalhost(server.Name) { return "", nil, errors.New("unencrypted connection") } if server.Name != a.host { return "", nil, errors.New("wrong host name") } if !slices.Contains(server.Auth, "PLAIN") { a.authMethod = "LOGIN" return a.authMethod, nil, nil } else { a.authMethod = "PLAIN" resp := []byte("\x00" + a.username + "\x00" + a.password) return a.authMethod, resp, nil } } func (a *plainOrLoginAuth) Next(fromServer []byte, more bool) ([]byte, error) { if !more { return nil, nil } if a.authMethod == "PLAIN" { // We've already sent everything. return nil, errors.New("unexpected server challenge") } switch { case bytes.Equal(fromServer, []byte("Username:")): return []byte(a.username), nil case bytes.Equal(fromServer, []byte("Password:")): return []byte(a.password), nil default: return nil, fmt.Errorf("unexpected server challenge: %s", fromServer) } } ================================================ FILE: util/mailer/mailer.go ================================================ package mailer import ( "bytes" "crypto/tls" "fmt" "net" "net/smtp" "strings" "text/template" "time" "github.com/semaphoreui/semaphore/pkg/tz" "github.com/semaphoreui/semaphore/util" ) const ( mailerBase = "MIME-version: 1.0\r\n" + "Content-Type: text/html; charset=UTF-8\r\n" + //"Content-Transfer-Encoding: quoted-printable\r\n" + "Date: {{ .Date }}\r\n" + "To: {{ .To }}\r\n" + "From: {{ .From }}\r\n" + "Subject: {{ .Subject }}\r\n\r\n" + "{{ .Body }}" ) var r = strings.NewReplacer( "\r\n", "", "\r", "", "\n", "", "%0a", "", "%0d", "", ) func parseTlsVersion(version string) (uint16, error) { switch version { case "1.0": return tls.VersionTLS10, nil case "1.1": return tls.VersionTLS11, nil case "1.2": return tls.VersionTLS12, nil case "1.3": return tls.VersionTLS13, nil } return 0, fmt.Errorf("Unsupported TLS version %s", version) } // Send simply sends the defined mail via SMTP. func Send( secure bool, useTls bool, host string, port string, username, password, from, to, subject string, content string, ) error { body := bytes.NewBufferString("") tpl, err := template.New("").Parse(mailerBase) if err != nil { return err } err = tpl.Execute(body, struct { Date string To string From string Subject string Body string }{ Date: tz.Now().Format(time.RFC1123), To: r.Replace(to), From: r.Replace(from), Subject: r.Replace(subject), Body: content, }) if err != nil { return err } if secure { if useTls { return sendTls( host, port, username, password, from, to, body, ) } else { return plainauth( host, port, username, password, from, to, body, ) } } return anonymous( host, port, from, to, body, ) } func plainauth( host string, port string, username string, password string, from string, to string, body *bytes.Buffer, ) error { auth := PlainOrLoginAuth(username, password, host) // auth := smtp.PlainAuth("", username, password, host) return smtp.SendMail( net.JoinHostPort(host, port), auth, from, []string{to}, body.Bytes(), ) } func sendTls( host, port, username, password, from, to string, body *bytes.Buffer, ) error { auth := PlainOrLoginAuth(username, password, host) tlsVersion, err := parseTlsVersion(util.Config.EmailTlsMinVersion) if err != nil { return err } tlsConfig := &tls.Config{ InsecureSkipVerify: false, ServerName: host, MinVersion: tlsVersion, } // Here is the key, you need to call tls.Dial instead of smtp.Dial // for smtp servers running on 465 that require an ssl connection // from the very beginning (no starttls) conn, err := tls.Dial("tcp", net.JoinHostPort(host, port), tlsConfig) if err != nil { return err } c, err := smtp.NewClient(conn, host) if err != nil { return err } if err = c.Auth(auth); err != nil { return err } if err = c.Mail(from); err != nil { return err } if err = c.Rcpt(to); err != nil { return err } w, err := c.Data() if err != nil { return err } _, err = w.Write(body.Bytes()) if err != nil { return err } err = w.Close() if err != nil { return err } err = c.Quit() if err != nil { return err } return nil } func anonymous( host string, port string, from string, to string, body *bytes.Buffer, ) error { c, err := smtp.Dial(net.JoinHostPort(host, port)) if err != nil { return err } defer c.Close() //nolint:errcheck if err := c.Mail(r.Replace(from)); err != nil { return err } if err = c.Rcpt(r.Replace(to)); err != nil { return err } w, err := c.Data() if err != nil { return err } defer w.Close() //nolint:errcheck if _, err := body.WriteTo(w); err != nil { return err } return nil } ================================================ FILE: util/shell.go ================================================ package util import ( "regexp" "strings" "unicode" ) // Imported from https://github.com/alessio/shellescape/blob/master/shellescape.go // Credits goes to https://github.com/alessio/shellescape maintainers var shellQuotePattern *regexp.Regexp func init() { shellQuotePattern = regexp.MustCompile(`[^\w@%+=:,./-]`) } // Quote returns a shell-escaped version of the string s. The returned value // is a string that can safely be used as one token in a shell command line. func ShellQuote(s string) string { if len(s) == 0 { return "''" } if shellQuotePattern.MatchString(s) { return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" } return s } // StripUnsafe remove non-printable runes, e.g. control characters in // a string that is meant for consumption by terminals that support // control characters. func ShellStripUnsafe(s string) string { return strings.Map(func(r rune) rune { if unicode.IsPrint(r) { return r } return -1 }, s) } ================================================ FILE: util/test_helpers.go ================================================ package util import ( "github.com/semaphoreui/semaphore/pkg/tz" "math/rand" ) //HELPERS // https://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-golang var src = rand.NewSource(tz.Now().UnixNano()) const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" const ( letterIdxBits = 6 // 6 bits to represent a letter index letterIdxMask = 1<<letterIdxBits - 1 // All 1-bits, as many as letterIdxBits letterIdxMax = 63 / letterIdxBits // # of letter indices fitting in 63 bits ) func RandString(n int) string { b := make([]byte, n) // A src.Int63() generates 63 random bits, enough for letterIdxMax characters! for i, cache, remain := n-1, src.Int63(), letterIdxMax; i >= 0; { if remain == 0 { cache, remain = src.Int63(), letterIdxMax } if idx := int(cache & letterIdxMask); idx < len(letterBytes) { b[i] = letterBytes[idx] i-- } cache >>= letterIdxBits remain-- } return string(b) } ================================================ FILE: util/version.go ================================================ package util import ( "strings" ) var ( Ver = "undefined" Commit = "00000000" Date = "" ) func Version() string { return strings.Join([]string{ Ver, Commit, Date, }, "-") } ================================================ FILE: web/.browserslistrc ================================================ > 1% last 2 versions not dead ================================================ FILE: web/.editorconfig ================================================ [*.{js,jsx,ts,tsx,vue}] indent_style = space indent_size = 2 end_of_line = lf trim_trailing_whitespace = true insert_final_newline = true max_line_length = 100 quote_type = single ================================================ FILE: web/.eslintrc.js ================================================ module.exports = { root: true, env: { node: true, }, extends: [ 'plugin:import/recommended', 'plugin:vue/essential', '@vue/airbnb', ], parserOptions: { parser: 'babel-eslint', }, rules: { 'no-bitwise': 'off', 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 'linebreak-style': 'off', 'prefer-destructuring': 'off', 'vuejs-accessibility/click-events-have-key-events': 'off', 'vue/valid-v-slot': 'off', 'vue/multi-word-component-names': 'off', }, overrides: [ { files: [ '**/__tests__/*.{j,t}s?(x)', '**/tests/unit/**/*.spec.{j,t}s?(x)', ], env: { mocha: true, }, }, ], settings: { 'import/resolver': { node: { extensions: ['.js', '.vue'], }, alias: { map: ['@', './src'], extensions: ['.vue', '.js'], }, }, }, }; ================================================ FILE: web/README.md ================================================ # web ## Project setup ``` npm install ``` ### Compiles and hot-reloads for development ``` npm run serve ``` ### Compiles and minifies for production ``` npm run build ``` ### Run your unit tests ``` npm run test:unit ``` ### Lints and fixes files ``` npm run lint ``` ### Customize configuration See [Configuration Reference](https://cli.vuejs.org/config/). ================================================ FILE: web/babel.config.js ================================================ module.exports = { presets: [ '@vue/cli-plugin-babel/preset', ], }; ================================================ FILE: web/gulp-gpt-translate.js ================================================ const through = require('through2'); const PluginError = require('plugin-error'); const { OpenAI } = require('openai'); const PLUGIN_NAME = 'gulp-gpt-translate'; function gptTranslate(options) { if (!options || !options.apiKey) { throw new PluginError(PLUGIN_NAME, 'An OpenAI API key is required.'); } if (!options.targetLanguage) { throw new PluginError(PLUGIN_NAME, 'A target language must be specified.'); } const openai = new OpenAI(); return through.obj(function (file, enc, cb) { const self = this; if (file.isNull()) { return cb(null, file); // Pass along if no contents } if (file.isStream()) { self.emit('error', new PluginError(PLUGIN_NAME, 'Streaming not supported.')); return cb(); } (async () => { try { const content = file.contents.toString(enc); const response = await openai.chat.completions.create({ model: options.model || 'gpt-4o-mini', temperature: 0, messages: [ { role: 'system', content: `You are a helpful assistant that translates text to ${options.targetLanguage}. `, }, ...(options.messages || []).map((m) => ({ role: 'user', content: m })), { role: 'user', content }, ], }); file.contents = Buffer.from(`${response.choices[0].message.content}\n`, enc); self.push(file); cb(); } catch (err) { self.emit('error', new PluginError(PLUGIN_NAME, err.message)); cb(err); } })(); }); } module.exports = gptTranslate; ================================================ FILE: web/gulpfile.js ================================================ const { src, dest } = require('gulp'); const rename = require('gulp-rename'); require('dotenv').config(); const gptTranslate = require('./gulp-gpt-translate'); const LANG_NAMES = { en: 'English', ru: 'Russian', es: 'Spanish', fr: 'French', de: 'German', it: 'Italian', ja: 'Japanese', ko: 'Korean', pt: 'Portuguese', zh_cn: 'Simplified Chinese', zh_tw: 'Traditional Chinese', nl: 'Dutch (Netherlands)', pl: 'Polish', pt_br: 'Brazilian Portuguese', }; function tr() { return Object.keys(LANG_NAMES).filter((lang) => lang !== 'en').map((lang) => src('src/lang/en.js') .pipe(gptTranslate({ apiKey: process.env.OPENAI_API_KEY, targetLanguage: LANG_NAMES[lang], messages: [ 'Translate values of the JS object fields.', 'Preserve file format. Do not wrap result to markdown tag. Result must be valid js file.', ], })) .pipe(rename({ basename: lang })) .pipe(dest('src/lang'))); } module.exports = { tr, }; ================================================ FILE: web/package.json ================================================ { "name": "web", "version": "0.1.0", "private": true, "scripts": { "serve": "vue-cli-service serve", "build": "vue-cli-service build", "test:unit": "vue-cli-service test:unit", "lint": "vue-cli-service lint" }, "dependencies": { "@mdi/font": "^7.0.0", "ansi_up": "^6.0.6", "axios": "^1.13.5", "chart.js": "^3.8.0", "core-js": "^3.48.0", "cron-parser": "^5.3.0", "dayjs": "^1.11.13", "vue": "^2.6.14", "vue-chartjs": "^4.0.0", "vue-codemirror": "^4.0.6", "vue-i18n": "^8.18.2", "vue-router": "^3.5.4", "vue-virtual-scroll-list": "^2.3.5", "vuedraggable": "^2.24.3", "vuetify": "^2.6.10" }, "devDependencies": { "@vue/cli-plugin-babel": "^5.0.6", "@vue/cli-plugin-eslint": "^5.0.6", "@vue/cli-plugin-router": "^5.0.6", "@vue/cli-plugin-unit-mocha": "^5.0.6", "@vue/cli-service": "^5.0.6", "@vue/eslint-config-airbnb": "^6.0.0", "@vue/test-utils": "^2.0.0", "babel-eslint": "^10.1.0", "chai": "^6.0.0", "dotenv": "^17.0.0", "eslint": "^7.32.0", "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-vue": "^9.1.1", "eslint-plugin-vuejs-accessibility": "^1.2.0", "glob-parent": ">=5.1.2", "gulp": "^5.0.0", "gulp-cli": "^3.0.0", "gulp-rename": "^2.0.0", "nanoid": ">=3.1.31", "nyc": "^17.0.0", "openai": "^6.0.0", "plugin-error": "^2.0.1", "prettier": "^3.4.2", "sass": "~1.32.12", "sass-loader": "^13.0.0", "through2": "^4.0.2", "vue-cli-plugin-vuetify": "~2.5.0", "vue-template-compiler": "^2.6.14", "vuetify-loader": "^1.8.0" } } ================================================ FILE: web/public/index.html ================================================ <!DOCTYPE html> <html lang="en"> <head> <base href="/"> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width,initial-scale=1.0"> <link rel="icon" href="favicon.png"> <title>Dashboard - Semaphore UI
================================================ FILE: web/public/swagger/api-docs.yml ================================================ --- swagger: '2.0' info: title: Semaphore API description: | Semaphore API provides endpoints for managing and interacting with the Semaphore UI. This documentation outlines the available operations and data models. version: "2.16.14" consumes: - application/json produces: - application/json - text/plain; charset=utf-8 tags: - name: authentication description: Authentication, Logout & API Tokens - name: project description: Everything related to a project - name: user description: User-related API - name: integration description: Integration API schemes: - http - https basePath: /api definitions: App: type: object Pong: type: string x-example: pong Login: type: object properties: auth: type: string description: Username/Email address x-example: user@semaphoreui.com password: type: string format: password description: Password LoginMetadata: type: object properties: oidc_providers: type: array description: List of OIDC providers items: type: object properties: id: type: string description: ID of the provider, used in the login URL x-example: mysso name: type: string description: Text to show on the login button x-example: Sign in with MySSO UserRequest: type: object properties: name: type: string x-example: Integration Test User example: Integration Test User username: type: string x-example: test-user example: test-user email: type: string x-example: test@ansiblesemaphore.test example: test@ansiblesemaphore.test password: type: string format: password alert: type: boolean admin: type: boolean external: type: boolean UserPutRequest: type: object properties: name: type: string x-example: Integration Test User2 example: Integration Test User2 username: type: string x-example: test-user2 example: test-user2 email: type: string x-example: test2@ansiblesemaphore.test example: test2@ansiblesemaphore.test alert: type: boolean admin: type: boolean User: type: object properties: id: type: integer minimum: 1 name: type: string username: type: string email: type: string created: type: string alert: type: boolean admin: type: boolean external: type: boolean ProjectUser: type: object properties: id: type: integer minimum: 1 name: type: string username: type: string role: type: string enum: [owner, manager, task_runner, guest] ProjectInvite: type: object properties: id: type: integer minimum: 1 project_id: type: integer minimum: 1 user_id: type: integer minimum: 1 description: User ID for user-based invites (optional) email: type: string format: email description: Email address for email-based invites (optional) role: type: string enum: [owner, manager, task_runner, guest] example: manager status: type: string enum: [pending, accepted, declined, expired] example: pending inviter_user_id: type: integer minimum: 1 description: ID of the user who created the invite created: type: string format: date-time description: When the invite was created expires_at: type: string format: date-time description: When the invite expires (optional) accepted_at: type: string format: date-time description: When the invite was accepted (optional) inviter_user: $ref: "#/definitions/User" description: Details of the user who created the invite user: $ref: "#/definitions/User" description: Details of the invited user (for user-based invites) ProjectInviteRequest: type: object properties: # user_id: # type: integer # minimum: 1 # description: User ID to invite (use either user_id or email, not both) email: type: string format: email description: Email address to invite (use either user_id or email, not both) x-example: user@example.com role: type: string enum: [owner, manager, task_runner, guest] example: manager expires_at: type: string format: date-time description: When the invite should expire (optional, defaults to 7 days) required: - role AcceptInviteRequest: type: object properties: token: type: string description: The invitation token x-example: "a1b2c3d4e5f6..." required: - token ProjectBackup: type: object example: {"meta":{"name":"homelab","alert":true,"alert_chat":"Test","max_parallel_tasks":0,"type":null},"templates":[{"inventory":"Build","repository":"Demo","environment":"Empty","name":"Build","playbook":"build.yml","arguments":"[]","allow_override_args_in_task":false,"description":"Build Job","vault_key":null,"type":"build","start_version":"1.0.0","build_template":null,"view":"Build","autorun":false,"survey_vars":[],"suppress_success_alerts":false,"cron":"* * * * *"}],"repositories":[{"name":"Demo","git_url":"https://github.com/semaphoreui/semaphore-demo.git","git_branch":"main","ssh_key":"None"}],"keys":[{"name":"None","type":"none"},{"name":"Vault Password","type":"login_password"}],"views":[{"title":"Build","position":0}],"inventories":[{"name":"Build","inventory":"","ssh_key":"None","become_key":"None","type":"static"},{"name":"Dev","inventory":"","ssh_key":"None","become_key":"None","type":"file"},{"name":"Prod","inventory":"","ssh_key":"None","become_key":"None","type":"file"}],"environments":[{"name":"Empty","password":null,"json":"{}","env":null}]} properties: meta: type: object properties: name: type: string alert: type: boolean alert_chat: type: string max_parallel_tasks: type: integer minimum: 0 type: type: string templates: type: array items: type: object properties: inventory: type: string repository: type: string environment: type: string view: type: string name: type: string playbook: type: string arguments: type: string description: type: string allow_override_args_in_task: type: boolean suppress_success_alerts: type: boolean cron: type: string build_template: type: string autorun: type: boolean survey_vars: type: string start_version: type: string type: type: string vault_key: type: string allow_override_branch_in_task: type: boolean repositories: type: array items: type: object properties: name: type: string git_url: type: string git_branch: type: string ssh_key: type: string keys: type: array items: type: object properties: name: type: string type: type: string enum: [ssh, login_password, none] views: type: array items: type: object properties: name: type: string position: type: integer minimum: 0 inventories: type: array items: type: object properties: name: type: string inventory: type: string ssh_key: type: string become_key: type: string type: type: string enum: [static, static-yaml, file] environments: type: array items: type: object properties: name: type: string password: type: string json: type: string env: type: string APIToken: type: object properties: id: type: string created: type: string # pattern: ^\d{4}-(?:0[0-9]{1}|1[0-2]{1})-[0-9]{2}T\d{2}:\d{2}:\d{2}Z$ expired: type: boolean user_id: type: integer minimum: 1 ProjectRequest: type: object properties: name: type: string example: Test alert: type: boolean alert_chat: type: string example: Test max_parallel_tasks: type: integer minimum: 0 type: type: string demo: description: Create Demo project resources? type: boolean Project: type: object properties: id: type: integer minimum: 1 name: type: string example: Test created: type: string # pattern: ^\d{4}-(?:0[0-9]{1}|1[0-2]{1})-[0-9]{2}T\d{2}:\d{2}:\d{2}Z$ alert: type: boolean alert_chat: type: string example: Test max_parallel_tasks: type: integer minimum: 0 type: type: string AccessKeyRequest: type: object properties: id: type: integer name: type: string x-example: None example: None type: type: string enum: [none, ssh, login_password] x-example: none project_id: type: integer minimum: 1 x-example: 2 override_secret: type: boolean login_password: type: object properties: password: type: string x-example: password example: password login: type: string x-example: username example: username ssh: type: object properties: login: type: string x-example: user example: user passphrase: type: string x-example: passphrase example: passphrase private_key: type: string x-example: private key example: private key AccessKey: type: object properties: id: type: integer name: type: string example: Test type: type: string enum: [none, ssh, login_password] project_id: type: integer EnvironmentSecret: type: object properties: id: type: integer name: type: string type: type: string enum: [env, var] EnvironmentSecretRequest: type: object properties: id: type: integer name: type: string example: Test secret: type: string type: type: string enum: [env, var] operation: type: string enum: [create, update, delete] EnvironmentRequest: type: object properties: id: type: integer example: 1 name: type: string example: Test project_id: type: integer minimum: 1 password: type: string json: type: string example: '{}' env: type: string example: '{}' secrets: type: array items: $ref: '#/definitions/EnvironmentSecretRequest' Environment: type: object properties: id: type: integer minimum: 1 name: type: string example: Test project_id: type: integer minimum: 1 password: type: string json: type: string example: '{}' env: type: string example: '{}' secrets: type: array items: $ref: '#/definitions/EnvironmentSecret' InventoryRequest: type: object properties: id: type: integer name: type: string example: Test project_id: type: integer minimum: 1 inventory: type: string ssh_key_id: type: integer minimum: 1 become_key_id: type: integer minimum: 1 repository_id: type: integer minimum: 1 type: type: string enum: [static, static-yaml, file, terraform-workspace] Inventory: type: object properties: id: type: integer name: type: string example: Test project_id: type: integer inventory: type: string ssh_key_id: type: integer become_key_id: type: integer repository_id: type: integer type: type: string enum: [static, static-yaml, file, terraform-workspace] Integration: type: object properties: id: type: integer name: type: string example: deploy project_id: type: integer minimum: 1 template_id: type: integer minimum: 1 task_params: $ref: '#/definitions/TaskPrams' IntegrationRequest: type: object properties: name: type: string example: deploy project_id: type: integer template_id: type: integer params: $ref: '#/definitions/TaskPrams' IntegrationExtractValueRequest: type: object properties: name: type: string example: deploy value_source: type: string enum: [body, header] body_data_type: type: string enum: [json, xml, string] key: type: string example: key variable: type: string example: variable variable_type: type: string enum: [environment, task] IntegrationExtractValue: type: object properties: id: type: integer name: type: string example: extract this value value_source: type: string enum: [body, header] body_data_type: type: string enum: [json, xml, string] key: type: string example: key variable: type: string example: variable variable_type: type: string enum: [environment, task] integration_id: type: integer IntegrationMatcherRequest: type: object properties: name: type: string example: deploy match_type: type: string enum: [body, header] method: type: string enum: [equals, unequals, contains] body_data_type: type: string enum: [json, xml, string] key: type: string example: key value: type: string example: value IntegrationMatcher: type: object properties: id: type: integer integration_id: type: integer name: type: string example: deploy match_type: type: string enum: [body, header] method: type: string enum: [equals, unequals, contains] body_data_type: type: string enum: [json, xml, string] key: type: string example: key value: type: string example: value IntegrationAlias: type: object properties: id: type: integer url: type: string RepositoryRequest: type: object properties: id: type: integer name: type: string example: Test project_id: type: integer git_url: type: string example: git@example.com git_branch: type: string example: master ssh_key_id: type: integer Repository: type: object properties: id: type: integer name: type: string example: Test project_id: type: integer git_url: type: string example: git@example.com git_branch: type: string example: master ssh_key_id: type: integer Task: type: object properties: id: type: integer example: 23 template_id: type: integer status: type: string playbook: type: string environment: type: string secret: type: string arguments: type: string git_branch: type: string message: type: string inventory_id: type: integer params: allOf: - $ref: '#/definitions/AnsibleTaskParams' - $ref: '#/definitions/TerraformTaskParams' limit: type: string AnsibleTaskParams: type: object properties: debug: type: boolean dry_run: type: boolean diff: type: boolean limit: type: array items: type: string tags: type: array items: type: string skip_tags: type: array items: type: string TerraformTaskParams: type: object properties: plan: type: boolean destroy: type: boolean auto_approve: type: boolean upgrade: type: boolean TaskPrams: type: object properties: environment: type: string git_branch: type: string message: type: string inventory_id: type: integer arguments: type: string params: allOf: - $ref: '#/definitions/AnsibleTaskParams' - $ref: '#/definitions/TerraformTaskParams' TaskOutput: type: object properties: task_id: type: integer example: 23 time: type: string format: date-time output: type: string TemplateRequest: type: object properties: id: type: integer example: 1 project_id: type: integer minimum: 1 inventory_id: type: integer minimum: 1 repository_id: type: integer minimum: 1 environment_id: type: integer minimum: 1 view_id: type: integer minimum: 1 vaults: type: array items: $ref: '#/definitions/TemplateVault' name: type: string example: Test playbook: type: string example: test.yml arguments: type: string example: '[]' description: type: string example: Hello, World! allow_override_args_in_task: type: boolean example: false limit: type: string example: '' suppress_success_alerts: type: boolean app: type: string example: ansible git_branch: type: string example: main survey_vars: type: array items: $ref: "#/definitions/TemplateSurveyVar" type: type: string enum: ["", build, deploy] start_version: type: string build_template_id: type: integer autorun: type: boolean Template: type: object properties: id: type: integer minimum: 1 project_id: type: integer minimum: 1 inventory_id: type: integer minimum: 1 repository_id: type: integer environment_id: type: integer minimum: 1 view_id: type: integer minimum: 1 name: type: string example: Test playbook: type: string example: test.yml arguments: type: string example: '[]' description: type: string example: Hello, World! allow_override_args_in_task: type: boolean example: false suppress_success_alerts: type: boolean app: type: string git_branch: type: string example: main type: type: string enum: ["", build, deploy] start_version: type: string build_template_id: type: integer autorun: type: boolean survey_vars: type: array items: $ref: "#/definitions/TemplateSurveyVar" vaults: type: array items: $ref: "#/definitions/TemplateVault" TemplateSurveyVar: type: object properties: name: type: string title: type: string description: type: string type: type: string enum: ["", int, enum, secret] # String => "", Integer => "int" example: int required: type: boolean values: type: array items: $ref: "#/definitions/TemplateSurveyVarValue" TemplateSurveyVarValue: type: object properties: name: type: string value: type: string TemplateVault: type: object properties: id: type: integer name: type: string example: default type: type: string enum: [password, script] example: script vault_key_id: type: integer script: type: string example: path/to/script-client.py ScheduleRequest: type: object properties: id: type: integer cron_format: type: string x-example: "* * * 1 *" example: "* * * 1 *" project_id: type: integer template_id: type: integer name: type: string active: type: boolean run_at: type: string format: date-time type: type: string enum: ['', 'run_at'] task_params: $ref: '#/definitions/TaskPrams' Schedule: type: object properties: id: type: integer cron_format: type: string project_id: type: integer template_id: type: integer name: type: string active: type: boolean run_at: type: string format: date-time type: type: string enum: ['', 'run_at'] task_params: $ref: '#/definitions/TaskPrams' ViewRequest: type: object properties: title: type: string example: Test project_id: type: integer minimum: 1 position: type: integer minimum: 1 View: type: object properties: id: type: integer title: type: string project_id: type: integer position: type: integer Runner: type: object properties: token: type: string Event: type: object properties: project_id: type: integer user_id: type: integer object_id: type: integer object_type: type: string description: type: string InfoType: type: object properties: version: type: string ansible: type: string web_host: type: string use_remote_runner: type: boolean auth_methods: type: object git_client: type: string schedule_timezone: type: string premium_features: type: object securityDefinitions: cookie: type: apiKey name: Cookie in: header bearer: type: apiKey name: Authorization in: header security: - bearer: [] - cookie: [] parameters: project_id: name: project_id description: Project ID in: path type: integer required: true x-example: 1 user_id: name: user_id description: User ID in: path type: integer required: true x-example: 2 key_id: name: key_id description: key ID in: path type: integer required: true x-example: 3 repository_id: name: repository_id description: repository ID in: path type: integer required: true x-example: 4 inventory_id: name: inventory_id description: inventory ID in: path type: integer required: true x-example: 5 environment_id: name: environment_id description: environment ID in: path type: integer required: true x-example: 6 template_id: name: template_id description: template ID in: path type: integer required: true x-example: 7 task_id: name: task_id description: task ID in: path type: integer required: true x-example: 8 schedule_id: name: schedule_id description: schedule ID in: path type: integer required: true x-example: 9 view_id: name: view_id description: view ID in: path type: integer required: true x-example: 10 integration_id: name: integration_id description: integration ID in: path type: integer required: true x-example: 11 extractvalue_id: name: extractvalue_id description: extractValue ID in: path type: integer required: true x-example: 12 matcher_id: name: matcher_id description: matcher ID in: path type: integer required: true x-example: 13 alias_id: name: alias_id description: Integration Alias ID in: path type: integer required: true x-example: 15 invite_id: name: invite_id description: Invite ID in: path type: integer required: true x-example: 14 paths: /debug/gc: post: summary: Garbage collector description: Run the garbage collector responses: 204: description: Successful "OK" reply /ping: get: summary: PING test produces: - text/plain security: [] # No security responses: 200: description: Successful "PONG" reply schema: $ref: "#/definitions/Pong" headers: content-type: type: string x-example: text/plain; charset=utf-8 /ws: get: summary: Websocket handler schemes: - ws - wss responses: 200: description: OK 401: description: not authenticated /info: get: summary: Fetches information about semaphore description: you must be authenticated to use this responses: 200: description: ok schema: $ref: "#/definitions/InfoType" # Authentication /auth/login: get: tags: - authentication summary: Fetches login metadata description: Fetches metadata for login, such as available OIDC providers security: [] responses: 200: description: Login metadata schema: $ref: "#/definitions/LoginMetadata" post: tags: - authentication summary: Performs Login description: Upon success you will be logged in security: [] # No security parameters: - name: Login Body in: body required: true schema: $ref: '#/definitions/Login' responses: 204: description: You are logged in 400: description: something in body is missing / is invalid /auth/logout: post: tags: - authentication summary: Destroys current session responses: 204: description: Your session was successfully nuked /auth/oidc/{provider_id}/login: parameters: - name: provider_id in: path type: string required: true x-example: "mysso" get: tags: - authentication summary: Begin OIDC authentication flow and redirect to OIDC provider description: The user agent is redirected to this endpoint when chosing to sign in via OIDC responses: 302: description: Redirection to the OIDC provider on success, or to the login page on error /auth/oidc/{provider_id}/redirect: parameters: - name: provider_id in: path type: string required: true x-example: "mysso" get: tags: - authentication summary: Finish OIDC authentication flow, upon succes you will be logged in description: The user agent is redirected here by the OIDC provider to complete authentication responses: 302: description: Redirection to the Semaphore root URL on success, or to the login page on error # User Tokens /user/: get: tags: - user summary: Fetch logged in user responses: 200: description: User schema: $ref: "#/definitions/User" /user/tokens: get: tags: - authentication - user summary: Fetch API tokens for user responses: 200: description: API Tokens schema: type: array items: $ref: "#/definitions/APIToken" post: tags: - authentication - user summary: Create an API token responses: 201: description: API Token schema: $ref: "#/definitions/APIToken" /user/tokens/{api_token_id}: parameters: - name: api_token_id in: path type: string required: true x-example: "kwofd61g93-yuqvex8efmhjkgnbxlo8mp1tin6spyhu=" delete: tags: - authentication - user summary: Expires API token responses: 204: description: Expired API Token # User Profiles /users: get: tags: - user summary: Fetches all users responses: 200: description: Users schema: type: array items: $ref: "#/definitions/User" post: tags: - user summary: Creates a user consumes: - application/json parameters: - name: User in: body required: true schema: $ref: "#/definitions/UserRequest" responses: 400: description: User creation failed 201: description: User created schema: $ref: "#/definitions/User" /users/{user_id}/: parameters: - $ref: "#/parameters/user_id" get: tags: - user summary: Fetches a user profile responses: 200: description: User profile schema: $ref: "#/definitions/User" put: tags: - user summary: Updates user details consumes: - application/json parameters: - name: User in: body required: true schema: $ref: "#/definitions/UserPutRequest" responses: 204: description: User Updated delete: tags: - user summary: Deletes user responses: 204: description: User deleted /users/{user_id}/password: parameters: - $ref: "#/parameters/user_id" post: tags: - user summary: Updates user password consumes: - application/json parameters: - name: Password in: body required: true schema: type: object properties: password: type: string format: password responses: 204: description: Password updated # Projects /projects: get: tags: - project summary: Get projects responses: 200: description: List of projects schema: type: array items: $ref: "#/definitions/Project" post: tags: - project summary: Create a new project consumes: - application/json parameters: - name: Project in: body required: true schema: $ref: '#/definitions/ProjectRequest' responses: 201: description: Created project schema: $ref: "#/definitions/Project" /projects/restore: post: tags: - project summary: Restore Project consumes: - application/json parameters: - name: Backup in: body required: true schema: $ref: '#/definitions/ProjectBackup' responses: 200: description: Created project schema: $ref: "#/definitions/Project" /events: get: summary: Get Events related to Semaphore and projects you are part of responses: 200: description: Array of events in chronological order schema: type: array items: $ref: '#/definitions/Event' /events/last: get: summary: Get last 200 Events related to Semaphore and projects you are part of responses: 200: description: Array of events in chronological order schema: type: array items: $ref: '#/definitions/Event' /project/{project_id}/: parameters: - $ref: "#/parameters/project_id" get: tags: - project summary: Fetch project responses: 200: description: Project schema: $ref: "#/definitions/Project" put: tags: - project summary: Update project parameters: - name: Project in: body required: true schema: allOf: - $ref: '#/definitions/ProjectRequest' - properties: id: type: integer minimum: 1 responses: 204: description: Project saved delete: tags: - project summary: Delete project responses: 204: description: Project deleted /project/{project_id}/backup: parameters: - $ref: "#/parameters/project_id" get: tags: - project summary: Backup A Project responses: 200: description: Backup schema: $ref: '#/definitions/ProjectBackup' /project/{project_id}/role: parameters: - $ref: "#/parameters/project_id" get: tags: - project summary: Fetch permissions of the current user for project responses: 200: description: Permissions schema: type: object properties: role: type: string example: owner permissions: type: number example: 0 /project/{project_id}/events: parameters: - $ref: '#/parameters/project_id' get: tags: - project summary: Get Events related to this project responses: 200: description: Array of events in chronological order schema: type: array items: $ref: '#/definitions/Event' # User management /project/{project_id}/users: parameters: - $ref: "#/parameters/project_id" get: tags: - project summary: Get users linked to project parameters: - name: sort in: query required: true type: string enum: [name, username, email, role] description: sorting name x-example: email - name: order in: query required: true type: string enum: [asc, desc] description: ordering manner x-example: desc responses: 200: description: Users schema: type: array items: $ref: "#/definitions/ProjectUser" post: tags: - project summary: Link user to project parameters: - name: User in: body required: true schema: type: object properties: user_id: type: integer minimum: 2 role: type: string enum: [owner, manager, task_runner, guest] example: owner responses: 204: description: User added /project/{project_id}/users/{user_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/user_id" delete: tags: - project summary: Removes user from project responses: 204: description: User removed put: parameters: - name: Project User in: body required: true schema: type: object properties: role: type: string enum: [owner, manager, task_runner, guest] example: owner summary: Update user role tags: - project responses: 204: description: User updated # Invite management /project/{project_id}/invites: parameters: - $ref: "#/parameters/project_id" get: tags: - project summary: Get invitations for project parameters: - name: sort in: query required: false type: string enum: [created, status, role] description: sorting field x-example: created - name: order in: query required: false type: string enum: [asc, desc] description: ordering manner x-example: desc responses: 200: description: Project invitations schema: type: array items: $ref: "#/definitions/ProjectInvite" post: tags: - project summary: Create project invitation parameters: - name: Invite in: body required: true schema: $ref: "#/definitions/ProjectInviteRequest" responses: 201: description: Invitation created schema: $ref: "#/definitions/ProjectInvite" 400: description: Bad request (invalid role, missing user_id/email, or both provided) 409: description: User already a member or invitation already exists /project/{project_id}/invites/{invite_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/invite_id" get: tags: - project summary: Get specific project invitation responses: 200: description: Project invitation schema: $ref: "#/definitions/ProjectInvite" 404: description: Invitation not found # put: # tags: # - project # summary: Update project invitation status # parameters: # - name: Invite Update # in: body # required: true # schema: # type: object # properties: # status: # type: string # enum: [pending, declined, expired] # example: declined # responses: # 204: # description: Invitation updated # 400: # description: Invalid status or status transition # delete: # tags: # - project # summary: Delete project invitation # responses: # 204: # description: Invitation deleted # # /invites/accept: # post: # tags: # - project # summary: Accept project invitation # parameters: # - name: Accept Invite # in: body # required: true # schema: # $ref: "#/definitions/AcceptInviteRequest" # responses: # 204: # description: Invitation accepted successfully # 400: # description: Invalid token, invitation expired, or user already a member # 403: # description: Invitation not for this user # 404: # description: Invitation not found /project/{project_id}/integrations: parameters: - $ref: "#/parameters/project_id" get: tags: - integration summary: get all integrations responses: 200: description: integration schema: type: array items: $ref: "#/definitions/Integration" post: summary: create a new integration tags: - integration parameters: - name: Integration in: body required: true schema: $ref: "#/definitions/IntegrationRequest" responses: 201: description: Integration Created schema: $ref: "#/definitions/Integration" /project/{project_id}/integrations/{integration_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/integration_id" get: tags: - integration summary: Get Integration responses: 200: description: Integration Value schema: $ref: "#/definitions/Integration" put: tags: - integration summary: Update Integration parameters: - name: Integration in: body required: true schema: $ref: "#/definitions/IntegrationRequest" responses: 204: description: Integration updated delete: tags: - integration summary: Remove integration responses: 204: description: integration removed /project/{project_id}/integrations/{integration_id}/values: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/integration_id" get: tags: - integration summary: Get Integration Extracted Values linked to integration extractor responses: 200: description: Integration Extracted Value schema: type: array items: $ref: "#/definitions/IntegrationExtractValue" post: tags: - integration summary: Add Integration Extracted Value parameters: - name: Integration Extracted Value in: body required: true schema: $ref: "#/definitions/IntegrationExtractValue" responses: 201: description: Integration Extract Value Created 400: description: Bad Integration Extract Value params /project/{project_id}/integrations/{integration_id}/values/{extractvalue_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/integration_id" - $ref: "#/parameters/extractvalue_id" put: tags: - integration summary: Updates Integration ExtractValue parameters: - name: Integration ExtractValue in: body required: true schema: $ref: "#/definitions/IntegrationExtractValueRequest" responses: 204: description: Integration Extract Value updated 400: description: Bad integration extract value parameter delete: tags: - integration summary: Removes integration extract value responses: 204: description: integration extract value removed /project/{project_id}/integrations/{integration_id}/matchers: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/integration_id" get: tags: - integration summary: Get Integration Matcher linked to integration extractor responses: 200: description: Integration Matcher schema: type: array items: $ref: "#/definitions/IntegrationMatcher" post: tags: - integration summary: Add Integration Matcher parameters: - name: Integration Matcher in: body required: true schema: $ref: "#/definitions/IntegrationMatcher" responses: 200: description: Integration Matcher Created 400: description: Bad Integration Matcher params /project/{project_id}/integrations/{integration_id}/matchers/{matcher_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/integration_id" - $ref: "#/parameters/matcher_id" put: tags: - integration summary: Updates Integration Matcher parameters: - name: Integration Matcher in: body required: true schema: $ref: "#/definitions/IntegrationMatcherRequest" responses: 204: description: Integration Matcher updated 400: description: Bad integration matcher parameter delete: tags: - integration summary: Removes integration matcher responses: 204: description: integration matcher removed /project/{project_id}/integrations/aliases: parameters: - $ref: "#/parameters/project_id" get: tags: - integration summary: Get all integration aliases for the project responses: 200: description: Integration Aliases schema: type: array items: $ref: "#/definitions/IntegrationAlias" post: tags: - integration summary: Create a new integration alias for the project responses: 200: description: Integration Alias Created schema: $ref: "#/definitions/IntegrationAlias" /project/{project_id}/integrations/aliases/{alias_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/alias_id" delete: tags: - integration summary: Remove integration alias responses: 204: description: integration alias removed /project/{project_id}/integrations/{integration_id}/aliases: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/integration_id" get: tags: - integration summary: Get all aliases for an integration responses: 200: description: Integration Aliases schema: type: array items: $ref: "#/definitions/IntegrationAlias" post: tags: - integration summary: Create a new alias for an integration responses: 200: description: Integration Alias Created schema: $ref: "#/definitions/IntegrationAlias" /project/{project_id}/integrations/{integration_id}/aliases/{alias_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/integration_id" - $ref: "#/parameters/alias_id" delete: tags: - integration summary: Remove integration alias responses: 204: description: integration alias removed # project access keys /project/{project_id}/keys: parameters: - $ref: "#/parameters/project_id" get: tags: - key-store summary: Get access keys linked to project parameters: # TODO - the space in this parameter name results in a dredd warning - name: Key type in: query required: false type: string enum: [none, ssh, login_password] description: Filter by key type x-example: none - name: sort in: query required: true type: string enum: [name, type] description: sorting name x-example: type - name: order in: query required: true type: string enum: [asc, desc] description: ordering manner x-example: asc responses: 200: description: Access Keys schema: type: array items: $ref: "#/definitions/AccessKey" post: tags: - key-store summary: Add access key parameters: - name: Access Key in: body required: true schema: $ref: "#/definitions/AccessKeyRequest" responses: 201: description: Access Key created schema: $ref: "#/definitions/AccessKey" 400: description: Bad type /project/{project_id}/keys/{key_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/key_id" put: tags: - key-store summary: Updates access key parameters: - name: Access Key in: body required: true schema: $ref: "#/definitions/AccessKeyRequest" responses: 204: description: Key updated 400: description: Bad type delete: tags: - key-store summary: Removes access key responses: 204: description: access key removed # project repositories /project/{project_id}/repositories: parameters: - $ref: "#/parameters/project_id" get: tags: - repository summary: Get repositories parameters: - name: sort in: query required: true type: string enum: [name, git_url, ssh_key] description: sorting name - name: order in: query required: true type: string format: asc/desc enum: [asc, desc] description: ordering manner responses: 200: description: repositories schema: type: array items: $ref: "#/definitions/Repository" post: tags: - repository summary: Add repository parameters: - name: Repository in: body required: true schema: $ref: "#/definitions/RepositoryRequest" responses: 201: description: Repository created schema: $ref: "#/definitions/Repository" /project/{project_id}/repositories/{repository_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/repository_id" get: tags: - repository summary: Get repository responses: 200: description: repository object schema: $ref: "#/definitions/Repository" put: tags: - repository summary: Updates repository parameters: - name: Repository in: body required: true schema: $ref: "#/definitions/RepositoryRequest" responses: 204: description: Repository updated 400: description: Bad request delete: tags: - repository summary: Removes repository responses: 204: description: repository removed # project inventory /project/{project_id}/inventory: parameters: - $ref: "#/parameters/project_id" get: tags: - inventory summary: Get inventory parameters: - name: sort in: query required: true type: string description: sorting name enum: [name, type] - name: order in: query required: true type: string description: ordering manner enum: [asc, desc] responses: 200: description: inventory schema: type: array items: $ref: "#/definitions/Inventory" post: tags: - inventory summary: create inventory parameters: - name: Inventory in: body required: true schema: $ref: "#/definitions/InventoryRequest" responses: 201: description: inventory created schema: $ref: "#/definitions/Inventory" /project/{project_id}/inventory/{inventory_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/inventory_id" get: tags: - inventory summary: Get inventory responses: 200: description: inventory object schema: $ref: "#/definitions/Inventory" put: tags: - inventory summary: Updates inventory parameters: - name: Inventory in: body required: true schema: $ref: "#/definitions/InventoryRequest" responses: 204: description: Inventory updated delete: tags: - inventory summary: Removes inventory responses: 204: description: inventory removed # project environment /project/{project_id}/environment: parameters: - $ref: "#/parameters/project_id" get: tags: - variable-group summary: Get environment parameters: - name: sort in: query required: true type: string format: name description: sorting name x-example: name - name: order in: query required: true type: string format: asc/desc description: ordering manner x-example: desc responses: 200: description: environment schema: type: array items: $ref: "#/definitions/Environment" post: tags: - variable-group summary: Add environment parameters: - name: environment in: body required: true schema: $ref: "#/definitions/EnvironmentRequest" responses: 201: description: Environment created schema: $ref: "#/definitions/Environment" /project/{project_id}/environment/{environment_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/environment_id" get: tags: - variable-group summary: Get environment responses: 200: description: environment object schema: $ref: "#/definitions/Environment" put: tags: - variable-group summary: Update environment parameters: - name: environment in: body required: true schema: $ref: "#/definitions/EnvironmentRequest" responses: 204: description: Environment Updated delete: tags: - variable-group summary: Removes environment responses: 204: description: environment removed # project templates /project/{project_id}/templates: parameters: - $ref: "#/parameters/project_id" get: tags: - template summary: Get template parameters: - name: sort in: query required: true type: string description: sorting name enum: [name, playbook, ssh_key, inventory, environment, repository] - name: order in: query required: true type: string description: ordering manner enum: [asc, desc] responses: 200: description: template schema: type: array items: $ref: "#/definitions/Template" properties: survey_vars: type: array items: $ref: "#/definitions/TemplateSurveyVar" last_task: $ref: "#/definitions/Task" post: tags: - template summary: create template parameters: - name: template in: body required: true schema: $ref: "#/definitions/TemplateRequest" responses: 201: description: template created schema: $ref: "#/definitions/Template" /project/{project_id}/templates/{template_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/template_id" get: tags: - template summary: Get template responses: 200: description: template object schema: $ref: "#/definitions/Template" put: tags: - template summary: Updates template parameters: - name: template in: body required: true schema: $ref: "#/definitions/TemplateRequest" responses: 204: description: template updated delete: tags: - template summary: Removes template responses: 204: description: template removed /project/{project_id}/templates/{template_id}/stop_all_tasks: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/template_id" post: tags: - template summary: Stop all active tasks of template parameters: - name: body in: body required: false schema: type: object properties: force: type: boolean description: Force stop (kill) all tasks immediately responses: 204: description: tasks stopped # project schedules /project/{project_id}/schedules/{schedule_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/schedule_id" get: tags: - schedule summary: Get schedule responses: 200: description: Schedule schema: $ref: "#/definitions/Schedule" delete: tags: - schedule summary: Deletes schedule responses: 204: description: schedule deleted put: tags: - schedule summary: Updates schedule parameters: - name: schedule in: body required: true schema: $ref: "#/definitions/ScheduleRequest" responses: 204: description: schedule updated /project/{project_id}/schedules: parameters: - $ref: "#/parameters/project_id" post: tags: - schedule summary: create schedule parameters: - name: schedule in: body required: true schema: $ref: "#/definitions/ScheduleRequest" responses: 201: description: schedule created schema: $ref: "#/definitions/Schedule" # project views /project/{project_id}/views: parameters: - $ref: "#/parameters/project_id" get: tags: - project summary: Get view responses: 200: description: view schema: type: array items: $ref: "#/definitions/View" post: tags: - project summary: create view parameters: - name: view in: body required: true schema: $ref: "#/definitions/ViewRequest" responses: 201: description: view created schema: $ref: "#/definitions/View" /project/{project_id}/views/{view_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/view_id" get: tags: - project summary: Get view responses: 200: description: view object schema: $ref: "#/definitions/View" put: tags: - project summary: Updates view parameters: - name: view in: body required: true schema: $ref: "#/definitions/ViewRequest" responses: 204: description: view updated delete: tags: - project summary: Removes view responses: 204: description: view removed # tasks /project/{project_id}/tasks: parameters: - $ref: "#/parameters/project_id" get: tags: - task summary: Get Tasks related to current project responses: 200: description: Array of tasks in chronological order schema: type: array items: $ref: '#/definitions/Task' post: tags: - task summary: Starts a job parameters: - name: task in: body required: true schema: type: object properties: template_id: type: integer debug: type: boolean dry_run: type: boolean diff: type: boolean playbook: type: string environment: type: string limit: type: string git_branch: type: string message: type: string arguments: type: string inventory_id: type: integer responses: 201: description: Task queued schema: $ref: "#/definitions/Task" /project/{project_id}/tasks/last: parameters: - $ref: "#/parameters/project_id" get: tags: - task summary: Get last 200 Tasks related to current project responses: 200: description: Array of tasks in chronological order schema: type: array items: $ref: '#/definitions/Task' /project/{project_id}/tasks/{task_id}/stop: parameters: - $ref: "#/parameters/project_id" - $ref: '#/parameters/task_id' post: tags: - task summary: Stop a job parameters: - name: body in: body required: false schema: type: object properties: force: type: boolean description: Force stop (kill) the task immediately responses: 204: description: Task queued /project/{project_id}/tasks/{task_id}: parameters: - $ref: "#/parameters/project_id" - $ref: "#/parameters/task_id" get: tags: - task summary: Get a single task responses: 200: description: Task schema: $ref: "#/definitions/Task" delete: tags: - task summary: Deletes task (including output) responses: 204: description: task deleted /project/{project_id}/tasks/{task_id}/output: parameters: - $ref: '#/parameters/project_id' - $ref: '#/parameters/task_id' get: tags: - task summary: Get task output responses: 200: description: output schema: type: array items: $ref: "#/definitions/TaskOutput" /project/{project_id}/tasks/{task_id}/raw_output: parameters: - $ref: '#/parameters/project_id' - $ref: '#/parameters/task_id' get: tags: - task summary: Get task raw output responses: 200: description: output headers: content-type: type: string x-example: text/plain; charset=utf-8 /apps: get: summary: Get apps responses: 200: description: Apps schema: type: array items: $ref: "#/definitions/App" /project/{project_id}/notifications/test: post: tags: - project summary: Send test notification description: Sends a test notification to all enabled messengers for the project parameters: - $ref: "#/parameters/project_id" responses: 409: description: Alerts not enabled for the project # 204: # description: Test notification dispatched (or alerts disabled) ================================================ FILE: web/public/swagger/index.css ================================================ html { box-sizing: border-box; overflow: -moz-scrollbars-vertical; overflow-y: scroll; } *, *:before, *:after { box-sizing: inherit; } body { margin: 0; background: #fafafa; } ================================================ FILE: web/public/swagger/index.html ================================================ Swagger UI
================================================ FILE: web/public/swagger/oauth2-redirect.html ================================================ Swagger UI: OAuth2 Redirect ================================================ FILE: web/public/swagger/swagger-initializer.js ================================================ window.onload = function () { // // the following lines will be replaced by docker/configurator, when it runs in a docker-container window.ui = SwaggerUIBundle({ url: 'api-docs.yml', dom_id: '#swagger-ui', deepLinking: true, presets: [ SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset, ], plugins: [ SwaggerUIBundle.plugins.DownloadUrl, ], layout: 'StandaloneLayout', }); // }; ================================================ FILE: web/public/swagger/swagger-ui-bundle.js ================================================ /*! For license information please see swagger-ui-bundle.js.LICENSE.txt */ !function webpackUniversalModuleDefinition(s,o){"object"==typeof exports&&"object"==typeof module?module.exports=o():"function"==typeof define&&define.amd?define([],o):"object"==typeof exports?exports.SwaggerUIBundle=o():s.SwaggerUIBundle=o()}(this,(()=>(()=>{var s={251:(s,o)=>{o.read=function(s,o,i,a,u){var _,w,x=8*u-a-1,C=(1<>1,L=-7,B=i?u-1:0,$=i?-1:1,V=s[o+B];for(B+=$,_=V&(1<<-L)-1,V>>=-L,L+=x;L>0;_=256*_+s[o+B],B+=$,L-=8);for(w=_&(1<<-L)-1,_>>=-L,L+=a;L>0;w=256*w+s[o+B],B+=$,L-=8);if(0===_)_=1-j;else{if(_===C)return w?NaN:1/0*(V?-1:1);w+=Math.pow(2,a),_-=j}return(V?-1:1)*w*Math.pow(2,_-a)},o.write=function(s,o,i,a,u,_){var w,x,C,j=8*_-u-1,L=(1<>1,$=23===u?Math.pow(2,-24)-Math.pow(2,-77):0,V=a?0:_-1,U=a?1:-1,z=o<0||0===o&&1/o<0?1:0;for(o=Math.abs(o),isNaN(o)||o===1/0?(x=isNaN(o)?1:0,w=L):(w=Math.floor(Math.log(o)/Math.LN2),o*(C=Math.pow(2,-w))<1&&(w--,C*=2),(o+=w+B>=1?$/C:$*Math.pow(2,1-B))*C>=2&&(w++,C/=2),w+B>=L?(x=0,w=L):w+B>=1?(x=(o*C-1)*Math.pow(2,u),w+=B):(x=o*Math.pow(2,B-1)*Math.pow(2,u),w=0));u>=8;s[i+V]=255&x,V+=U,x/=256,u-=8);for(w=w<0;s[i+V]=255&w,V+=U,w/=256,j-=8);s[i+V-U]|=128*z}},462:(s,o,i)=>{"use strict";var a=i(40975);s.exports=a},659:(s,o,i)=>{var a=i(51873),u=Object.prototype,_=u.hasOwnProperty,w=u.toString,x=a?a.toStringTag:void 0;s.exports=function getRawTag(s){var o=_.call(s,x),i=s[x];try{s[x]=void 0;var a=!0}catch(s){}var u=w.call(s);return a&&(o?s[x]=i:delete s[x]),u}},694:(s,o,i)=>{"use strict";i(91599);var a=i(37257);i(12560),s.exports=a},953:(s,o,i)=>{"use strict";s.exports=i(53375)},1733:s=>{var o=/[^\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f]+/g;s.exports=function asciiWords(s){return s.match(o)||[]}},1882:(s,o,i)=>{var a=i(72552),u=i(23805);s.exports=function isFunction(s){if(!u(s))return!1;var o=a(s);return"[object Function]"==o||"[object GeneratorFunction]"==o||"[object AsyncFunction]"==o||"[object Proxy]"==o}},1907:(s,o,i)=>{"use strict";var a=i(41505),u=Function.prototype,_=u.call,w=a&&u.bind.bind(_,_);s.exports=a?w:function(s){return function(){return _.apply(s,arguments)}}},2205:function(s,o,i){var a;a=void 0!==i.g?i.g:this,s.exports=function(s){if(s.CSS&&s.CSS.escape)return s.CSS.escape;var cssEscape=function(s){if(0==arguments.length)throw new TypeError("`CSS.escape` requires an argument.");for(var o,i=String(s),a=i.length,u=-1,_="",w=i.charCodeAt(0);++u=1&&o<=31||127==o||0==u&&o>=48&&o<=57||1==u&&o>=48&&o<=57&&45==w?"\\"+o.toString(16)+" ":0==u&&1==a&&45==o||!(o>=128||45==o||95==o||o>=48&&o<=57||o>=65&&o<=90||o>=97&&o<=122)?"\\"+i.charAt(u):i.charAt(u):_+="�";return _};return s.CSS||(s.CSS={}),s.CSS.escape=cssEscape,cssEscape}(a)},2209:(s,o,i)=>{"use strict";var a,u=i(9404),_=function productionTypeChecker(){invariant(!1,"ImmutablePropTypes type checking code is stripped in production.")};_.isRequired=_;var w=function getProductionTypeChecker(){return _};function getPropType(s){var o=typeof s;return Array.isArray(s)?"array":s instanceof RegExp?"object":s instanceof u.Iterable?"Immutable."+s.toSource().split(" ")[0]:o}function createChainableTypeChecker(s){function checkType(o,i,a,u,_,w){for(var x=arguments.length,C=Array(x>6?x-6:0),j=6;j>",null!=i[a]?s.apply(void 0,[i,a,u,_,w].concat(C)):o?new Error("Required "+_+" `"+w+"` was not specified in `"+u+"`."):void 0}var o=checkType.bind(null,!1);return o.isRequired=checkType.bind(null,!0),o}function createIterableSubclassTypeChecker(s,o){return function createImmutableTypeChecker(s,o){return createChainableTypeChecker((function validate(i,a,u,_,w){var x=i[a];if(!o(x)){var C=getPropType(x);return new Error("Invalid "+_+" `"+w+"` of type `"+C+"` supplied to `"+u+"`, expected `"+s+"`.")}return null}))}("Iterable."+s,(function(s){return u.Iterable.isIterable(s)&&o(s)}))}(a={listOf:w,mapOf:w,orderedMapOf:w,setOf:w,orderedSetOf:w,stackOf:w,iterableOf:w,recordOf:w,shape:w,contains:w,mapContains:w,orderedMapContains:w,list:_,map:_,orderedMap:_,set:_,orderedSet:_,stack:_,seq:_,record:_,iterable:_}).iterable.indexed=createIterableSubclassTypeChecker("Indexed",u.Iterable.isIndexed),a.iterable.keyed=createIterableSubclassTypeChecker("Keyed",u.Iterable.isKeyed),s.exports=a},2404:(s,o,i)=>{var a=i(60270);s.exports=function isEqual(s,o){return a(s,o)}},2523:s=>{s.exports=function baseFindIndex(s,o,i,a){for(var u=s.length,_=i+(a?1:-1);a?_--:++_{"use strict";var a=i(45951),u=Object.defineProperty;s.exports=function(s,o){try{u(a,s,{value:o,configurable:!0,writable:!0})}catch(i){a[s]=o}return o}},2694:(s,o,i)=>{"use strict";var a=i(6925);function emptyFunction(){}function emptyFunctionWithReset(){}emptyFunctionWithReset.resetWarningCache=emptyFunction,s.exports=function(){function shim(s,o,i,u,_,w){if(w!==a){var x=new Error("Calling PropTypes validators directly is not supported by the `prop-types` package. Use PropTypes.checkPropTypes() to call them. Read more at http://fb.me/use-check-prop-types");throw x.name="Invariant Violation",x}}function getShim(){return shim}shim.isRequired=shim;var s={array:shim,bigint:shim,bool:shim,func:shim,number:shim,object:shim,string:shim,symbol:shim,any:shim,arrayOf:getShim,element:shim,elementType:shim,instanceOf:getShim,node:shim,objectOf:getShim,oneOf:getShim,oneOfType:getShim,shape:getShim,exact:getShim,checkPropTypes:emptyFunctionWithReset,resetWarningCache:emptyFunction};return s.PropTypes=s,s}},2874:s=>{s.exports={}},2875:(s,o,i)=>{"use strict";var a=i(23045),u=i(80376);s.exports=Object.keys||function keys(s){return a(s,u)}},2955:(s,o,i)=>{"use strict";var a,u=i(65606);function _defineProperty(s,o,i){return(o=function _toPropertyKey(s){var o=function _toPrimitive(s,o){if("object"!=typeof s||null===s)return s;var i=s[Symbol.toPrimitive];if(void 0!==i){var a=i.call(s,o||"default");if("object"!=typeof a)return a;throw new TypeError("@@toPrimitive must return a primitive value.")}return("string"===o?String:Number)(s)}(s,"string");return"symbol"==typeof o?o:String(o)}(o))in s?Object.defineProperty(s,o,{value:i,enumerable:!0,configurable:!0,writable:!0}):s[o]=i,s}var _=i(86238),w=Symbol("lastResolve"),x=Symbol("lastReject"),C=Symbol("error"),j=Symbol("ended"),L=Symbol("lastPromise"),B=Symbol("handlePromise"),$=Symbol("stream");function createIterResult(s,o){return{value:s,done:o}}function readAndResolve(s){var o=s[w];if(null!==o){var i=s[$].read();null!==i&&(s[L]=null,s[w]=null,s[x]=null,o(createIterResult(i,!1)))}}function onReadable(s){u.nextTick(readAndResolve,s)}var V=Object.getPrototypeOf((function(){})),U=Object.setPrototypeOf((_defineProperty(a={get stream(){return this[$]},next:function next(){var s=this,o=this[C];if(null!==o)return Promise.reject(o);if(this[j])return Promise.resolve(createIterResult(void 0,!0));if(this[$].destroyed)return new Promise((function(o,i){u.nextTick((function(){s[C]?i(s[C]):o(createIterResult(void 0,!0))}))}));var i,a=this[L];if(a)i=new Promise(function wrapForNext(s,o){return function(i,a){s.then((function(){o[j]?i(createIterResult(void 0,!0)):o[B](i,a)}),a)}}(a,this));else{var _=this[$].read();if(null!==_)return Promise.resolve(createIterResult(_,!1));i=new Promise(this[B])}return this[L]=i,i}},Symbol.asyncIterator,(function(){return this})),_defineProperty(a,"return",(function _return(){var s=this;return new Promise((function(o,i){s[$].destroy(null,(function(s){s?i(s):o(createIterResult(void 0,!0))}))}))})),a),V);s.exports=function createReadableStreamAsyncIterator(s){var o,i=Object.create(U,(_defineProperty(o={},$,{value:s,writable:!0}),_defineProperty(o,w,{value:null,writable:!0}),_defineProperty(o,x,{value:null,writable:!0}),_defineProperty(o,C,{value:null,writable:!0}),_defineProperty(o,j,{value:s._readableState.endEmitted,writable:!0}),_defineProperty(o,B,{value:function value(s,o){var a=i[$].read();a?(i[L]=null,i[w]=null,i[x]=null,s(createIterResult(a,!1))):(i[w]=s,i[x]=o)},writable:!0}),o));return i[L]=null,_(s,(function(s){if(s&&"ERR_STREAM_PREMATURE_CLOSE"!==s.code){var o=i[x];return null!==o&&(i[L]=null,i[w]=null,i[x]=null,o(s)),void(i[C]=s)}var a=i[w];null!==a&&(i[L]=null,i[w]=null,i[x]=null,a(createIterResult(void 0,!0))),i[j]=!0})),s.on("readable",onReadable.bind(null,i)),i}},3110:(s,o,i)=>{const a=i(5187),u=i(85015),_=i(98023),w=i(53812),x=i(23805),C=i(85105),j=i(86804);class Namespace{constructor(s){this.elementMap={},this.elementDetection=[],this.Element=j.Element,this.KeyValuePair=j.KeyValuePair,s&&s.noDefault||this.useDefault(),this._attributeElementKeys=[],this._attributeElementArrayKeys=[]}use(s){return s.namespace&&s.namespace({base:this}),s.load&&s.load({base:this}),this}useDefault(){return this.register("null",j.NullElement).register("string",j.StringElement).register("number",j.NumberElement).register("boolean",j.BooleanElement).register("array",j.ArrayElement).register("object",j.ObjectElement).register("member",j.MemberElement).register("ref",j.RefElement).register("link",j.LinkElement),this.detect(a,j.NullElement,!1).detect(u,j.StringElement,!1).detect(_,j.NumberElement,!1).detect(w,j.BooleanElement,!1).detect(Array.isArray,j.ArrayElement,!1).detect(x,j.ObjectElement,!1),this}register(s,o){return this._elements=void 0,this.elementMap[s]=o,this}unregister(s){return this._elements=void 0,delete this.elementMap[s],this}detect(s,o,i){return void 0===i||i?this.elementDetection.unshift([s,o]):this.elementDetection.push([s,o]),this}toElement(s){if(s instanceof this.Element)return s;let o;for(let i=0;i{const o=s[0].toUpperCase()+s.substr(1);this._elements[o]=this.elementMap[s]}))),this._elements}get serialiser(){return new C(this)}}C.prototype.Namespace=Namespace,s.exports=Namespace},3121:(s,o,i)=>{"use strict";var a=i(65482),u=Math.min;s.exports=function(s){var o=a(s);return o>0?u(o,9007199254740991):0}},3209:(s,o,i)=>{var a=i(91596),u=i(53320),_=i(36306),w="__lodash_placeholder__",x=128,C=Math.min;s.exports=function mergeData(s,o){var i=s[1],j=o[1],L=i|j,B=L<131,$=j==x&&8==i||j==x&&256==i&&s[7].length<=o[8]||384==j&&o[7].length<=o[8]&&8==i;if(!B&&!$)return s;1&j&&(s[2]=o[2],L|=1&i?0:4);var V=o[3];if(V){var U=s[3];s[3]=U?a(U,V,o[4]):V,s[4]=U?_(s[3],w):o[4]}return(V=o[5])&&(U=s[5],s[5]=U?u(U,V,o[6]):V,s[6]=U?_(s[5],w):o[6]),(V=o[7])&&(s[7]=V),j&x&&(s[8]=null==s[8]?o[8]:C(s[8],o[8])),null==s[9]&&(s[9]=o[9]),s[0]=o[0],s[1]=L,s}},3650:(s,o,i)=>{var a=i(74335)(Object.keys,Object);s.exports=a},3656:(s,o,i)=>{s=i.nmd(s);var a=i(9325),u=i(89935),_=o&&!o.nodeType&&o,w=_&&s&&!s.nodeType&&s,x=w&&w.exports===_?a.Buffer:void 0,C=(x?x.isBuffer:void 0)||u;s.exports=C},4509:(s,o,i)=>{var a=i(12651);s.exports=function mapCacheHas(s){return a(this,s).has(s)}},4640:s=>{"use strict";var o=String;s.exports=function(s){try{return o(s)}catch(s){return"Object"}}},4664:(s,o,i)=>{var a=i(79770),u=i(63345),_=Object.prototype.propertyIsEnumerable,w=Object.getOwnPropertySymbols,x=w?function(s){return null==s?[]:(s=Object(s),a(w(s),(function(o){return _.call(s,o)})))}:u;s.exports=x},4901:(s,o,i)=>{var a=i(72552),u=i(30294),_=i(40346),w={};w["[object Float32Array]"]=w["[object Float64Array]"]=w["[object Int8Array]"]=w["[object Int16Array]"]=w["[object Int32Array]"]=w["[object Uint8Array]"]=w["[object Uint8ClampedArray]"]=w["[object Uint16Array]"]=w["[object Uint32Array]"]=!0,w["[object Arguments]"]=w["[object Array]"]=w["[object ArrayBuffer]"]=w["[object Boolean]"]=w["[object DataView]"]=w["[object Date]"]=w["[object Error]"]=w["[object Function]"]=w["[object Map]"]=w["[object Number]"]=w["[object Object]"]=w["[object RegExp]"]=w["[object Set]"]=w["[object String]"]=w["[object WeakMap]"]=!1,s.exports=function baseIsTypedArray(s){return _(s)&&u(s.length)&&!!w[a(s)]}},4993:(s,o,i)=>{"use strict";var a=i(16946),u=i(74239);s.exports=function(s){return a(u(s))}},5187:s=>{s.exports=function isNull(s){return null===s}},5419:s=>{s.exports=function(s,o,i,a){var u=new Blob(void 0!==a?[a,s]:[s],{type:i||"application/octet-stream"});if(void 0!==window.navigator.msSaveBlob)window.navigator.msSaveBlob(u,o);else{var _=window.URL&&window.URL.createObjectURL?window.URL.createObjectURL(u):window.webkitURL.createObjectURL(u),w=document.createElement("a");w.style.display="none",w.href=_,w.setAttribute("download",o),void 0===w.download&&w.setAttribute("target","_blank"),document.body.appendChild(w),w.click(),setTimeout((function(){document.body.removeChild(w),window.URL.revokeObjectURL(_)}),200)}}},5556:(s,o,i)=>{s.exports=i(2694)()},5861:(s,o,i)=>{var a=i(55580),u=i(68223),_=i(32804),w=i(76545),x=i(28303),C=i(72552),j=i(47473),L="[object Map]",B="[object Promise]",$="[object Set]",V="[object WeakMap]",U="[object DataView]",z=j(a),Y=j(u),Z=j(_),ee=j(w),ie=j(x),ae=C;(a&&ae(new a(new ArrayBuffer(1)))!=U||u&&ae(new u)!=L||_&&ae(_.resolve())!=B||w&&ae(new w)!=$||x&&ae(new x)!=V)&&(ae=function(s){var o=C(s),i="[object Object]"==o?s.constructor:void 0,a=i?j(i):"";if(a)switch(a){case z:return U;case Y:return L;case Z:return B;case ee:return $;case ie:return V}return o}),s.exports=ae},6048:s=>{s.exports=function negate(s){if("function"!=typeof s)throw new TypeError("Expected a function");return function(){var o=arguments;switch(o.length){case 0:return!s.call(this);case 1:return!s.call(this,o[0]);case 2:return!s.call(this,o[0],o[1]);case 3:return!s.call(this,o[0],o[1],o[2])}return!s.apply(this,o)}}},6205:s=>{s.exports={ROOT:0,GROUP:1,POSITION:2,SET:3,RANGE:4,REPETITION:5,REFERENCE:6,CHAR:7}},6233:(s,o,i)=>{const a=i(6048),u=i(10316),_=i(92340);class ArrayElement extends u{constructor(s,o,i){super(s||[],o,i),this.element="array"}primitive(){return"array"}get(s){return this.content[s]}getValue(s){const o=this.get(s);if(o)return o.toValue()}getIndex(s){return this.content[s]}set(s,o){return this.content[s]=this.refract(o),this}remove(s){const o=this.content.splice(s,1);return o.length?o[0]:null}map(s,o){return this.content.map(s,o)}flatMap(s,o){return this.map(s,o).reduce(((s,o)=>s.concat(o)),[])}compactMap(s,o){const i=[];return this.forEach((a=>{const u=s.bind(o)(a);u&&i.push(u)})),i}filter(s,o){return new _(this.content.filter(s,o))}reject(s,o){return this.filter(a(s),o)}reduce(s,o){let i,a;void 0!==o?(i=0,a=this.refract(o)):(i=1,a="object"===this.primitive()?this.first.value:this.first);for(let o=i;o{s.bind(o)(i,this.refract(a))}))}shift(){return this.content.shift()}unshift(s){this.content.unshift(this.refract(s))}push(s){return this.content.push(this.refract(s)),this}add(s){this.push(s)}findElements(s,o){const i=o||{},a=!!i.recursive,u=void 0===i.results?[]:i.results;return this.forEach(((o,i,_)=>{a&&void 0!==o.findElements&&o.findElements(s,{results:u,recursive:a}),s(o,i,_)&&u.push(o)})),u}find(s){return new _(this.findElements(s,{recursive:!0}))}findByElement(s){return this.find((o=>o.element===s))}findByClass(s){return this.find((o=>o.classes.includes(s)))}getById(s){return this.find((o=>o.id.toValue()===s)).first}includes(s){return this.content.some((o=>o.equals(s)))}contains(s){return this.includes(s)}empty(){return new this.constructor([])}"fantasy-land/empty"(){return this.empty()}concat(s){return new this.constructor(this.content.concat(s.content))}"fantasy-land/concat"(s){return this.concat(s)}"fantasy-land/map"(s){return new this.constructor(this.map(s))}"fantasy-land/chain"(s){return this.map((o=>s(o)),this).reduce(((s,o)=>s.concat(o)),this.empty())}"fantasy-land/filter"(s){return new this.constructor(this.content.filter(s))}"fantasy-land/reduce"(s,o){return this.content.reduce(s,o)}get length(){return this.content.length}get isEmpty(){return 0===this.content.length}get first(){return this.getIndex(0)}get second(){return this.getIndex(1)}get last(){return this.getIndex(this.length-1)}}ArrayElement.empty=function empty(){return new this},ArrayElement["fantasy-land/empty"]=ArrayElement.empty,"undefined"!=typeof Symbol&&(ArrayElement.prototype[Symbol.iterator]=function symbol(){return this.content[Symbol.iterator]()}),s.exports=ArrayElement},6499:(s,o,i)=>{"use strict";var a=i(1907),u=0,_=Math.random(),w=a(1..toString);s.exports=function(s){return"Symbol("+(void 0===s?"":s)+")_"+w(++u+_,36)}},6925:s=>{"use strict";s.exports="SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED"},7057:(s,o,i)=>{"use strict";var a=i(11470).charAt,u=i(90160),_=i(64932),w=i(60183),x=i(59550),C="String Iterator",j=_.set,L=_.getterFor(C);w(String,"String",(function(s){j(this,{type:C,string:u(s),index:0})}),(function next(){var s,o=L(this),i=o.string,u=o.index;return u>=i.length?x(void 0,!0):(s=a(i,u),o.index+=s.length,x(s,!1))}))},7309:(s,o,i)=>{var a=i(62006)(i(24713));s.exports=a},7376:s=>{"use strict";s.exports=!0},7463:(s,o,i)=>{"use strict";var a=i(98828),u=i(62250),_=/#|\.prototype\./,isForced=function(s,o){var i=x[w(s)];return i===j||i!==C&&(u(o)?a(o):!!o)},w=isForced.normalize=function(s){return String(s).replace(_,".").toLowerCase()},x=isForced.data={},C=isForced.NATIVE="N",j=isForced.POLYFILL="P";s.exports=isForced},7666:(s,o,i)=>{var a=i(84851),u=i(953);function _extends(){var o;return s.exports=_extends=a?u(o=a).call(o):function(s){for(var o=1;o{const a=i(6205);o.wordBoundary=()=>({type:a.POSITION,value:"b"}),o.nonWordBoundary=()=>({type:a.POSITION,value:"B"}),o.begin=()=>({type:a.POSITION,value:"^"}),o.end=()=>({type:a.POSITION,value:"$"})},8068:s=>{"use strict";var o=(()=>{var s=Object.defineProperty,o=Object.getOwnPropertyDescriptor,i=Object.getOwnPropertyNames,a=Object.getOwnPropertySymbols,u=Object.prototype.hasOwnProperty,_=Object.prototype.propertyIsEnumerable,__defNormalProp=(o,i,a)=>i in o?s(o,i,{enumerable:!0,configurable:!0,writable:!0,value:a}):o[i]=a,__spreadValues=(s,o)=>{for(var i in o||(o={}))u.call(o,i)&&__defNormalProp(s,i,o[i]);if(a)for(var i of a(o))_.call(o,i)&&__defNormalProp(s,i,o[i]);return s},__publicField=(s,o,i)=>__defNormalProp(s,"symbol"!=typeof o?o+"":o,i),w={};((o,i)=>{for(var a in i)s(o,a,{get:i[a],enumerable:!0})})(w,{DEFAULT_OPTIONS:()=>C,DEFAULT_UUID_LENGTH:()=>x,default:()=>B});var x=6,C={dictionary:"alphanum",shuffle:!0,debug:!1,length:x,counter:0},j=class _ShortUniqueId{constructor(s={}){__publicField(this,"counter"),__publicField(this,"debug"),__publicField(this,"dict"),__publicField(this,"version"),__publicField(this,"dictIndex",0),__publicField(this,"dictRange",[]),__publicField(this,"lowerBound",0),__publicField(this,"upperBound",0),__publicField(this,"dictLength",0),__publicField(this,"uuidLength"),__publicField(this,"_digit_first_ascii",48),__publicField(this,"_digit_last_ascii",58),__publicField(this,"_alpha_lower_first_ascii",97),__publicField(this,"_alpha_lower_last_ascii",123),__publicField(this,"_hex_last_ascii",103),__publicField(this,"_alpha_upper_first_ascii",65),__publicField(this,"_alpha_upper_last_ascii",91),__publicField(this,"_number_dict_ranges",{digits:[this._digit_first_ascii,this._digit_last_ascii]}),__publicField(this,"_alpha_dict_ranges",{lowerCase:[this._alpha_lower_first_ascii,this._alpha_lower_last_ascii],upperCase:[this._alpha_upper_first_ascii,this._alpha_upper_last_ascii]}),__publicField(this,"_alpha_lower_dict_ranges",{lowerCase:[this._alpha_lower_first_ascii,this._alpha_lower_last_ascii]}),__publicField(this,"_alpha_upper_dict_ranges",{upperCase:[this._alpha_upper_first_ascii,this._alpha_upper_last_ascii]}),__publicField(this,"_alphanum_dict_ranges",{digits:[this._digit_first_ascii,this._digit_last_ascii],lowerCase:[this._alpha_lower_first_ascii,this._alpha_lower_last_ascii],upperCase:[this._alpha_upper_first_ascii,this._alpha_upper_last_ascii]}),__publicField(this,"_alphanum_lower_dict_ranges",{digits:[this._digit_first_ascii,this._digit_last_ascii],lowerCase:[this._alpha_lower_first_ascii,this._alpha_lower_last_ascii]}),__publicField(this,"_alphanum_upper_dict_ranges",{digits:[this._digit_first_ascii,this._digit_last_ascii],upperCase:[this._alpha_upper_first_ascii,this._alpha_upper_last_ascii]}),__publicField(this,"_hex_dict_ranges",{decDigits:[this._digit_first_ascii,this._digit_last_ascii],alphaDigits:[this._alpha_lower_first_ascii,this._hex_last_ascii]}),__publicField(this,"_dict_ranges",{_number_dict_ranges:this._number_dict_ranges,_alpha_dict_ranges:this._alpha_dict_ranges,_alpha_lower_dict_ranges:this._alpha_lower_dict_ranges,_alpha_upper_dict_ranges:this._alpha_upper_dict_ranges,_alphanum_dict_ranges:this._alphanum_dict_ranges,_alphanum_lower_dict_ranges:this._alphanum_lower_dict_ranges,_alphanum_upper_dict_ranges:this._alphanum_upper_dict_ranges,_hex_dict_ranges:this._hex_dict_ranges}),__publicField(this,"log",((...s)=>{const o=[...s];o[0]="[short-unique-id] ".concat(s[0]),!0!==this.debug||"undefined"==typeof console||null===console||console.log(...o)})),__publicField(this,"_normalizeDictionary",((s,o)=>{let i;if(s&&Array.isArray(s)&&s.length>1)i=s;else{i=[],this.dictIndex=0;const o="_".concat(s,"_dict_ranges"),a=this._dict_ranges[o];let u=0;for(const[,s]of Object.entries(a)){const[o,i]=s;u+=Math.abs(i-o)}i=new Array(u);let _=0;for(const[,s]of Object.entries(a)){this.dictRange=s,this.lowerBound=this.dictRange[0],this.upperBound=this.dictRange[1];const o=this.lowerBound<=this.upperBound,a=this.lowerBound,u=this.upperBound;if(o)for(let s=a;su;s--)i[_++]=String.fromCharCode(s),this.dictIndex=s}i.length=_}if(o){for(let s=i.length-1;s>0;s--){const o=Math.floor(Math.random()*(s+1));[i[s],i[o]]=[i[o],i[s]]}}return i})),__publicField(this,"setDictionary",((s,o)=>{this.dict=this._normalizeDictionary(s,o),this.dictLength=this.dict.length,this.setCounter(0)})),__publicField(this,"seq",(()=>this.sequentialUUID())),__publicField(this,"sequentialUUID",(()=>{const s=this.dictLength,o=this.dict;let i=this.counter;const a=[];do{const u=i%s;i=Math.trunc(i/s),a.push(o[u])}while(0!==i);const u=a.join("");return this.counter+=1,u})),__publicField(this,"rnd",((s=this.uuidLength||x)=>this.randomUUID(s))),__publicField(this,"randomUUID",((s=this.uuidLength||x)=>{if(null==s||s<1)throw new Error("Invalid UUID Length Provided");const o=new Array(s),i=this.dictLength,a=this.dict;for(let u=0;uthis.formattedUUID(s,o))),__publicField(this,"formattedUUID",((s,o)=>{const i={$r:this.randomUUID,$s:this.sequentialUUID,$t:this.stamp};return s.replace(/\$[rs]\d{0,}|\$t0|\$t[1-9]\d{1,}/g,(s=>{const a=s.slice(0,2),u=Number.parseInt(s.slice(2),10);return"$s"===a?i[a]().padStart(u,"0"):"$t"===a&&o?i[a](u,o):i[a](u)}))})),__publicField(this,"availableUUIDs",((s=this.uuidLength)=>Number.parseFloat(([...new Set(this.dict)].length**s).toFixed(0)))),__publicField(this,"_collisionCache",new Map),__publicField(this,"approxMaxBeforeCollision",((s=this.availableUUIDs(this.uuidLength))=>{const o=s,i=this._collisionCache.get(o);if(void 0!==i)return i;const a=Number.parseFloat(Math.sqrt(Math.PI/2*s).toFixed(20));return this._collisionCache.set(o,a),a})),__publicField(this,"collisionProbability",((s=this.availableUUIDs(this.uuidLength),o=this.uuidLength)=>Number.parseFloat((this.approxMaxBeforeCollision(s)/this.availableUUIDs(o)).toFixed(20)))),__publicField(this,"uniqueness",((s=this.availableUUIDs(this.uuidLength))=>{const o=Number.parseFloat((1-this.approxMaxBeforeCollision(s)/s).toFixed(20));return o>1?1:o<0?0:o})),__publicField(this,"getVersion",(()=>this.version)),__publicField(this,"stamp",((s,o)=>{const i=Math.floor(+(o||new Date)/1e3).toString(16);if("number"==typeof s&&0===s)return i;if("number"!=typeof s||s<10)throw new Error(["Param finalLength must be a number greater than or equal to 10,","or 0 if you want the raw hexadecimal timestamp"].join("\n"));const a=s-9,u=Math.round(Math.random()*(a>15?15:a)),_=this.randomUUID(a);return"".concat(_.substring(0,u)).concat(i).concat(_.substring(u)).concat(u.toString(16))})),__publicField(this,"parseStamp",((s,o)=>{if(o&&!/t0|t[1-9]\d{1,}/.test(o))throw new Error("Cannot extract date from a formated UUID with no timestamp in the format");const i=o?o.replace(/\$[rs]\d{0,}|\$t0|\$t[1-9]\d{1,}/g,(s=>{const o={$r:s=>[...Array(s)].map((()=>"r")).join(""),$s:s=>[...Array(s)].map((()=>"s")).join(""),$t:s=>[...Array(s)].map((()=>"t")).join("")},i=s.slice(0,2),a=Number.parseInt(s.slice(2),10);return o[i](a)})).replace(/^(.*?)(t{8,})(.*)$/g,((o,i,a)=>s.substring(i.length,i.length+a.length))):s;if(8===i.length)return new Date(1e3*Number.parseInt(i,16));if(i.length<10)throw new Error("Stamp length invalid");const a=Number.parseInt(i.substring(i.length-1),16);return new Date(1e3*Number.parseInt(i.substring(a,a+8),16))})),__publicField(this,"setCounter",(s=>{this.counter=s})),__publicField(this,"validate",((s,o)=>{const i=o?this._normalizeDictionary(o):this.dict;return s.split("").every((s=>i.includes(s)))}));const o=__spreadValues(__spreadValues({},C),s);this.counter=0,this.debug=!1,this.dict=[],this.version="5.3.2";const{dictionary:i,shuffle:a,length:u,counter:_}=o;this.uuidLength=u,this.setDictionary(i,a),this.setCounter(_),this.debug=o.debug,this.log(this.dict),this.log("Generator instantiated with Dictionary Size ".concat(this.dictLength," and counter set to ").concat(this.counter)),this.log=this.log.bind(this),this.setDictionary=this.setDictionary.bind(this),this.setCounter=this.setCounter.bind(this),this.seq=this.seq.bind(this),this.sequentialUUID=this.sequentialUUID.bind(this),this.rnd=this.rnd.bind(this),this.randomUUID=this.randomUUID.bind(this),this.fmt=this.fmt.bind(this),this.formattedUUID=this.formattedUUID.bind(this),this.availableUUIDs=this.availableUUIDs.bind(this),this.approxMaxBeforeCollision=this.approxMaxBeforeCollision.bind(this),this.collisionProbability=this.collisionProbability.bind(this),this.uniqueness=this.uniqueness.bind(this),this.getVersion=this.getVersion.bind(this),this.stamp=this.stamp.bind(this),this.parseStamp=this.parseStamp.bind(this)}};__publicField(j,"default",j);var L,B=j;return L=w,((a,_,w,x)=>{if(_&&"object"==typeof _||"function"==typeof _)for(let C of i(_))u.call(a,C)||C===w||s(a,C,{get:()=>_[C],enumerable:!(x=o(_,C))||x.enumerable});return a})(s({},"__esModule",{value:!0}),L)})();s.exports=o.default,"undefined"!=typeof window&&(o=o.default)},9325:(s,o,i)=>{var a=i(34840),u="object"==typeof self&&self&&self.Object===Object&&self,_=a||u||Function("return this")();s.exports=_},9404:function(s){s.exports=function(){"use strict";var s=Array.prototype.slice;function createClass(s,o){o&&(s.prototype=Object.create(o.prototype)),s.prototype.constructor=s}function Iterable(s){return isIterable(s)?s:Seq(s)}function KeyedIterable(s){return isKeyed(s)?s:KeyedSeq(s)}function IndexedIterable(s){return isIndexed(s)?s:IndexedSeq(s)}function SetIterable(s){return isIterable(s)&&!isAssociative(s)?s:SetSeq(s)}function isIterable(s){return!(!s||!s[o])}function isKeyed(s){return!(!s||!s[i])}function isIndexed(s){return!(!s||!s[a])}function isAssociative(s){return isKeyed(s)||isIndexed(s)}function isOrdered(s){return!(!s||!s[u])}createClass(KeyedIterable,Iterable),createClass(IndexedIterable,Iterable),createClass(SetIterable,Iterable),Iterable.isIterable=isIterable,Iterable.isKeyed=isKeyed,Iterable.isIndexed=isIndexed,Iterable.isAssociative=isAssociative,Iterable.isOrdered=isOrdered,Iterable.Keyed=KeyedIterable,Iterable.Indexed=IndexedIterable,Iterable.Set=SetIterable;var o="@@__IMMUTABLE_ITERABLE__@@",i="@@__IMMUTABLE_KEYED__@@",a="@@__IMMUTABLE_INDEXED__@@",u="@@__IMMUTABLE_ORDERED__@@",_="delete",w=5,x=1<>>0;if(""+i!==o||4294967295===i)return NaN;o=i}return o<0?ensureSize(s)+o:o}function returnTrue(){return!0}function wholeSlice(s,o,i){return(0===s||void 0!==i&&s<=-i)&&(void 0===o||void 0!==i&&o>=i)}function resolveBegin(s,o){return resolveIndex(s,o,0)}function resolveEnd(s,o){return resolveIndex(s,o,o)}function resolveIndex(s,o,i){return void 0===s?i:s<0?Math.max(0,o+s):void 0===o?s:Math.min(o,s)}var $=0,V=1,U=2,z="function"==typeof Symbol&&Symbol.iterator,Y="@@iterator",Z=z||Y;function Iterator(s){this.next=s}function iteratorValue(s,o,i,a){var u=0===s?o:1===s?i:[o,i];return a?a.value=u:a={value:u,done:!1},a}function iteratorDone(){return{value:void 0,done:!0}}function hasIterator(s){return!!getIteratorFn(s)}function isIterator(s){return s&&"function"==typeof s.next}function getIterator(s){var o=getIteratorFn(s);return o&&o.call(s)}function getIteratorFn(s){var o=s&&(z&&s[z]||s[Y]);if("function"==typeof o)return o}function isArrayLike(s){return s&&"number"==typeof s.length}function Seq(s){return null==s?emptySequence():isIterable(s)?s.toSeq():seqFromValue(s)}function KeyedSeq(s){return null==s?emptySequence().toKeyedSeq():isIterable(s)?isKeyed(s)?s.toSeq():s.fromEntrySeq():keyedSeqFromValue(s)}function IndexedSeq(s){return null==s?emptySequence():isIterable(s)?isKeyed(s)?s.entrySeq():s.toIndexedSeq():indexedSeqFromValue(s)}function SetSeq(s){return(null==s?emptySequence():isIterable(s)?isKeyed(s)?s.entrySeq():s:indexedSeqFromValue(s)).toSetSeq()}Iterator.prototype.toString=function(){return"[Iterator]"},Iterator.KEYS=$,Iterator.VALUES=V,Iterator.ENTRIES=U,Iterator.prototype.inspect=Iterator.prototype.toSource=function(){return this.toString()},Iterator.prototype[Z]=function(){return this},createClass(Seq,Iterable),Seq.of=function(){return Seq(arguments)},Seq.prototype.toSeq=function(){return this},Seq.prototype.toString=function(){return this.__toString("Seq {","}")},Seq.prototype.cacheResult=function(){return!this._cache&&this.__iterateUncached&&(this._cache=this.entrySeq().toArray(),this.size=this._cache.length),this},Seq.prototype.__iterate=function(s,o){return seqIterate(this,s,o,!0)},Seq.prototype.__iterator=function(s,o){return seqIterator(this,s,o,!0)},createClass(KeyedSeq,Seq),KeyedSeq.prototype.toKeyedSeq=function(){return this},createClass(IndexedSeq,Seq),IndexedSeq.of=function(){return IndexedSeq(arguments)},IndexedSeq.prototype.toIndexedSeq=function(){return this},IndexedSeq.prototype.toString=function(){return this.__toString("Seq [","]")},IndexedSeq.prototype.__iterate=function(s,o){return seqIterate(this,s,o,!1)},IndexedSeq.prototype.__iterator=function(s,o){return seqIterator(this,s,o,!1)},createClass(SetSeq,Seq),SetSeq.of=function(){return SetSeq(arguments)},SetSeq.prototype.toSetSeq=function(){return this},Seq.isSeq=isSeq,Seq.Keyed=KeyedSeq,Seq.Set=SetSeq,Seq.Indexed=IndexedSeq;var ee,ie,ae,ce="@@__IMMUTABLE_SEQ__@@";function ArraySeq(s){this._array=s,this.size=s.length}function ObjectSeq(s){var o=Object.keys(s);this._object=s,this._keys=o,this.size=o.length}function IterableSeq(s){this._iterable=s,this.size=s.length||s.size}function IteratorSeq(s){this._iterator=s,this._iteratorCache=[]}function isSeq(s){return!(!s||!s[ce])}function emptySequence(){return ee||(ee=new ArraySeq([]))}function keyedSeqFromValue(s){var o=Array.isArray(s)?new ArraySeq(s).fromEntrySeq():isIterator(s)?new IteratorSeq(s).fromEntrySeq():hasIterator(s)?new IterableSeq(s).fromEntrySeq():"object"==typeof s?new ObjectSeq(s):void 0;if(!o)throw new TypeError("Expected Array or iterable object of [k, v] entries, or keyed object: "+s);return o}function indexedSeqFromValue(s){var o=maybeIndexedSeqFromValue(s);if(!o)throw new TypeError("Expected Array or iterable object of values: "+s);return o}function seqFromValue(s){var o=maybeIndexedSeqFromValue(s)||"object"==typeof s&&new ObjectSeq(s);if(!o)throw new TypeError("Expected Array or iterable object of values, or keyed object: "+s);return o}function maybeIndexedSeqFromValue(s){return isArrayLike(s)?new ArraySeq(s):isIterator(s)?new IteratorSeq(s):hasIterator(s)?new IterableSeq(s):void 0}function seqIterate(s,o,i,a){var u=s._cache;if(u){for(var _=u.length-1,w=0;w<=_;w++){var x=u[i?_-w:w];if(!1===o(x[1],a?x[0]:w,s))return w+1}return w}return s.__iterateUncached(o,i)}function seqIterator(s,o,i,a){var u=s._cache;if(u){var _=u.length-1,w=0;return new Iterator((function(){var s=u[i?_-w:w];return w++>_?iteratorDone():iteratorValue(o,a?s[0]:w-1,s[1])}))}return s.__iteratorUncached(o,i)}function fromJS(s,o){return o?fromJSWith(o,s,"",{"":s}):fromJSDefault(s)}function fromJSWith(s,o,i,a){return Array.isArray(o)?s.call(a,i,IndexedSeq(o).map((function(i,a){return fromJSWith(s,i,a,o)}))):isPlainObj(o)?s.call(a,i,KeyedSeq(o).map((function(i,a){return fromJSWith(s,i,a,o)}))):o}function fromJSDefault(s){return Array.isArray(s)?IndexedSeq(s).map(fromJSDefault).toList():isPlainObj(s)?KeyedSeq(s).map(fromJSDefault).toMap():s}function isPlainObj(s){return s&&(s.constructor===Object||void 0===s.constructor)}function is(s,o){if(s===o||s!=s&&o!=o)return!0;if(!s||!o)return!1;if("function"==typeof s.valueOf&&"function"==typeof o.valueOf){if((s=s.valueOf())===(o=o.valueOf())||s!=s&&o!=o)return!0;if(!s||!o)return!1}return!("function"!=typeof s.equals||"function"!=typeof o.equals||!s.equals(o))}function deepEqual(s,o){if(s===o)return!0;if(!isIterable(o)||void 0!==s.size&&void 0!==o.size&&s.size!==o.size||void 0!==s.__hash&&void 0!==o.__hash&&s.__hash!==o.__hash||isKeyed(s)!==isKeyed(o)||isIndexed(s)!==isIndexed(o)||isOrdered(s)!==isOrdered(o))return!1;if(0===s.size&&0===o.size)return!0;var i=!isAssociative(s);if(isOrdered(s)){var a=s.entries();return o.every((function(s,o){var u=a.next().value;return u&&is(u[1],s)&&(i||is(u[0],o))}))&&a.next().done}var u=!1;if(void 0===s.size)if(void 0===o.size)"function"==typeof s.cacheResult&&s.cacheResult();else{u=!0;var _=s;s=o,o=_}var w=!0,x=o.__iterate((function(o,a){if(i?!s.has(o):u?!is(o,s.get(a,j)):!is(s.get(a,j),o))return w=!1,!1}));return w&&s.size===x}function Repeat(s,o){if(!(this instanceof Repeat))return new Repeat(s,o);if(this._value=s,this.size=void 0===o?1/0:Math.max(0,o),0===this.size){if(ie)return ie;ie=this}}function invariant(s,o){if(!s)throw new Error(o)}function Range(s,o,i){if(!(this instanceof Range))return new Range(s,o,i);if(invariant(0!==i,"Cannot step a Range by 0"),s=s||0,void 0===o&&(o=1/0),i=void 0===i?1:Math.abs(i),oa?iteratorDone():iteratorValue(s,u,i[o?a-u++:u++])}))},createClass(ObjectSeq,KeyedSeq),ObjectSeq.prototype.get=function(s,o){return void 0===o||this.has(s)?this._object[s]:o},ObjectSeq.prototype.has=function(s){return this._object.hasOwnProperty(s)},ObjectSeq.prototype.__iterate=function(s,o){for(var i=this._object,a=this._keys,u=a.length-1,_=0;_<=u;_++){var w=a[o?u-_:_];if(!1===s(i[w],w,this))return _+1}return _},ObjectSeq.prototype.__iterator=function(s,o){var i=this._object,a=this._keys,u=a.length-1,_=0;return new Iterator((function(){var w=a[o?u-_:_];return _++>u?iteratorDone():iteratorValue(s,w,i[w])}))},ObjectSeq.prototype[u]=!0,createClass(IterableSeq,IndexedSeq),IterableSeq.prototype.__iterateUncached=function(s,o){if(o)return this.cacheResult().__iterate(s,o);var i=getIterator(this._iterable),a=0;if(isIterator(i))for(var u;!(u=i.next()).done&&!1!==s(u.value,a++,this););return a},IterableSeq.prototype.__iteratorUncached=function(s,o){if(o)return this.cacheResult().__iterator(s,o);var i=getIterator(this._iterable);if(!isIterator(i))return new Iterator(iteratorDone);var a=0;return new Iterator((function(){var o=i.next();return o.done?o:iteratorValue(s,a++,o.value)}))},createClass(IteratorSeq,IndexedSeq),IteratorSeq.prototype.__iterateUncached=function(s,o){if(o)return this.cacheResult().__iterate(s,o);for(var i,a=this._iterator,u=this._iteratorCache,_=0;_=a.length){var o=i.next();if(o.done)return o;a[u]=o.value}return iteratorValue(s,u,a[u++])}))},createClass(Repeat,IndexedSeq),Repeat.prototype.toString=function(){return 0===this.size?"Repeat []":"Repeat [ "+this._value+" "+this.size+" times ]"},Repeat.prototype.get=function(s,o){return this.has(s)?this._value:o},Repeat.prototype.includes=function(s){return is(this._value,s)},Repeat.prototype.slice=function(s,o){var i=this.size;return wholeSlice(s,o,i)?this:new Repeat(this._value,resolveEnd(o,i)-resolveBegin(s,i))},Repeat.prototype.reverse=function(){return this},Repeat.prototype.indexOf=function(s){return is(this._value,s)?0:-1},Repeat.prototype.lastIndexOf=function(s){return is(this._value,s)?this.size:-1},Repeat.prototype.__iterate=function(s,o){for(var i=0;i=0&&o=0&&ii?iteratorDone():iteratorValue(s,_++,w)}))},Range.prototype.equals=function(s){return s instanceof Range?this._start===s._start&&this._end===s._end&&this._step===s._step:deepEqual(this,s)},createClass(Collection,Iterable),createClass(KeyedCollection,Collection),createClass(IndexedCollection,Collection),createClass(SetCollection,Collection),Collection.Keyed=KeyedCollection,Collection.Indexed=IndexedCollection,Collection.Set=SetCollection;var le="function"==typeof Math.imul&&-2===Math.imul(4294967295,2)?Math.imul:function imul(s,o){var i=65535&(s|=0),a=65535&(o|=0);return i*a+((s>>>16)*a+i*(o>>>16)<<16>>>0)|0};function smi(s){return s>>>1&1073741824|3221225471&s}function hash(s){if(!1===s||null==s)return 0;if("function"==typeof s.valueOf&&(!1===(s=s.valueOf())||null==s))return 0;if(!0===s)return 1;var o=typeof s;if("number"===o){if(s!=s||s===1/0)return 0;var i=0|s;for(i!==s&&(i^=4294967295*s);s>4294967295;)i^=s/=4294967295;return smi(i)}if("string"===o)return s.length>Se?cachedHashString(s):hashString(s);if("function"==typeof s.hashCode)return s.hashCode();if("object"===o)return hashJSObj(s);if("function"==typeof s.toString)return hashString(s.toString());throw new Error("Value type "+o+" cannot be hashed.")}function cachedHashString(s){var o=Pe[s];return void 0===o&&(o=hashString(s),xe===we&&(xe=0,Pe={}),xe++,Pe[s]=o),o}function hashString(s){for(var o=0,i=0;i0)switch(s.nodeType){case 1:return s.uniqueID;case 9:return s.documentElement&&s.documentElement.uniqueID}}var fe,ye="function"==typeof WeakMap;ye&&(fe=new WeakMap);var be=0,_e="__immutablehash__";"function"==typeof Symbol&&(_e=Symbol(_e));var Se=16,we=255,xe=0,Pe={};function assertNotInfinite(s){invariant(s!==1/0,"Cannot perform this action with an infinite size.")}function Map(s){return null==s?emptyMap():isMap(s)&&!isOrdered(s)?s:emptyMap().withMutations((function(o){var i=KeyedIterable(s);assertNotInfinite(i.size),i.forEach((function(s,i){return o.set(i,s)}))}))}function isMap(s){return!(!s||!s[Re])}createClass(Map,KeyedCollection),Map.of=function(){var o=s.call(arguments,0);return emptyMap().withMutations((function(s){for(var i=0;i=o.length)throw new Error("Missing value for key: "+o[i]);s.set(o[i],o[i+1])}}))},Map.prototype.toString=function(){return this.__toString("Map {","}")},Map.prototype.get=function(s,o){return this._root?this._root.get(0,void 0,s,o):o},Map.prototype.set=function(s,o){return updateMap(this,s,o)},Map.prototype.setIn=function(s,o){return this.updateIn(s,j,(function(){return o}))},Map.prototype.remove=function(s){return updateMap(this,s,j)},Map.prototype.deleteIn=function(s){return this.updateIn(s,(function(){return j}))},Map.prototype.update=function(s,o,i){return 1===arguments.length?s(this):this.updateIn([s],o,i)},Map.prototype.updateIn=function(s,o,i){i||(i=o,o=void 0);var a=updateInDeepMap(this,forceIterator(s),o,i);return a===j?void 0:a},Map.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._root=null,this.__hash=void 0,this.__altered=!0,this):emptyMap()},Map.prototype.merge=function(){return mergeIntoMapWith(this,void 0,arguments)},Map.prototype.mergeWith=function(o){return mergeIntoMapWith(this,o,s.call(arguments,1))},Map.prototype.mergeIn=function(o){var i=s.call(arguments,1);return this.updateIn(o,emptyMap(),(function(s){return"function"==typeof s.merge?s.merge.apply(s,i):i[i.length-1]}))},Map.prototype.mergeDeep=function(){return mergeIntoMapWith(this,deepMerger,arguments)},Map.prototype.mergeDeepWith=function(o){var i=s.call(arguments,1);return mergeIntoMapWith(this,deepMergerWith(o),i)},Map.prototype.mergeDeepIn=function(o){var i=s.call(arguments,1);return this.updateIn(o,emptyMap(),(function(s){return"function"==typeof s.mergeDeep?s.mergeDeep.apply(s,i):i[i.length-1]}))},Map.prototype.sort=function(s){return OrderedMap(sortFactory(this,s))},Map.prototype.sortBy=function(s,o){return OrderedMap(sortFactory(this,o,s))},Map.prototype.withMutations=function(s){var o=this.asMutable();return s(o),o.wasAltered()?o.__ensureOwner(this.__ownerID):this},Map.prototype.asMutable=function(){return this.__ownerID?this:this.__ensureOwner(new OwnerID)},Map.prototype.asImmutable=function(){return this.__ensureOwner()},Map.prototype.wasAltered=function(){return this.__altered},Map.prototype.__iterator=function(s,o){return new MapIterator(this,s,o)},Map.prototype.__iterate=function(s,o){var i=this,a=0;return this._root&&this._root.iterate((function(o){return a++,s(o[1],o[0],i)}),o),a},Map.prototype.__ensureOwner=function(s){return s===this.__ownerID?this:s?makeMap(this.size,this._root,s,this.__hash):(this.__ownerID=s,this.__altered=!1,this)},Map.isMap=isMap;var Te,Re="@@__IMMUTABLE_MAP__@@",$e=Map.prototype;function ArrayMapNode(s,o){this.ownerID=s,this.entries=o}function BitmapIndexedNode(s,o,i){this.ownerID=s,this.bitmap=o,this.nodes=i}function HashArrayMapNode(s,o,i){this.ownerID=s,this.count=o,this.nodes=i}function HashCollisionNode(s,o,i){this.ownerID=s,this.keyHash=o,this.entries=i}function ValueNode(s,o,i){this.ownerID=s,this.keyHash=o,this.entry=i}function MapIterator(s,o,i){this._type=o,this._reverse=i,this._stack=s._root&&mapIteratorFrame(s._root)}function mapIteratorValue(s,o){return iteratorValue(s,o[0],o[1])}function mapIteratorFrame(s,o){return{node:s,index:0,__prev:o}}function makeMap(s,o,i,a){var u=Object.create($e);return u.size=s,u._root=o,u.__ownerID=i,u.__hash=a,u.__altered=!1,u}function emptyMap(){return Te||(Te=makeMap(0))}function updateMap(s,o,i){var a,u;if(s._root){var _=MakeRef(L),w=MakeRef(B);if(a=updateNode(s._root,s.__ownerID,0,void 0,o,i,_,w),!w.value)return s;u=s.size+(_.value?i===j?-1:1:0)}else{if(i===j)return s;u=1,a=new ArrayMapNode(s.__ownerID,[[o,i]])}return s.__ownerID?(s.size=u,s._root=a,s.__hash=void 0,s.__altered=!0,s):a?makeMap(u,a):emptyMap()}function updateNode(s,o,i,a,u,_,w,x){return s?s.update(o,i,a,u,_,w,x):_===j?s:(SetRef(x),SetRef(w),new ValueNode(o,a,[u,_]))}function isLeafNode(s){return s.constructor===ValueNode||s.constructor===HashCollisionNode}function mergeIntoNode(s,o,i,a,u){if(s.keyHash===a)return new HashCollisionNode(o,a,[s.entry,u]);var _,x=(0===i?s.keyHash:s.keyHash>>>i)&C,j=(0===i?a:a>>>i)&C;return new BitmapIndexedNode(o,1<>>=1)w[C]=1&i?o[_++]:void 0;return w[a]=u,new HashArrayMapNode(s,_+1,w)}function mergeIntoMapWith(s,o,i){for(var a=[],u=0;u>1&1431655765))+(s>>2&858993459))+(s>>4)&252645135,s+=s>>8,127&(s+=s>>16)}function setIn(s,o,i,a){var u=a?s:arrCopy(s);return u[o]=i,u}function spliceIn(s,o,i,a){var u=s.length+1;if(a&&o+1===u)return s[o]=i,s;for(var _=new Array(u),w=0,x=0;x=qe)return createNodes(s,C,a,u);var V=s&&s===this.ownerID,U=V?C:arrCopy(C);return $?x?L===B-1?U.pop():U[L]=U.pop():U[L]=[a,u]:U.push([a,u]),V?(this.entries=U,this):new ArrayMapNode(s,U)}},BitmapIndexedNode.prototype.get=function(s,o,i,a){void 0===o&&(o=hash(i));var u=1<<((0===s?o:o>>>s)&C),_=this.bitmap;return _&u?this.nodes[popCount(_&u-1)].get(s+w,o,i,a):a},BitmapIndexedNode.prototype.update=function(s,o,i,a,u,_,x){void 0===i&&(i=hash(a));var L=(0===o?i:i>>>o)&C,B=1<=ze)return expandNodes(s,z,$,L,Z);if(V&&!Z&&2===z.length&&isLeafNode(z[1^U]))return z[1^U];if(V&&Z&&1===z.length&&isLeafNode(Z))return Z;var ee=s&&s===this.ownerID,ie=V?Z?$:$^B:$|B,ae=V?Z?setIn(z,U,Z,ee):spliceOut(z,U,ee):spliceIn(z,U,Z,ee);return ee?(this.bitmap=ie,this.nodes=ae,this):new BitmapIndexedNode(s,ie,ae)},HashArrayMapNode.prototype.get=function(s,o,i,a){void 0===o&&(o=hash(i));var u=(0===s?o:o>>>s)&C,_=this.nodes[u];return _?_.get(s+w,o,i,a):a},HashArrayMapNode.prototype.update=function(s,o,i,a,u,_,x){void 0===i&&(i=hash(a));var L=(0===o?i:i>>>o)&C,B=u===j,$=this.nodes,V=$[L];if(B&&!V)return this;var U=updateNode(V,s,o+w,i,a,u,_,x);if(U===V)return this;var z=this.count;if(V){if(!U&&--z0&&a=0&&s>>o&C;if(a>=this.array.length)return new VNode([],s);var u,_=0===a;if(o>0){var x=this.array[a];if((u=x&&x.removeBefore(s,o-w,i))===x&&_)return this}if(_&&!u)return this;var j=editableVNode(this,s);if(!_)for(var L=0;L>>o&C;if(u>=this.array.length)return this;if(o>0){var _=this.array[u];if((a=_&&_.removeAfter(s,o-w,i))===_&&u===this.array.length-1)return this}var x=editableVNode(this,s);return x.array.splice(u+1),a&&(x.array[u]=a),x};var Xe,Qe,et={};function iterateList(s,o){var i=s._origin,a=s._capacity,u=getTailOffset(a),_=s._tail;return iterateNodeOrLeaf(s._root,s._level,0);function iterateNodeOrLeaf(s,o,i){return 0===o?iterateLeaf(s,i):iterateNode(s,o,i)}function iterateLeaf(s,w){var C=w===u?_&&_.array:s&&s.array,j=w>i?0:i-w,L=a-w;return L>x&&(L=x),function(){if(j===L)return et;var s=o?--L:j++;return C&&C[s]}}function iterateNode(s,u,_){var C,j=s&&s.array,L=_>i?0:i-_>>u,B=1+(a-_>>u);return B>x&&(B=x),function(){for(;;){if(C){var s=C();if(s!==et)return s;C=null}if(L===B)return et;var i=o?--B:L++;C=iterateNodeOrLeaf(j&&j[i],u-w,_+(i<=s.size||o<0)return s.withMutations((function(s){o<0?setListBounds(s,o).set(0,i):setListBounds(s,0,o+1).set(o,i)}));o+=s._origin;var a=s._tail,u=s._root,_=MakeRef(B);return o>=getTailOffset(s._capacity)?a=updateVNode(a,s.__ownerID,0,o,i,_):u=updateVNode(u,s.__ownerID,s._level,o,i,_),_.value?s.__ownerID?(s._root=u,s._tail=a,s.__hash=void 0,s.__altered=!0,s):makeList(s._origin,s._capacity,s._level,u,a):s}function updateVNode(s,o,i,a,u,_){var x,j=a>>>i&C,L=s&&j0){var B=s&&s.array[j],$=updateVNode(B,o,i-w,a,u,_);return $===B?s:((x=editableVNode(s,o)).array[j]=$,x)}return L&&s.array[j]===u?s:(SetRef(_),x=editableVNode(s,o),void 0===u&&j===x.array.length-1?x.array.pop():x.array[j]=u,x)}function editableVNode(s,o){return o&&s&&o===s.ownerID?s:new VNode(s?s.array.slice():[],o)}function listNodeFor(s,o){if(o>=getTailOffset(s._capacity))return s._tail;if(o<1<0;)i=i.array[o>>>a&C],a-=w;return i}}function setListBounds(s,o,i){void 0!==o&&(o|=0),void 0!==i&&(i|=0);var a=s.__ownerID||new OwnerID,u=s._origin,_=s._capacity,x=u+o,j=void 0===i?_:i<0?_+i:u+i;if(x===u&&j===_)return s;if(x>=j)return s.clear();for(var L=s._level,B=s._root,$=0;x+$<0;)B=new VNode(B&&B.array.length?[void 0,B]:[],a),$+=1<<(L+=w);$&&(x+=$,u+=$,j+=$,_+=$);for(var V=getTailOffset(_),U=getTailOffset(j);U>=1<V?new VNode([],a):z;if(z&&U>V&&x<_&&z.array.length){for(var Z=B=editableVNode(B,a),ee=L;ee>w;ee-=w){var ie=V>>>ee&C;Z=Z.array[ie]=editableVNode(Z.array[ie],a)}Z.array[V>>>w&C]=z}if(j<_&&(Y=Y&&Y.removeAfter(a,0,j)),x>=U)x-=U,j-=U,L=w,B=null,Y=Y&&Y.removeBefore(a,0,x);else if(x>u||U>>L&C;if(ae!==U>>>L&C)break;ae&&($+=(1<u&&(B=B.removeBefore(a,L,x-$)),B&&Uu&&(u=x.size),isIterable(w)||(x=x.map((function(s){return fromJS(s)}))),a.push(x)}return u>s.size&&(s=s.setSize(u)),mergeIntoCollectionWith(s,o,a)}function getTailOffset(s){return s>>w<=x&&w.size>=2*_.size?(a=(u=w.filter((function(s,o){return void 0!==s&&C!==o}))).toKeyedSeq().map((function(s){return s[0]})).flip().toMap(),s.__ownerID&&(a.__ownerID=u.__ownerID=s.__ownerID)):(a=_.remove(o),u=C===w.size-1?w.pop():w.set(C,void 0))}else if(L){if(i===w.get(C)[1])return s;a=_,u=w.set(C,[o,i])}else a=_.set(o,w.size),u=w.set(w.size,[o,i]);return s.__ownerID?(s.size=a.size,s._map=a,s._list=u,s.__hash=void 0,s):makeOrderedMap(a,u)}function ToKeyedSequence(s,o){this._iter=s,this._useKeys=o,this.size=s.size}function ToIndexedSequence(s){this._iter=s,this.size=s.size}function ToSetSequence(s){this._iter=s,this.size=s.size}function FromEntriesSequence(s){this._iter=s,this.size=s.size}function flipFactory(s){var o=makeSequence(s);return o._iter=s,o.size=s.size,o.flip=function(){return s},o.reverse=function(){var o=s.reverse.apply(this);return o.flip=function(){return s.reverse()},o},o.has=function(o){return s.includes(o)},o.includes=function(o){return s.has(o)},o.cacheResult=cacheResultThrough,o.__iterateUncached=function(o,i){var a=this;return s.__iterate((function(s,i){return!1!==o(i,s,a)}),i)},o.__iteratorUncached=function(o,i){if(o===U){var a=s.__iterator(o,i);return new Iterator((function(){var s=a.next();if(!s.done){var o=s.value[0];s.value[0]=s.value[1],s.value[1]=o}return s}))}return s.__iterator(o===V?$:V,i)},o}function mapFactory(s,o,i){var a=makeSequence(s);return a.size=s.size,a.has=function(o){return s.has(o)},a.get=function(a,u){var _=s.get(a,j);return _===j?u:o.call(i,_,a,s)},a.__iterateUncached=function(a,u){var _=this;return s.__iterate((function(s,u,w){return!1!==a(o.call(i,s,u,w),u,_)}),u)},a.__iteratorUncached=function(a,u){var _=s.__iterator(U,u);return new Iterator((function(){var u=_.next();if(u.done)return u;var w=u.value,x=w[0];return iteratorValue(a,x,o.call(i,w[1],x,s),u)}))},a}function reverseFactory(s,o){var i=makeSequence(s);return i._iter=s,i.size=s.size,i.reverse=function(){return s},s.flip&&(i.flip=function(){var o=flipFactory(s);return o.reverse=function(){return s.flip()},o}),i.get=function(i,a){return s.get(o?i:-1-i,a)},i.has=function(i){return s.has(o?i:-1-i)},i.includes=function(o){return s.includes(o)},i.cacheResult=cacheResultThrough,i.__iterate=function(o,i){var a=this;return s.__iterate((function(s,i){return o(s,i,a)}),!i)},i.__iterator=function(o,i){return s.__iterator(o,!i)},i}function filterFactory(s,o,i,a){var u=makeSequence(s);return a&&(u.has=function(a){var u=s.get(a,j);return u!==j&&!!o.call(i,u,a,s)},u.get=function(a,u){var _=s.get(a,j);return _!==j&&o.call(i,_,a,s)?_:u}),u.__iterateUncached=function(u,_){var w=this,x=0;return s.__iterate((function(s,_,C){if(o.call(i,s,_,C))return x++,u(s,a?_:x-1,w)}),_),x},u.__iteratorUncached=function(u,_){var w=s.__iterator(U,_),x=0;return new Iterator((function(){for(;;){var _=w.next();if(_.done)return _;var C=_.value,j=C[0],L=C[1];if(o.call(i,L,j,s))return iteratorValue(u,a?j:x++,L,_)}}))},u}function countByFactory(s,o,i){var a=Map().asMutable();return s.__iterate((function(u,_){a.update(o.call(i,u,_,s),0,(function(s){return s+1}))})),a.asImmutable()}function groupByFactory(s,o,i){var a=isKeyed(s),u=(isOrdered(s)?OrderedMap():Map()).asMutable();s.__iterate((function(_,w){u.update(o.call(i,_,w,s),(function(s){return(s=s||[]).push(a?[w,_]:_),s}))}));var _=iterableClass(s);return u.map((function(o){return reify(s,_(o))}))}function sliceFactory(s,o,i,a){var u=s.size;if(void 0!==o&&(o|=0),void 0!==i&&(i===1/0?i=u:i|=0),wholeSlice(o,i,u))return s;var _=resolveBegin(o,u),w=resolveEnd(i,u);if(_!=_||w!=w)return sliceFactory(s.toSeq().cacheResult(),o,i,a);var x,C=w-_;C==C&&(x=C<0?0:C);var j=makeSequence(s);return j.size=0===x?x:s.size&&x||void 0,!a&&isSeq(s)&&x>=0&&(j.get=function(o,i){return(o=wrapIndex(this,o))>=0&&ox)return iteratorDone();var s=u.next();return a||o===V?s:iteratorValue(o,C-1,o===$?void 0:s.value[1],s)}))},j}function takeWhileFactory(s,o,i){var a=makeSequence(s);return a.__iterateUncached=function(a,u){var _=this;if(u)return this.cacheResult().__iterate(a,u);var w=0;return s.__iterate((function(s,u,x){return o.call(i,s,u,x)&&++w&&a(s,u,_)})),w},a.__iteratorUncached=function(a,u){var _=this;if(u)return this.cacheResult().__iterator(a,u);var w=s.__iterator(U,u),x=!0;return new Iterator((function(){if(!x)return iteratorDone();var s=w.next();if(s.done)return s;var u=s.value,C=u[0],j=u[1];return o.call(i,j,C,_)?a===U?s:iteratorValue(a,C,j,s):(x=!1,iteratorDone())}))},a}function skipWhileFactory(s,o,i,a){var u=makeSequence(s);return u.__iterateUncached=function(u,_){var w=this;if(_)return this.cacheResult().__iterate(u,_);var x=!0,C=0;return s.__iterate((function(s,_,j){if(!x||!(x=o.call(i,s,_,j)))return C++,u(s,a?_:C-1,w)})),C},u.__iteratorUncached=function(u,_){var w=this;if(_)return this.cacheResult().__iterator(u,_);var x=s.__iterator(U,_),C=!0,j=0;return new Iterator((function(){var s,_,L;do{if((s=x.next()).done)return a||u===V?s:iteratorValue(u,j++,u===$?void 0:s.value[1],s);var B=s.value;_=B[0],L=B[1],C&&(C=o.call(i,L,_,w))}while(C);return u===U?s:iteratorValue(u,_,L,s)}))},u}function concatFactory(s,o){var i=isKeyed(s),a=[s].concat(o).map((function(s){return isIterable(s)?i&&(s=KeyedIterable(s)):s=i?keyedSeqFromValue(s):indexedSeqFromValue(Array.isArray(s)?s:[s]),s})).filter((function(s){return 0!==s.size}));if(0===a.length)return s;if(1===a.length){var u=a[0];if(u===s||i&&isKeyed(u)||isIndexed(s)&&isIndexed(u))return u}var _=new ArraySeq(a);return i?_=_.toKeyedSeq():isIndexed(s)||(_=_.toSetSeq()),(_=_.flatten(!0)).size=a.reduce((function(s,o){if(void 0!==s){var i=o.size;if(void 0!==i)return s+i}}),0),_}function flattenFactory(s,o,i){var a=makeSequence(s);return a.__iterateUncached=function(a,u){var _=0,w=!1;function flatDeep(s,x){var C=this;s.__iterate((function(s,u){return(!o||x0}function zipWithFactory(s,o,i){var a=makeSequence(s);return a.size=new ArraySeq(i).map((function(s){return s.size})).min(),a.__iterate=function(s,o){for(var i,a=this.__iterator(V,o),u=0;!(i=a.next()).done&&!1!==s(i.value,u++,this););return u},a.__iteratorUncached=function(s,a){var u=i.map((function(s){return s=Iterable(s),getIterator(a?s.reverse():s)})),_=0,w=!1;return new Iterator((function(){var i;return w||(i=u.map((function(s){return s.next()})),w=i.some((function(s){return s.done}))),w?iteratorDone():iteratorValue(s,_++,o.apply(null,i.map((function(s){return s.value}))))}))},a}function reify(s,o){return isSeq(s)?o:s.constructor(o)}function validateEntry(s){if(s!==Object(s))throw new TypeError("Expected [K, V] tuple: "+s)}function resolveSize(s){return assertNotInfinite(s.size),ensureSize(s)}function iterableClass(s){return isKeyed(s)?KeyedIterable:isIndexed(s)?IndexedIterable:SetIterable}function makeSequence(s){return Object.create((isKeyed(s)?KeyedSeq:isIndexed(s)?IndexedSeq:SetSeq).prototype)}function cacheResultThrough(){return this._iter.cacheResult?(this._iter.cacheResult(),this.size=this._iter.size,this):Seq.prototype.cacheResult.call(this)}function defaultComparator(s,o){return s>o?1:s=0;i--)o={value:arguments[i],next:o};return this.__ownerID?(this.size=s,this._head=o,this.__hash=void 0,this.__altered=!0,this):makeStack(s,o)},Stack.prototype.pushAll=function(s){if(0===(s=IndexedIterable(s)).size)return this;assertNotInfinite(s.size);var o=this.size,i=this._head;return s.reverse().forEach((function(s){o++,i={value:s,next:i}})),this.__ownerID?(this.size=o,this._head=i,this.__hash=void 0,this.__altered=!0,this):makeStack(o,i)},Stack.prototype.pop=function(){return this.slice(1)},Stack.prototype.unshift=function(){return this.push.apply(this,arguments)},Stack.prototype.unshiftAll=function(s){return this.pushAll(s)},Stack.prototype.shift=function(){return this.pop.apply(this,arguments)},Stack.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._head=void 0,this.__hash=void 0,this.__altered=!0,this):emptyStack()},Stack.prototype.slice=function(s,o){if(wholeSlice(s,o,this.size))return this;var i=resolveBegin(s,this.size);if(resolveEnd(o,this.size)!==this.size)return IndexedCollection.prototype.slice.call(this,s,o);for(var a=this.size-i,u=this._head;i--;)u=u.next;return this.__ownerID?(this.size=a,this._head=u,this.__hash=void 0,this.__altered=!0,this):makeStack(a,u)},Stack.prototype.__ensureOwner=function(s){return s===this.__ownerID?this:s?makeStack(this.size,this._head,s,this.__hash):(this.__ownerID=s,this.__altered=!1,this)},Stack.prototype.__iterate=function(s,o){if(o)return this.reverse().__iterate(s);for(var i=0,a=this._head;a&&!1!==s(a.value,i++,this);)a=a.next;return i},Stack.prototype.__iterator=function(s,o){if(o)return this.reverse().__iterator(s);var i=0,a=this._head;return new Iterator((function(){if(a){var o=a.value;return a=a.next,iteratorValue(s,i++,o)}return iteratorDone()}))},Stack.isStack=isStack;var at,ct="@@__IMMUTABLE_STACK__@@",lt=Stack.prototype;function makeStack(s,o,i,a){var u=Object.create(lt);return u.size=s,u._head=o,u.__ownerID=i,u.__hash=a,u.__altered=!1,u}function emptyStack(){return at||(at=makeStack(0))}function mixin(s,o){var keyCopier=function(i){s.prototype[i]=o[i]};return Object.keys(o).forEach(keyCopier),Object.getOwnPropertySymbols&&Object.getOwnPropertySymbols(o).forEach(keyCopier),s}lt[ct]=!0,lt.withMutations=$e.withMutations,lt.asMutable=$e.asMutable,lt.asImmutable=$e.asImmutable,lt.wasAltered=$e.wasAltered,Iterable.Iterator=Iterator,mixin(Iterable,{toArray:function(){assertNotInfinite(this.size);var s=new Array(this.size||0);return this.valueSeq().__iterate((function(o,i){s[i]=o})),s},toIndexedSeq:function(){return new ToIndexedSequence(this)},toJS:function(){return this.toSeq().map((function(s){return s&&"function"==typeof s.toJS?s.toJS():s})).__toJS()},toJSON:function(){return this.toSeq().map((function(s){return s&&"function"==typeof s.toJSON?s.toJSON():s})).__toJS()},toKeyedSeq:function(){return new ToKeyedSequence(this,!0)},toMap:function(){return Map(this.toKeyedSeq())},toObject:function(){assertNotInfinite(this.size);var s={};return this.__iterate((function(o,i){s[i]=o})),s},toOrderedMap:function(){return OrderedMap(this.toKeyedSeq())},toOrderedSet:function(){return OrderedSet(isKeyed(this)?this.valueSeq():this)},toSet:function(){return Set(isKeyed(this)?this.valueSeq():this)},toSetSeq:function(){return new ToSetSequence(this)},toSeq:function(){return isIndexed(this)?this.toIndexedSeq():isKeyed(this)?this.toKeyedSeq():this.toSetSeq()},toStack:function(){return Stack(isKeyed(this)?this.valueSeq():this)},toList:function(){return List(isKeyed(this)?this.valueSeq():this)},toString:function(){return"[Iterable]"},__toString:function(s,o){return 0===this.size?s+o:s+" "+this.toSeq().map(this.__toStringMapper).join(", ")+" "+o},concat:function(){return reify(this,concatFactory(this,s.call(arguments,0)))},includes:function(s){return this.some((function(o){return is(o,s)}))},entries:function(){return this.__iterator(U)},every:function(s,o){assertNotInfinite(this.size);var i=!0;return this.__iterate((function(a,u,_){if(!s.call(o,a,u,_))return i=!1,!1})),i},filter:function(s,o){return reify(this,filterFactory(this,s,o,!0))},find:function(s,o,i){var a=this.findEntry(s,o);return a?a[1]:i},forEach:function(s,o){return assertNotInfinite(this.size),this.__iterate(o?s.bind(o):s)},join:function(s){assertNotInfinite(this.size),s=void 0!==s?""+s:",";var o="",i=!0;return this.__iterate((function(a){i?i=!1:o+=s,o+=null!=a?a.toString():""})),o},keys:function(){return this.__iterator($)},map:function(s,o){return reify(this,mapFactory(this,s,o))},reduce:function(s,o,i){var a,u;return assertNotInfinite(this.size),arguments.length<2?u=!0:a=o,this.__iterate((function(o,_,w){u?(u=!1,a=o):a=s.call(i,a,o,_,w)})),a},reduceRight:function(s,o,i){var a=this.toKeyedSeq().reverse();return a.reduce.apply(a,arguments)},reverse:function(){return reify(this,reverseFactory(this,!0))},slice:function(s,o){return reify(this,sliceFactory(this,s,o,!0))},some:function(s,o){return!this.every(not(s),o)},sort:function(s){return reify(this,sortFactory(this,s))},values:function(){return this.__iterator(V)},butLast:function(){return this.slice(0,-1)},isEmpty:function(){return void 0!==this.size?0===this.size:!this.some((function(){return!0}))},count:function(s,o){return ensureSize(s?this.toSeq().filter(s,o):this)},countBy:function(s,o){return countByFactory(this,s,o)},equals:function(s){return deepEqual(this,s)},entrySeq:function(){var s=this;if(s._cache)return new ArraySeq(s._cache);var o=s.toSeq().map(entryMapper).toIndexedSeq();return o.fromEntrySeq=function(){return s.toSeq()},o},filterNot:function(s,o){return this.filter(not(s),o)},findEntry:function(s,o,i){var a=i;return this.__iterate((function(i,u,_){if(s.call(o,i,u,_))return a=[u,i],!1})),a},findKey:function(s,o){var i=this.findEntry(s,o);return i&&i[0]},findLast:function(s,o,i){return this.toKeyedSeq().reverse().find(s,o,i)},findLastEntry:function(s,o,i){return this.toKeyedSeq().reverse().findEntry(s,o,i)},findLastKey:function(s,o){return this.toKeyedSeq().reverse().findKey(s,o)},first:function(){return this.find(returnTrue)},flatMap:function(s,o){return reify(this,flatMapFactory(this,s,o))},flatten:function(s){return reify(this,flattenFactory(this,s,!0))},fromEntrySeq:function(){return new FromEntriesSequence(this)},get:function(s,o){return this.find((function(o,i){return is(i,s)}),void 0,o)},getIn:function(s,o){for(var i,a=this,u=forceIterator(s);!(i=u.next()).done;){var _=i.value;if((a=a&&a.get?a.get(_,j):j)===j)return o}return a},groupBy:function(s,o){return groupByFactory(this,s,o)},has:function(s){return this.get(s,j)!==j},hasIn:function(s){return this.getIn(s,j)!==j},isSubset:function(s){return s="function"==typeof s.includes?s:Iterable(s),this.every((function(o){return s.includes(o)}))},isSuperset:function(s){return(s="function"==typeof s.isSubset?s:Iterable(s)).isSubset(this)},keyOf:function(s){return this.findKey((function(o){return is(o,s)}))},keySeq:function(){return this.toSeq().map(keyMapper).toIndexedSeq()},last:function(){return this.toSeq().reverse().first()},lastKeyOf:function(s){return this.toKeyedSeq().reverse().keyOf(s)},max:function(s){return maxFactory(this,s)},maxBy:function(s,o){return maxFactory(this,o,s)},min:function(s){return maxFactory(this,s?neg(s):defaultNegComparator)},minBy:function(s,o){return maxFactory(this,o?neg(o):defaultNegComparator,s)},rest:function(){return this.slice(1)},skip:function(s){return this.slice(Math.max(0,s))},skipLast:function(s){return reify(this,this.toSeq().reverse().skip(s).reverse())},skipWhile:function(s,o){return reify(this,skipWhileFactory(this,s,o,!0))},skipUntil:function(s,o){return this.skipWhile(not(s),o)},sortBy:function(s,o){return reify(this,sortFactory(this,o,s))},take:function(s){return this.slice(0,Math.max(0,s))},takeLast:function(s){return reify(this,this.toSeq().reverse().take(s).reverse())},takeWhile:function(s,o){return reify(this,takeWhileFactory(this,s,o))},takeUntil:function(s,o){return this.takeWhile(not(s),o)},valueSeq:function(){return this.toIndexedSeq()},hashCode:function(){return this.__hash||(this.__hash=hashIterable(this))}});var ut=Iterable.prototype;ut[o]=!0,ut[Z]=ut.values,ut.__toJS=ut.toArray,ut.__toStringMapper=quoteString,ut.inspect=ut.toSource=function(){return this.toString()},ut.chain=ut.flatMap,ut.contains=ut.includes,mixin(KeyedIterable,{flip:function(){return reify(this,flipFactory(this))},mapEntries:function(s,o){var i=this,a=0;return reify(this,this.toSeq().map((function(u,_){return s.call(o,[_,u],a++,i)})).fromEntrySeq())},mapKeys:function(s,o){var i=this;return reify(this,this.toSeq().flip().map((function(a,u){return s.call(o,a,u,i)})).flip())}});var pt=KeyedIterable.prototype;function keyMapper(s,o){return o}function entryMapper(s,o){return[o,s]}function not(s){return function(){return!s.apply(this,arguments)}}function neg(s){return function(){return-s.apply(this,arguments)}}function quoteString(s){return"string"==typeof s?JSON.stringify(s):String(s)}function defaultZipper(){return arrCopy(arguments)}function defaultNegComparator(s,o){return so?-1:0}function hashIterable(s){if(s.size===1/0)return 0;var o=isOrdered(s),i=isKeyed(s),a=o?1:0;return murmurHashOfSize(s.__iterate(i?o?function(s,o){a=31*a+hashMerge(hash(s),hash(o))|0}:function(s,o){a=a+hashMerge(hash(s),hash(o))|0}:o?function(s){a=31*a+hash(s)|0}:function(s){a=a+hash(s)|0}),a)}function murmurHashOfSize(s,o){return o=le(o,3432918353),o=le(o<<15|o>>>-15,461845907),o=le(o<<13|o>>>-13,5),o=le((o=o+3864292196^s)^o>>>16,2246822507),o=smi((o=le(o^o>>>13,3266489909))^o>>>16)}function hashMerge(s,o){return s^o+2654435769+(s<<6)+(s>>2)}return pt[i]=!0,pt[Z]=ut.entries,pt.__toJS=ut.toObject,pt.__toStringMapper=function(s,o){return JSON.stringify(o)+": "+quoteString(s)},mixin(IndexedIterable,{toKeyedSeq:function(){return new ToKeyedSequence(this,!1)},filter:function(s,o){return reify(this,filterFactory(this,s,o,!1))},findIndex:function(s,o){var i=this.findEntry(s,o);return i?i[0]:-1},indexOf:function(s){var o=this.keyOf(s);return void 0===o?-1:o},lastIndexOf:function(s){var o=this.lastKeyOf(s);return void 0===o?-1:o},reverse:function(){return reify(this,reverseFactory(this,!1))},slice:function(s,o){return reify(this,sliceFactory(this,s,o,!1))},splice:function(s,o){var i=arguments.length;if(o=Math.max(0|o,0),0===i||2===i&&!o)return this;s=resolveBegin(s,s<0?this.count():this.size);var a=this.slice(0,s);return reify(this,1===i?a:a.concat(arrCopy(arguments,2),this.slice(s+o)))},findLastIndex:function(s,o){var i=this.findLastEntry(s,o);return i?i[0]:-1},first:function(){return this.get(0)},flatten:function(s){return reify(this,flattenFactory(this,s,!1))},get:function(s,o){return(s=wrapIndex(this,s))<0||this.size===1/0||void 0!==this.size&&s>this.size?o:this.find((function(o,i){return i===s}),void 0,o)},has:function(s){return(s=wrapIndex(this,s))>=0&&(void 0!==this.size?this.size===1/0||s{"use strict";i(71340);var a=i(92046);s.exports=a.Object.assign},9999:(s,o,i)=>{var a=i(37217),u=i(83729),_=i(16547),w=i(74733),x=i(43838),C=i(93290),j=i(23007),L=i(92271),B=i(48948),$=i(50002),V=i(83349),U=i(5861),z=i(76189),Y=i(77199),Z=i(35529),ee=i(56449),ie=i(3656),ae=i(87730),ce=i(23805),le=i(38440),pe=i(95950),de=i(37241),fe="[object Arguments]",ye="[object Function]",be="[object Object]",_e={};_e[fe]=_e["[object Array]"]=_e["[object ArrayBuffer]"]=_e["[object DataView]"]=_e["[object Boolean]"]=_e["[object Date]"]=_e["[object Float32Array]"]=_e["[object Float64Array]"]=_e["[object Int8Array]"]=_e["[object Int16Array]"]=_e["[object Int32Array]"]=_e["[object Map]"]=_e["[object Number]"]=_e[be]=_e["[object RegExp]"]=_e["[object Set]"]=_e["[object String]"]=_e["[object Symbol]"]=_e["[object Uint8Array]"]=_e["[object Uint8ClampedArray]"]=_e["[object Uint16Array]"]=_e["[object Uint32Array]"]=!0,_e["[object Error]"]=_e[ye]=_e["[object WeakMap]"]=!1,s.exports=function baseClone(s,o,i,Se,we,xe){var Pe,Te=1&o,Re=2&o,$e=4&o;if(i&&(Pe=we?i(s,Se,we,xe):i(s)),void 0!==Pe)return Pe;if(!ce(s))return s;var qe=ee(s);if(qe){if(Pe=z(s),!Te)return j(s,Pe)}else{var ze=U(s),We=ze==ye||"[object GeneratorFunction]"==ze;if(ie(s))return C(s,Te);if(ze==be||ze==fe||We&&!we){if(Pe=Re||We?{}:Z(s),!Te)return Re?B(s,x(Pe,s)):L(s,w(Pe,s))}else{if(!_e[ze])return we?s:{};Pe=Y(s,ze,Te)}}xe||(xe=new a);var He=xe.get(s);if(He)return He;xe.set(s,Pe),le(s)?s.forEach((function(a){Pe.add(baseClone(a,o,i,a,s,xe))})):ae(s)&&s.forEach((function(a,u){Pe.set(u,baseClone(a,o,i,u,s,xe))}));var Ye=qe?void 0:($e?Re?V:$:Re?de:pe)(s);return u(Ye||s,(function(a,u){Ye&&(a=s[u=a]),_(Pe,u,baseClone(a,o,i,u,s,xe))})),Pe}},10023:(s,o,i)=>{const a=i(6205),INTS=()=>[{type:a.RANGE,from:48,to:57}],WORDS=()=>[{type:a.CHAR,value:95},{type:a.RANGE,from:97,to:122},{type:a.RANGE,from:65,to:90}].concat(INTS()),WHITESPACE=()=>[{type:a.CHAR,value:9},{type:a.CHAR,value:10},{type:a.CHAR,value:11},{type:a.CHAR,value:12},{type:a.CHAR,value:13},{type:a.CHAR,value:32},{type:a.CHAR,value:160},{type:a.CHAR,value:5760},{type:a.RANGE,from:8192,to:8202},{type:a.CHAR,value:8232},{type:a.CHAR,value:8233},{type:a.CHAR,value:8239},{type:a.CHAR,value:8287},{type:a.CHAR,value:12288},{type:a.CHAR,value:65279}];o.words=()=>({type:a.SET,set:WORDS(),not:!1}),o.notWords=()=>({type:a.SET,set:WORDS(),not:!0}),o.ints=()=>({type:a.SET,set:INTS(),not:!1}),o.notInts=()=>({type:a.SET,set:INTS(),not:!0}),o.whitespace=()=>({type:a.SET,set:WHITESPACE(),not:!1}),o.notWhitespace=()=>({type:a.SET,set:WHITESPACE(),not:!0}),o.anyChar=()=>({type:a.SET,set:[{type:a.CHAR,value:10},{type:a.CHAR,value:13},{type:a.CHAR,value:8232},{type:a.CHAR,value:8233}],not:!0})},10043:(s,o,i)=>{"use strict";var a=i(54018),u=String,_=TypeError;s.exports=function(s){if(a(s))return s;throw new _("Can't set "+u(s)+" as a prototype")}},10124:(s,o,i)=>{var a=i(9325);s.exports=function(){return a.Date.now()}},10300:(s,o,i)=>{"use strict";var a=i(13930),u=i(82159),_=i(36624),w=i(4640),x=i(73448),C=TypeError;s.exports=function(s,o){var i=arguments.length<2?x(s):o;if(u(i))return _(a(i,s));throw new C(w(s)+" is not iterable")}},10316:(s,o,i)=>{const a=i(2404),u=i(55973),_=i(92340);class Element{constructor(s,o,i){o&&(this.meta=o),i&&(this.attributes=i),this.content=s}freeze(){Object.isFrozen(this)||(this._meta&&(this.meta.parent=this,this.meta.freeze()),this._attributes&&(this.attributes.parent=this,this.attributes.freeze()),this.children.forEach((s=>{s.parent=this,s.freeze()}),this),this.content&&Array.isArray(this.content)&&Object.freeze(this.content),Object.freeze(this))}primitive(){}clone(){const s=new this.constructor;return s.element=this.element,this.meta.length&&(s._meta=this.meta.clone()),this.attributes.length&&(s._attributes=this.attributes.clone()),this.content?this.content.clone?s.content=this.content.clone():Array.isArray(this.content)?s.content=this.content.map((s=>s.clone())):s.content=this.content:s.content=this.content,s}toValue(){return this.content instanceof Element?this.content.toValue():this.content instanceof u?{key:this.content.key.toValue(),value:this.content.value?this.content.value.toValue():void 0}:this.content&&this.content.map?this.content.map((s=>s.toValue()),this):this.content}toRef(s){if(""===this.id.toValue())throw Error("Cannot create reference to an element that does not contain an ID");const o=new this.RefElement(this.id.toValue());return s&&(o.path=s),o}findRecursive(...s){if(arguments.length>1&&!this.isFrozen)throw new Error("Cannot find recursive with multiple element names without first freezing the element. Call `element.freeze()`");const o=s.pop();let i=new _;const append=(s,o)=>(s.push(o),s),checkElement=(s,i)=>{i.element===o&&s.push(i);const a=i.findRecursive(o);return a&&a.reduce(append,s),i.content instanceof u&&(i.content.key&&checkElement(s,i.content.key),i.content.value&&checkElement(s,i.content.value)),s};return this.content&&(this.content.element&&checkElement(i,this.content),Array.isArray(this.content)&&this.content.reduce(checkElement,i)),s.isEmpty||(i=i.filter((o=>{let i=o.parents.map((s=>s.element));for(const o in s){const a=s[o],u=i.indexOf(a);if(-1===u)return!1;i=i.splice(0,u)}return!0}))),i}set(s){return this.content=s,this}equals(s){return a(this.toValue(),s)}getMetaProperty(s,o){if(!this.meta.hasKey(s)){if(this.isFrozen){const s=this.refract(o);return s.freeze(),s}this.meta.set(s,o)}return this.meta.get(s)}setMetaProperty(s,o){this.meta.set(s,o)}get element(){return this._storedElement||"element"}set element(s){this._storedElement=s}get content(){return this._content}set content(s){if(s instanceof Element)this._content=s;else if(s instanceof _)this.content=s.elements;else if("string"==typeof s||"number"==typeof s||"boolean"==typeof s||"null"===s||null==s)this._content=s;else if(s instanceof u)this._content=s;else if(Array.isArray(s))this._content=s.map(this.refract);else{if("object"!=typeof s)throw new Error("Cannot set content to given value");this._content=Object.keys(s).map((o=>new this.MemberElement(o,s[o])))}}get meta(){if(!this._meta){if(this.isFrozen){const s=new this.ObjectElement;return s.freeze(),s}this._meta=new this.ObjectElement}return this._meta}set meta(s){s instanceof this.ObjectElement?this._meta=s:this.meta.set(s||{})}get attributes(){if(!this._attributes){if(this.isFrozen){const s=new this.ObjectElement;return s.freeze(),s}this._attributes=new this.ObjectElement}return this._attributes}set attributes(s){s instanceof this.ObjectElement?this._attributes=s:this.attributes.set(s||{})}get id(){return this.getMetaProperty("id","")}set id(s){this.setMetaProperty("id",s)}get classes(){return this.getMetaProperty("classes",[])}set classes(s){this.setMetaProperty("classes",s)}get title(){return this.getMetaProperty("title","")}set title(s){this.setMetaProperty("title",s)}get description(){return this.getMetaProperty("description","")}set description(s){this.setMetaProperty("description",s)}get links(){return this.getMetaProperty("links",[])}set links(s){this.setMetaProperty("links",s)}get isFrozen(){return Object.isFrozen(this)}get parents(){let{parent:s}=this;const o=new _;for(;s;)o.push(s),s=s.parent;return o}get children(){if(Array.isArray(this.content))return new _(this.content);if(this.content instanceof u){const s=new _([this.content.key]);return this.content.value&&s.push(this.content.value),s}return this.content instanceof Element?new _([this.content]):new _}get recursiveChildren(){const s=new _;return this.children.forEach((o=>{s.push(o),o.recursiveChildren.forEach((o=>{s.push(o)}))})),s}}s.exports=Element},10392:s=>{s.exports=function getValue(s,o){return null==s?void 0:s[o]}},10776:(s,o,i)=>{var a=i(30756),u=i(95950);s.exports=function getMatchData(s){for(var o=u(s),i=o.length;i--;){var _=o[i],w=s[_];o[i]=[_,w,a(w)]}return o}},10866:(s,o,i)=>{const a=i(6048),u=i(92340);class ObjectSlice extends u{map(s,o){return this.elements.map((i=>s.bind(o)(i.value,i.key,i)))}filter(s,o){return new ObjectSlice(this.elements.filter((i=>s.bind(o)(i.value,i.key,i))))}reject(s,o){return this.filter(a(s.bind(o)))}forEach(s,o){return this.elements.forEach(((i,a)=>{s.bind(o)(i.value,i.key,i,a)}))}keys(){return this.map(((s,o)=>o.toValue()))}values(){return this.map((s=>s.toValue()))}}s.exports=ObjectSlice},11042:(s,o,i)=>{"use strict";var a=i(85582),u=i(1907),_=i(24443),w=i(87170),x=i(36624),C=u([].concat);s.exports=a("Reflect","ownKeys")||function ownKeys(s){var o=_.f(x(s)),i=w.f;return i?C(o,i(s)):o}},11091:(s,o,i)=>{"use strict";var a=i(45951),u=i(76024),_=i(92361),w=i(62250),x=i(13846).f,C=i(7463),j=i(92046),L=i(28311),B=i(61626),$=i(49724);i(36128);var wrapConstructor=function(s){var Wrapper=function(o,i,a){if(this instanceof Wrapper){switch(arguments.length){case 0:return new s;case 1:return new s(o);case 2:return new s(o,i)}return new s(o,i,a)}return u(s,this,arguments)};return Wrapper.prototype=s.prototype,Wrapper};s.exports=function(s,o){var i,u,V,U,z,Y,Z,ee,ie,ae=s.target,ce=s.global,le=s.stat,pe=s.proto,de=ce?a:le?a[ae]:a[ae]&&a[ae].prototype,fe=ce?j:j[ae]||B(j,ae,{})[ae],ye=fe.prototype;for(U in o)u=!(i=C(ce?U:ae+(le?".":"#")+U,s.forced))&&de&&$(de,U),Y=fe[U],u&&(Z=s.dontCallGetSet?(ie=x(de,U))&&ie.value:de[U]),z=u&&Z?Z:o[U],(i||pe||typeof Y!=typeof z)&&(ee=s.bind&&u?L(z,a):s.wrap&&u?wrapConstructor(z):pe&&w(z)?_(z):z,(s.sham||z&&z.sham||Y&&Y.sham)&&B(ee,"sham",!0),B(fe,U,ee),pe&&($(j,V=ae+"Prototype")||B(j,V,{}),B(j[V],U,z),s.real&&ye&&(i||!ye[U])&&B(ye,U,z)))}},11287:s=>{s.exports=function getHolder(s){return s.placeholder}},11331:(s,o,i)=>{var a=i(72552),u=i(28879),_=i(40346),w=Function.prototype,x=Object.prototype,C=w.toString,j=x.hasOwnProperty,L=C.call(Object);s.exports=function isPlainObject(s){if(!_(s)||"[object Object]"!=a(s))return!1;var o=u(s);if(null===o)return!0;var i=j.call(o,"constructor")&&o.constructor;return"function"==typeof i&&i instanceof i&&C.call(i)==L}},11470:(s,o,i)=>{"use strict";var a=i(1907),u=i(65482),_=i(90160),w=i(74239),x=a("".charAt),C=a("".charCodeAt),j=a("".slice),createMethod=function(s){return function(o,i){var a,L,B=_(w(o)),$=u(i),V=B.length;return $<0||$>=V?s?"":void 0:(a=C(B,$))<55296||a>56319||$+1===V||(L=C(B,$+1))<56320||L>57343?s?x(B,$):a:s?j(B,$,$+2):L-56320+(a-55296<<10)+65536}};s.exports={codeAt:createMethod(!1),charAt:createMethod(!0)}},11842:(s,o,i)=>{var a=i(82819),u=i(9325);s.exports=function createBind(s,o,i){var _=1&o,w=a(s);return function wrapper(){return(this&&this!==u&&this instanceof wrapper?w:s).apply(_?i:this,arguments)}}},12242:(s,o,i)=>{const a=i(10316);s.exports=class BooleanElement extends a{constructor(s,o,i){super(s,o,i),this.element="boolean"}primitive(){return"boolean"}}},12507:(s,o,i)=>{var a=i(28754),u=i(49698),_=i(63912),w=i(13222);s.exports=function createCaseFirst(s){return function(o){o=w(o);var i=u(o)?_(o):void 0,x=i?i[0]:o.charAt(0),C=i?a(i,1).join(""):o.slice(1);return x[s]()+C}}},12560:(s,o,i)=>{"use strict";i(99363);var a=i(19287),u=i(45951),_=i(14840),w=i(93742);for(var x in a)_(u[x],x),w[x]=w.Array},12651:(s,o,i)=>{var a=i(74218);s.exports=function getMapData(s,o){var i=s.__data__;return a(o)?i["string"==typeof o?"string":"hash"]:i.map}},12749:(s,o,i)=>{var a=i(81042),u=Object.prototype.hasOwnProperty;s.exports=function hashHas(s){var o=this.__data__;return a?void 0!==o[s]:u.call(o,s)}},13222:(s,o,i)=>{var a=i(77556);s.exports=function toString(s){return null==s?"":a(s)}},13846:(s,o,i)=>{"use strict";var a=i(39447),u=i(13930),_=i(22574),w=i(75817),x=i(4993),C=i(70470),j=i(49724),L=i(73648),B=Object.getOwnPropertyDescriptor;o.f=a?B:function getOwnPropertyDescriptor(s,o){if(s=x(s),o=C(o),L)try{return B(s,o)}catch(s){}if(j(s,o))return w(!u(_.f,s,o),s[o])}},13930:(s,o,i)=>{"use strict";var a=i(41505),u=Function.prototype.call;s.exports=a?u.bind(u):function(){return u.apply(u,arguments)}},14248:s=>{s.exports=function arraySome(s,o){for(var i=-1,a=null==s?0:s.length;++i{s.exports=function arrayPush(s,o){for(var i=-1,a=o.length,u=s.length;++i{const a=i(10316);s.exports=class RefElement extends a{constructor(s,o,i){super(s||[],o,i),this.element="ref",this.path||(this.path="element")}get path(){return this.attributes.get("path")}set path(s){this.attributes.set("path",s)}}},14744:s=>{"use strict";var o=function isMergeableObject(s){return function isNonNullObject(s){return!!s&&"object"==typeof s}(s)&&!function isSpecial(s){var o=Object.prototype.toString.call(s);return"[object RegExp]"===o||"[object Date]"===o||function isReactElement(s){return s.$$typeof===i}(s)}(s)};var i="function"==typeof Symbol&&Symbol.for?Symbol.for("react.element"):60103;function cloneUnlessOtherwiseSpecified(s,o){return!1!==o.clone&&o.isMergeableObject(s)?deepmerge(function emptyTarget(s){return Array.isArray(s)?[]:{}}(s),s,o):s}function defaultArrayMerge(s,o,i){return s.concat(o).map((function(s){return cloneUnlessOtherwiseSpecified(s,i)}))}function getKeys(s){return Object.keys(s).concat(function getEnumerableOwnPropertySymbols(s){return Object.getOwnPropertySymbols?Object.getOwnPropertySymbols(s).filter((function(o){return Object.propertyIsEnumerable.call(s,o)})):[]}(s))}function propertyIsOnObject(s,o){try{return o in s}catch(s){return!1}}function mergeObject(s,o,i){var a={};return i.isMergeableObject(s)&&getKeys(s).forEach((function(o){a[o]=cloneUnlessOtherwiseSpecified(s[o],i)})),getKeys(o).forEach((function(u){(function propertyIsUnsafe(s,o){return propertyIsOnObject(s,o)&&!(Object.hasOwnProperty.call(s,o)&&Object.propertyIsEnumerable.call(s,o))})(s,u)||(propertyIsOnObject(s,u)&&i.isMergeableObject(o[u])?a[u]=function getMergeFunction(s,o){if(!o.customMerge)return deepmerge;var i=o.customMerge(s);return"function"==typeof i?i:deepmerge}(u,i)(s[u],o[u],i):a[u]=cloneUnlessOtherwiseSpecified(o[u],i))})),a}function deepmerge(s,i,a){(a=a||{}).arrayMerge=a.arrayMerge||defaultArrayMerge,a.isMergeableObject=a.isMergeableObject||o,a.cloneUnlessOtherwiseSpecified=cloneUnlessOtherwiseSpecified;var u=Array.isArray(i);return u===Array.isArray(s)?u?a.arrayMerge(s,i,a):mergeObject(s,i,a):cloneUnlessOtherwiseSpecified(i,a)}deepmerge.all=function deepmergeAll(s,o){if(!Array.isArray(s))throw new Error("first argument should be an array");return s.reduce((function(s,i){return deepmerge(s,i,o)}),{})};var a=deepmerge;s.exports=a},14792:(s,o,i)=>{var a=i(13222),u=i(55808);s.exports=function capitalize(s){return u(a(s).toLowerCase())}},14840:(s,o,i)=>{"use strict";var a=i(52623),u=i(74284).f,_=i(61626),w=i(49724),x=i(54878),C=i(76264)("toStringTag");s.exports=function(s,o,i,j){var L=i?s:s&&s.prototype;L&&(w(L,C)||u(L,C,{configurable:!0,value:o}),j&&!a&&_(L,"toString",x))}},14974:s=>{s.exports=function safeGet(s,o){if(("constructor"!==o||"function"!=typeof s[o])&&"__proto__"!=o)return s[o]}},15287:(s,o)=>{"use strict";var i=Symbol.for("react.element"),a=Symbol.for("react.portal"),u=Symbol.for("react.fragment"),_=Symbol.for("react.strict_mode"),w=Symbol.for("react.profiler"),x=Symbol.for("react.provider"),C=Symbol.for("react.context"),j=Symbol.for("react.forward_ref"),L=Symbol.for("react.suspense"),B=Symbol.for("react.memo"),$=Symbol.for("react.lazy"),V=Symbol.iterator;var U={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},z=Object.assign,Y={};function E(s,o,i){this.props=s,this.context=o,this.refs=Y,this.updater=i||U}function F(){}function G(s,o,i){this.props=s,this.context=o,this.refs=Y,this.updater=i||U}E.prototype.isReactComponent={},E.prototype.setState=function(s,o){if("object"!=typeof s&&"function"!=typeof s&&null!=s)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,s,o,"setState")},E.prototype.forceUpdate=function(s){this.updater.enqueueForceUpdate(this,s,"forceUpdate")},F.prototype=E.prototype;var Z=G.prototype=new F;Z.constructor=G,z(Z,E.prototype),Z.isPureReactComponent=!0;var ee=Array.isArray,ie=Object.prototype.hasOwnProperty,ae={current:null},ce={key:!0,ref:!0,__self:!0,__source:!0};function M(s,o,a){var u,_={},w=null,x=null;if(null!=o)for(u in void 0!==o.ref&&(x=o.ref),void 0!==o.key&&(w=""+o.key),o)ie.call(o,u)&&!ce.hasOwnProperty(u)&&(_[u]=o[u]);var C=arguments.length-2;if(1===C)_.children=a;else if(1{var a=i(96131);s.exports=function arrayIncludes(s,o){return!!(null==s?0:s.length)&&a(s,o,0)>-1}},15340:()=>{},15389:(s,o,i)=>{var a=i(93663),u=i(87978),_=i(83488),w=i(56449),x=i(50583);s.exports=function baseIteratee(s){return"function"==typeof s?s:null==s?_:"object"==typeof s?w(s)?u(s[0],s[1]):a(s):x(s)}},15972:(s,o,i)=>{"use strict";var a=i(49724),u=i(62250),_=i(39298),w=i(92522),x=i(57382),C=w("IE_PROTO"),j=Object,L=j.prototype;s.exports=x?j.getPrototypeOf:function(s){var o=_(s);if(a(o,C))return o[C];var i=o.constructor;return u(i)&&o instanceof i?i.prototype:o instanceof j?L:null}},16038:(s,o,i)=>{var a=i(5861),u=i(40346);s.exports=function baseIsSet(s){return u(s)&&"[object Set]"==a(s)}},16426:s=>{s.exports=function(){var s=document.getSelection();if(!s.rangeCount)return function(){};for(var o=document.activeElement,i=[],a=0;a{var a=i(43360),u=i(75288),_=Object.prototype.hasOwnProperty;s.exports=function assignValue(s,o,i){var w=s[o];_.call(s,o)&&u(w,i)&&(void 0!==i||o in s)||a(s,o,i)}},16708:(s,o,i)=>{"use strict";var a,u=i(65606);function CorkedRequest(s){var o=this;this.next=null,this.entry=null,this.finish=function(){!function onCorkedFinish(s,o,i){var a=s.entry;s.entry=null;for(;a;){var u=a.callback;o.pendingcb--,u(i),a=a.next}o.corkedRequestsFree.next=s}(o,s)}}s.exports=Writable,Writable.WritableState=WritableState;var _={deprecate:i(94643)},w=i(40345),x=i(48287).Buffer,C=(void 0!==i.g?i.g:"undefined"!=typeof window?window:"undefined"!=typeof self?self:{}).Uint8Array||function(){};var j,L=i(75896),B=i(65291).getHighWaterMark,$=i(86048).F,V=$.ERR_INVALID_ARG_TYPE,U=$.ERR_METHOD_NOT_IMPLEMENTED,z=$.ERR_MULTIPLE_CALLBACK,Y=$.ERR_STREAM_CANNOT_PIPE,Z=$.ERR_STREAM_DESTROYED,ee=$.ERR_STREAM_NULL_VALUES,ie=$.ERR_STREAM_WRITE_AFTER_END,ae=$.ERR_UNKNOWN_ENCODING,ce=L.errorOrDestroy;function nop(){}function WritableState(s,o,_){a=a||i(25382),s=s||{},"boolean"!=typeof _&&(_=o instanceof a),this.objectMode=!!s.objectMode,_&&(this.objectMode=this.objectMode||!!s.writableObjectMode),this.highWaterMark=B(this,s,"writableHighWaterMark",_),this.finalCalled=!1,this.needDrain=!1,this.ending=!1,this.ended=!1,this.finished=!1,this.destroyed=!1;var w=!1===s.decodeStrings;this.decodeStrings=!w,this.defaultEncoding=s.defaultEncoding||"utf8",this.length=0,this.writing=!1,this.corked=0,this.sync=!0,this.bufferProcessing=!1,this.onwrite=function(s){!function onwrite(s,o){var i=s._writableState,a=i.sync,_=i.writecb;if("function"!=typeof _)throw new z;if(function onwriteStateUpdate(s){s.writing=!1,s.writecb=null,s.length-=s.writelen,s.writelen=0}(i),o)!function onwriteError(s,o,i,a,_){--o.pendingcb,i?(u.nextTick(_,a),u.nextTick(finishMaybe,s,o),s._writableState.errorEmitted=!0,ce(s,a)):(_(a),s._writableState.errorEmitted=!0,ce(s,a),finishMaybe(s,o))}(s,i,a,o,_);else{var w=needFinish(i)||s.destroyed;w||i.corked||i.bufferProcessing||!i.bufferedRequest||clearBuffer(s,i),a?u.nextTick(afterWrite,s,i,w,_):afterWrite(s,i,w,_)}}(o,s)},this.writecb=null,this.writelen=0,this.bufferedRequest=null,this.lastBufferedRequest=null,this.pendingcb=0,this.prefinished=!1,this.errorEmitted=!1,this.emitClose=!1!==s.emitClose,this.autoDestroy=!!s.autoDestroy,this.bufferedRequestCount=0,this.corkedRequestsFree=new CorkedRequest(this)}function Writable(s){var o=this instanceof(a=a||i(25382));if(!o&&!j.call(Writable,this))return new Writable(s);this._writableState=new WritableState(s,this,o),this.writable=!0,s&&("function"==typeof s.write&&(this._write=s.write),"function"==typeof s.writev&&(this._writev=s.writev),"function"==typeof s.destroy&&(this._destroy=s.destroy),"function"==typeof s.final&&(this._final=s.final)),w.call(this)}function doWrite(s,o,i,a,u,_,w){o.writelen=a,o.writecb=w,o.writing=!0,o.sync=!0,o.destroyed?o.onwrite(new Z("write")):i?s._writev(u,o.onwrite):s._write(u,_,o.onwrite),o.sync=!1}function afterWrite(s,o,i,a){i||function onwriteDrain(s,o){0===o.length&&o.needDrain&&(o.needDrain=!1,s.emit("drain"))}(s,o),o.pendingcb--,a(),finishMaybe(s,o)}function clearBuffer(s,o){o.bufferProcessing=!0;var i=o.bufferedRequest;if(s._writev&&i&&i.next){var a=o.bufferedRequestCount,u=new Array(a),_=o.corkedRequestsFree;_.entry=i;for(var w=0,x=!0;i;)u[w]=i,i.isBuf||(x=!1),i=i.next,w+=1;u.allBuffers=x,doWrite(s,o,!0,o.length,u,"",_.finish),o.pendingcb++,o.lastBufferedRequest=null,_.next?(o.corkedRequestsFree=_.next,_.next=null):o.corkedRequestsFree=new CorkedRequest(o),o.bufferedRequestCount=0}else{for(;i;){var C=i.chunk,j=i.encoding,L=i.callback;if(doWrite(s,o,!1,o.objectMode?1:C.length,C,j,L),i=i.next,o.bufferedRequestCount--,o.writing)break}null===i&&(o.lastBufferedRequest=null)}o.bufferedRequest=i,o.bufferProcessing=!1}function needFinish(s){return s.ending&&0===s.length&&null===s.bufferedRequest&&!s.finished&&!s.writing}function callFinal(s,o){s._final((function(i){o.pendingcb--,i&&ce(s,i),o.prefinished=!0,s.emit("prefinish"),finishMaybe(s,o)}))}function finishMaybe(s,o){var i=needFinish(o);if(i&&(function prefinish(s,o){o.prefinished||o.finalCalled||("function"!=typeof s._final||o.destroyed?(o.prefinished=!0,s.emit("prefinish")):(o.pendingcb++,o.finalCalled=!0,u.nextTick(callFinal,s,o)))}(s,o),0===o.pendingcb&&(o.finished=!0,s.emit("finish"),o.autoDestroy))){var a=s._readableState;(!a||a.autoDestroy&&a.endEmitted)&&s.destroy()}return i}i(56698)(Writable,w),WritableState.prototype.getBuffer=function getBuffer(){for(var s=this.bufferedRequest,o=[];s;)o.push(s),s=s.next;return o},function(){try{Object.defineProperty(WritableState.prototype,"buffer",{get:_.deprecate((function writableStateBufferGetter(){return this.getBuffer()}),"_writableState.buffer is deprecated. Use _writableState.getBuffer instead.","DEP0003")})}catch(s){}}(),"function"==typeof Symbol&&Symbol.hasInstance&&"function"==typeof Function.prototype[Symbol.hasInstance]?(j=Function.prototype[Symbol.hasInstance],Object.defineProperty(Writable,Symbol.hasInstance,{value:function value(s){return!!j.call(this,s)||this===Writable&&(s&&s._writableState instanceof WritableState)}})):j=function realHasInstance(s){return s instanceof this},Writable.prototype.pipe=function(){ce(this,new Y)},Writable.prototype.write=function(s,o,i){var a=this._writableState,_=!1,w=!a.objectMode&&function _isUint8Array(s){return x.isBuffer(s)||s instanceof C}(s);return w&&!x.isBuffer(s)&&(s=function _uint8ArrayToBuffer(s){return x.from(s)}(s)),"function"==typeof o&&(i=o,o=null),w?o="buffer":o||(o=a.defaultEncoding),"function"!=typeof i&&(i=nop),a.ending?function writeAfterEnd(s,o){var i=new ie;ce(s,i),u.nextTick(o,i)}(this,i):(w||function validChunk(s,o,i,a){var _;return null===i?_=new ee:"string"==typeof i||o.objectMode||(_=new V("chunk",["string","Buffer"],i)),!_||(ce(s,_),u.nextTick(a,_),!1)}(this,a,s,i))&&(a.pendingcb++,_=function writeOrBuffer(s,o,i,a,u,_){if(!i){var w=function decodeChunk(s,o,i){s.objectMode||!1===s.decodeStrings||"string"!=typeof o||(o=x.from(o,i));return o}(o,a,u);a!==w&&(i=!0,u="buffer",a=w)}var C=o.objectMode?1:a.length;o.length+=C;var j=o.length-1))throw new ae(s);return this._writableState.defaultEncoding=s,this},Object.defineProperty(Writable.prototype,"writableBuffer",{enumerable:!1,get:function get(){return this._writableState&&this._writableState.getBuffer()}}),Object.defineProperty(Writable.prototype,"writableHighWaterMark",{enumerable:!1,get:function get(){return this._writableState.highWaterMark}}),Writable.prototype._write=function(s,o,i){i(new U("_write()"))},Writable.prototype._writev=null,Writable.prototype.end=function(s,o,i){var a=this._writableState;return"function"==typeof s?(i=s,s=null,o=null):"function"==typeof o&&(i=o,o=null),null!=s&&this.write(s,o),a.corked&&(a.corked=1,this.uncork()),a.ending||function endWritable(s,o,i){o.ending=!0,finishMaybe(s,o),i&&(o.finished?u.nextTick(i):s.once("finish",i));o.ended=!0,s.writable=!1}(this,a,i),this},Object.defineProperty(Writable.prototype,"writableLength",{enumerable:!1,get:function get(){return this._writableState.length}}),Object.defineProperty(Writable.prototype,"destroyed",{enumerable:!1,get:function get(){return void 0!==this._writableState&&this._writableState.destroyed},set:function set(s){this._writableState&&(this._writableState.destroyed=s)}}),Writable.prototype.destroy=L.destroy,Writable.prototype._undestroy=L.undestroy,Writable.prototype._destroy=function(s,o){o(s)}},16946:(s,o,i)=>{"use strict";var a=i(1907),u=i(98828),_=i(45807),w=Object,x=a("".split);s.exports=u((function(){return!w("z").propertyIsEnumerable(0)}))?function(s){return"String"===_(s)?x(s,""):w(s)}:w},16962:(s,o)=>{o.aliasToReal={each:"forEach",eachRight:"forEachRight",entries:"toPairs",entriesIn:"toPairsIn",extend:"assignIn",extendAll:"assignInAll",extendAllWith:"assignInAllWith",extendWith:"assignInWith",first:"head",conforms:"conformsTo",matches:"isMatch",property:"get",__:"placeholder",F:"stubFalse",T:"stubTrue",all:"every",allPass:"overEvery",always:"constant",any:"some",anyPass:"overSome",apply:"spread",assoc:"set",assocPath:"set",complement:"negate",compose:"flowRight",contains:"includes",dissoc:"unset",dissocPath:"unset",dropLast:"dropRight",dropLastWhile:"dropRightWhile",equals:"isEqual",identical:"eq",indexBy:"keyBy",init:"initial",invertObj:"invert",juxt:"over",omitAll:"omit",nAry:"ary",path:"get",pathEq:"matchesProperty",pathOr:"getOr",paths:"at",pickAll:"pick",pipe:"flow",pluck:"map",prop:"get",propEq:"matchesProperty",propOr:"getOr",props:"at",symmetricDifference:"xor",symmetricDifferenceBy:"xorBy",symmetricDifferenceWith:"xorWith",takeLast:"takeRight",takeLastWhile:"takeRightWhile",unapply:"rest",unnest:"flatten",useWith:"overArgs",where:"conformsTo",whereEq:"isMatch",zipObj:"zipObject"},o.aryMethod={1:["assignAll","assignInAll","attempt","castArray","ceil","create","curry","curryRight","defaultsAll","defaultsDeepAll","floor","flow","flowRight","fromPairs","invert","iteratee","memoize","method","mergeAll","methodOf","mixin","nthArg","over","overEvery","overSome","rest","reverse","round","runInContext","spread","template","trim","trimEnd","trimStart","uniqueId","words","zipAll"],2:["add","after","ary","assign","assignAllWith","assignIn","assignInAllWith","at","before","bind","bindAll","bindKey","chunk","cloneDeepWith","cloneWith","concat","conformsTo","countBy","curryN","curryRightN","debounce","defaults","defaultsDeep","defaultTo","delay","difference","divide","drop","dropRight","dropRightWhile","dropWhile","endsWith","eq","every","filter","find","findIndex","findKey","findLast","findLastIndex","findLastKey","flatMap","flatMapDeep","flattenDepth","forEach","forEachRight","forIn","forInRight","forOwn","forOwnRight","get","groupBy","gt","gte","has","hasIn","includes","indexOf","intersection","invertBy","invoke","invokeMap","isEqual","isMatch","join","keyBy","lastIndexOf","lt","lte","map","mapKeys","mapValues","matchesProperty","maxBy","meanBy","merge","mergeAllWith","minBy","multiply","nth","omit","omitBy","overArgs","pad","padEnd","padStart","parseInt","partial","partialRight","partition","pick","pickBy","propertyOf","pull","pullAll","pullAt","random","range","rangeRight","rearg","reject","remove","repeat","restFrom","result","sampleSize","some","sortBy","sortedIndex","sortedIndexOf","sortedLastIndex","sortedLastIndexOf","sortedUniqBy","split","spreadFrom","startsWith","subtract","sumBy","take","takeRight","takeRightWhile","takeWhile","tap","throttle","thru","times","trimChars","trimCharsEnd","trimCharsStart","truncate","union","uniqBy","uniqWith","unset","unzipWith","without","wrap","xor","zip","zipObject","zipObjectDeep"],3:["assignInWith","assignWith","clamp","differenceBy","differenceWith","findFrom","findIndexFrom","findLastFrom","findLastIndexFrom","getOr","includesFrom","indexOfFrom","inRange","intersectionBy","intersectionWith","invokeArgs","invokeArgsMap","isEqualWith","isMatchWith","flatMapDepth","lastIndexOfFrom","mergeWith","orderBy","padChars","padCharsEnd","padCharsStart","pullAllBy","pullAllWith","rangeStep","rangeStepRight","reduce","reduceRight","replace","set","slice","sortedIndexBy","sortedLastIndexBy","transform","unionBy","unionWith","update","xorBy","xorWith","zipWith"],4:["fill","setWith","updateWith"]},o.aryRearg={2:[1,0],3:[2,0,1],4:[3,2,0,1]},o.iterateeAry={dropRightWhile:1,dropWhile:1,every:1,filter:1,find:1,findFrom:1,findIndex:1,findIndexFrom:1,findKey:1,findLast:1,findLastFrom:1,findLastIndex:1,findLastIndexFrom:1,findLastKey:1,flatMap:1,flatMapDeep:1,flatMapDepth:1,forEach:1,forEachRight:1,forIn:1,forInRight:1,forOwn:1,forOwnRight:1,map:1,mapKeys:1,mapValues:1,partition:1,reduce:2,reduceRight:2,reject:1,remove:1,some:1,takeRightWhile:1,takeWhile:1,times:1,transform:2},o.iterateeRearg={mapKeys:[1],reduceRight:[1,0]},o.methodRearg={assignInAllWith:[1,0],assignInWith:[1,2,0],assignAllWith:[1,0],assignWith:[1,2,0],differenceBy:[1,2,0],differenceWith:[1,2,0],getOr:[2,1,0],intersectionBy:[1,2,0],intersectionWith:[1,2,0],isEqualWith:[1,2,0],isMatchWith:[2,1,0],mergeAllWith:[1,0],mergeWith:[1,2,0],padChars:[2,1,0],padCharsEnd:[2,1,0],padCharsStart:[2,1,0],pullAllBy:[2,1,0],pullAllWith:[2,1,0],rangeStep:[1,2,0],rangeStepRight:[1,2,0],setWith:[3,1,2,0],sortedIndexBy:[2,1,0],sortedLastIndexBy:[2,1,0],unionBy:[1,2,0],unionWith:[1,2,0],updateWith:[3,1,2,0],xorBy:[1,2,0],xorWith:[1,2,0],zipWith:[1,2,0]},o.methodSpread={assignAll:{start:0},assignAllWith:{start:0},assignInAll:{start:0},assignInAllWith:{start:0},defaultsAll:{start:0},defaultsDeepAll:{start:0},invokeArgs:{start:2},invokeArgsMap:{start:2},mergeAll:{start:0},mergeAllWith:{start:0},partial:{start:1},partialRight:{start:1},without:{start:1},zipAll:{start:0}},o.mutate={array:{fill:!0,pull:!0,pullAll:!0,pullAllBy:!0,pullAllWith:!0,pullAt:!0,remove:!0,reverse:!0},object:{assign:!0,assignAll:!0,assignAllWith:!0,assignIn:!0,assignInAll:!0,assignInAllWith:!0,assignInWith:!0,assignWith:!0,defaults:!0,defaultsAll:!0,defaultsDeep:!0,defaultsDeepAll:!0,merge:!0,mergeAll:!0,mergeAllWith:!0,mergeWith:!0},set:{set:!0,setWith:!0,unset:!0,update:!0,updateWith:!0}},o.realToAlias=function(){var s=Object.prototype.hasOwnProperty,i=o.aliasToReal,a={};for(var u in i){var _=i[u];s.call(a,_)?a[_].push(u):a[_]=[u]}return a}(),o.remap={assignAll:"assign",assignAllWith:"assignWith",assignInAll:"assignIn",assignInAllWith:"assignInWith",curryN:"curry",curryRightN:"curryRight",defaultsAll:"defaults",defaultsDeepAll:"defaultsDeep",findFrom:"find",findIndexFrom:"findIndex",findLastFrom:"findLast",findLastIndexFrom:"findLastIndex",getOr:"get",includesFrom:"includes",indexOfFrom:"indexOf",invokeArgs:"invoke",invokeArgsMap:"invokeMap",lastIndexOfFrom:"lastIndexOf",mergeAll:"merge",mergeAllWith:"mergeWith",padChars:"pad",padCharsEnd:"padEnd",padCharsStart:"padStart",propertyOf:"get",rangeStep:"range",rangeStepRight:"rangeRight",restFrom:"rest",spreadFrom:"spread",trimChars:"trim",trimCharsEnd:"trimEnd",trimCharsStart:"trimStart",zipAll:"zip"},o.skipFixed={castArray:!0,flow:!0,flowRight:!0,iteratee:!0,mixin:!0,rearg:!0,runInContext:!0},o.skipRearg={add:!0,assign:!0,assignIn:!0,bind:!0,bindKey:!0,concat:!0,difference:!0,divide:!0,eq:!0,gt:!0,gte:!0,isEqual:!0,lt:!0,lte:!0,matchesProperty:!0,merge:!0,multiply:!0,overArgs:!0,partial:!0,partialRight:!0,propertyOf:!0,random:!0,range:!0,rangeRight:!0,subtract:!0,zip:!0,zipObject:!0,zipObjectDeep:!0}},17255:(s,o,i)=>{var a=i(47422);s.exports=function basePropertyDeep(s){return function(o){return a(o,s)}}},17285:s=>{function source(s){return s?"string"==typeof s?s:s.source:null}function lookahead(s){return concat("(?=",s,")")}function concat(...s){return s.map((s=>source(s))).join("")}function either(...s){return"("+s.map((s=>source(s))).join("|")+")"}s.exports=function xml(s){const o=concat(/[A-Z_]/,function optional(s){return concat("(",s,")?")}(/[A-Z0-9_.-]*:/),/[A-Z0-9_.-]*/),i={className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},a={begin:/\s/,contains:[{className:"meta-keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}]},u=s.inherit(a,{begin:/\(/,end:/\)/}),_=s.inherit(s.APOS_STRING_MODE,{className:"meta-string"}),w=s.inherit(s.QUOTE_STRING_MODE,{className:"meta-string"}),x={endsWithParent:!0,illegal:/`]+/}]}]}]};return{name:"HTML, XML",aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"],case_insensitive:!0,contains:[{className:"meta",begin://,relevance:10,contains:[a,w,_,u,{begin:/\[/,end:/\]/,contains:[{className:"meta",begin://,contains:[a,u,w,_]}]}]},s.COMMENT(//,{relevance:10}),{begin://,relevance:10},i,{className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag",begin:/)/,end:/>/,keywords:{name:"style"},contains:[x],starts:{end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag",begin:/)/,end:/>/,keywords:{name:"script"},contains:[x],starts:{end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{className:"tag",begin:/<>|<\/>/},{className:"tag",begin:concat(//,/>/,/\s/)))),end:/\/?>/,contains:[{className:"name",begin:o,relevance:0,starts:x}]},{className:"tag",begin:concat(/<\//,lookahead(concat(o,/>/))),contains:[{className:"name",begin:o,relevance:0},{begin:/>/,relevance:0,endsParent:!0}]}]}}},17400:(s,o,i)=>{var a=i(99374),u=1/0;s.exports=function toFinite(s){return s?(s=a(s))===u||s===-1/0?17976931348623157e292*(s<0?-1:1):s==s?s:0:0===s?s:0}},17533:s=>{s.exports=function yaml(s){var o="true false yes no null",i="[\\w#;/?:@&=+$,.~*'()[\\]]+",a={className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/\S+/}],contains:[s.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},u=s.inherit(a,{variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),_={className:"number",begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b"},w={end:",",endsWithParent:!0,excludeEnd:!0,keywords:o,relevance:0},x={begin:/\{/,end:/\}/,contains:[w],illegal:"\\n",relevance:0},C={begin:"\\[",end:"\\]",contains:[w],illegal:"\\n",relevance:0},j=[{className:"attr",variants:[{begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$",relevance:10},{className:"string",begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:"!\\w+!"+i},{className:"type",begin:"!<"+i+">"},{className:"type",begin:"!"+i},{className:"type",begin:"!!"+i},{className:"meta",begin:"&"+s.UNDERSCORE_IDENT_RE+"$"},{className:"meta",begin:"\\*"+s.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)",relevance:0},s.HASH_COMMENT_MODE,{beginKeywords:o,keywords:{literal:o}},_,{className:"number",begin:s.C_NUMBER_RE+"\\b",relevance:0},x,C,a],L=[...j];return L.pop(),L.push(u),w.contains=L,{name:"YAML",case_insensitive:!0,aliases:["yml"],contains:j}}},17670:(s,o,i)=>{var a=i(12651);s.exports=function mapCacheDelete(s){var o=a(this,s).delete(s);return this.size-=o?1:0,o}},17965:(s,o,i)=>{"use strict";var a=i(16426),u={"text/plain":"Text","text/html":"Url",default:"Text"};s.exports=function copy(s,o){var i,_,w,x,C,j,L=!1;o||(o={}),i=o.debug||!1;try{if(w=a(),x=document.createRange(),C=document.getSelection(),(j=document.createElement("span")).textContent=s,j.ariaHidden="true",j.style.all="unset",j.style.position="fixed",j.style.top=0,j.style.clip="rect(0, 0, 0, 0)",j.style.whiteSpace="pre",j.style.webkitUserSelect="text",j.style.MozUserSelect="text",j.style.msUserSelect="text",j.style.userSelect="text",j.addEventListener("copy",(function(a){if(a.stopPropagation(),o.format)if(a.preventDefault(),void 0===a.clipboardData){i&&console.warn("unable to use e.clipboardData"),i&&console.warn("trying IE specific stuff"),window.clipboardData.clearData();var _=u[o.format]||u.default;window.clipboardData.setData(_,s)}else a.clipboardData.clearData(),a.clipboardData.setData(o.format,s);o.onCopy&&(a.preventDefault(),o.onCopy(a.clipboardData))})),document.body.appendChild(j),x.selectNodeContents(j),C.addRange(x),!document.execCommand("copy"))throw new Error("copy command was unsuccessful");L=!0}catch(a){i&&console.error("unable to copy using execCommand: ",a),i&&console.warn("trying IE specific stuff");try{window.clipboardData.setData(o.format||"text",s),o.onCopy&&o.onCopy(window.clipboardData),L=!0}catch(a){i&&console.error("unable to copy using clipboardData: ",a),i&&console.error("falling back to prompt"),_=function format(s){var o=(/mac os x/i.test(navigator.userAgent)?"⌘":"Ctrl")+"+C";return s.replace(/#{\s*key\s*}/g,o)}("message"in o?o.message:"Copy to clipboard: #{key}, Enter"),window.prompt(_,s)}}finally{C&&("function"==typeof C.removeRange?C.removeRange(x):C.removeAllRanges()),j&&document.body.removeChild(j),w()}return L}},18073:(s,o,i)=>{var a=i(85087),u=i(54641),_=i(70981);s.exports=function createRecurry(s,o,i,w,x,C,j,L,B,$){var V=8&o;o|=V?32:64,4&(o&=~(V?64:32))||(o&=-4);var U=[s,o,x,V?C:void 0,V?j:void 0,V?void 0:C,V?void 0:j,L,B,$],z=i.apply(void 0,U);return a(s)&&u(z,U),z.placeholder=w,_(z,s,o)}},19123:(s,o,i)=>{var a=i(65606),u=i(31499),_=i(88310).Stream;function resolve(s,o,i){var a,_=function create_indent(s,o){return new Array(o||0).join(s||"")}(o,i=i||0),w=s;if("object"==typeof s&&((w=s[a=Object.keys(s)[0]])&&w._elem))return w._elem.name=a,w._elem.icount=i,w._elem.indent=o,w._elem.indents=_,w._elem.interrupt=w,w._elem;var x,C=[],j=[];function get_attributes(s){Object.keys(s).forEach((function(o){C.push(function attribute(s,o){return s+'="'+u(o)+'"'}(o,s[o]))}))}switch(typeof w){case"object":if(null===w)break;w._attr&&get_attributes(w._attr),w._cdata&&j.push(("/g,"]]]]>")+"]]>"),w.forEach&&(x=!1,j.push(""),w.forEach((function(s){"object"==typeof s?"_attr"==Object.keys(s)[0]?get_attributes(s._attr):j.push(resolve(s,o,i+1)):(j.pop(),x=!0,j.push(u(s)))})),x||j.push(""));break;default:j.push(u(w))}return{name:a,interrupt:!1,attributes:C,content:j,icount:i,indents:_,indent:o}}function format(s,o,i){if("object"!=typeof o)return s(!1,o);var a=o.interrupt?1:o.content.length;function proceed(){for(;o.content.length;){var u=o.content.shift();if(void 0!==u){if(interrupt(u))return;format(s,u)}}s(!1,(a>1?o.indents:"")+(o.name?"":"")+(o.indent&&!i?"\n":"")),i&&i()}function interrupt(o){return!!o.interrupt&&(o.interrupt.append=s,o.interrupt.end=proceed,o.interrupt=!1,s(!0),!0)}if(s(!1,o.indents+(o.name?"<"+o.name:"")+(o.attributes.length?" "+o.attributes.join(" "):"")+(a?o.name?">":"":o.name?"/>":"")+(o.indent&&a>1?"\n":"")),!a)return s(!1,o.indent?"\n":"");interrupt(o)||proceed()}s.exports=function xml(s,o){"object"!=typeof o&&(o={indent:o});var i=o.stream?new _:null,u="",w=!1,x=o.indent?!0===o.indent?" ":o.indent:"",C=!0;function delay(s){C?a.nextTick(s):s()}function append(s,o){if(void 0!==o&&(u+=o),s&&!w&&(i=i||new _,w=!0),s&&w){var a=u;delay((function(){i.emit("data",a)})),u=""}}function add(s,o){format(append,resolve(s,x,x?1:0),o)}function end(){if(i){var s=u;delay((function(){i.emit("data",s),i.emit("end"),i.readable=!1,i.emit("close")}))}}return delay((function(){C=!1})),o.declaration&&function addXmlDeclaration(s){var o={version:"1.0",encoding:s.encoding||"UTF-8"};s.standalone&&(o.standalone=s.standalone),add({"?xml":{_attr:o}}),u=u.replace("/>","?>")}(o.declaration),s&&s.forEach?s.forEach((function(o,i){var a;i+1===s.length&&(a=end),add(o,a)})):add(s,end),i?(i.readable=!0,i):u},s.exports.element=s.exports.Element=function element(){var s={_elem:resolve(Array.prototype.slice.call(arguments)),push:function(s){if(!this.append)throw new Error("not assigned to a parent!");var o=this,i=this._elem.indent;format(this.append,resolve(s,i,this._elem.icount+(i?1:0)),(function(){o.append(!0)}))},close:function(s){void 0!==s&&this.push(s),this.end&&this.end()}};return s}},19219:s=>{s.exports=function cacheHas(s,o){return s.has(o)}},19287:s=>{"use strict";s.exports={CSSRuleList:0,CSSStyleDeclaration:0,CSSValueList:0,ClientRectList:0,DOMRectList:0,DOMStringList:0,DOMTokenList:1,DataTransferItemList:0,FileList:0,HTMLAllCollection:0,HTMLCollection:0,HTMLFormElement:0,HTMLSelectElement:0,MediaList:0,MimeTypeArray:0,NamedNodeMap:0,NodeList:1,PaintRequestList:0,Plugin:0,PluginArray:0,SVGLengthList:0,SVGNumberList:0,SVGPathSegList:0,SVGPointList:0,SVGStringList:0,SVGTransformList:0,SourceBufferList:0,StyleSheetList:0,TextTrackCueList:0,TextTrackList:0,TouchList:0}},19358:(s,o,i)=>{"use strict";var a=i(85582),u=i(49724),_=i(61626),w=i(88280),x=i(79192),C=i(19595),j=i(54829),L=i(34084),B=i(32096),$=i(39259),V=i(85884),U=i(39447),z=i(7376);s.exports=function(s,o,i,Y){var Z="stackTraceLimit",ee=Y?2:1,ie=s.split("."),ae=ie[ie.length-1],ce=a.apply(null,ie);if(ce){var le=ce.prototype;if(!z&&u(le,"cause")&&delete le.cause,!i)return ce;var pe=a("Error"),de=o((function(s,o){var i=B(Y?o:s,void 0),a=Y?new ce(s):new ce;return void 0!==i&&_(a,"message",i),V(a,de,a.stack,2),this&&w(le,this)&&L(a,this,de),arguments.length>ee&&$(a,arguments[ee]),a}));if(de.prototype=le,"Error"!==ae?x?x(de,pe):C(de,pe,{name:!0}):U&&Z in ce&&(j(de,ce,Z),j(de,ce,"prepareStackTrace")),C(de,ce),!z)try{le.name!==ae&&_(le,"name",ae),le.constructor=de}catch(s){}return de}}},19570:(s,o,i)=>{var a=i(37334),u=i(93243),_=i(83488),w=u?function(s,o){return u(s,"toString",{configurable:!0,enumerable:!1,value:a(o),writable:!0})}:_;s.exports=w},19595:(s,o,i)=>{"use strict";var a=i(49724),u=i(11042),_=i(13846),w=i(74284);s.exports=function(s,o,i){for(var x=u(o),C=w.f,j=_.f,L=0;L{"use strict";var a=i(23034);s.exports=a},19846:(s,o,i)=>{"use strict";var a=i(20798),u=i(98828),_=i(45951).String;s.exports=!!Object.getOwnPropertySymbols&&!u((function(){var s=Symbol("symbol detection");return!_(s)||!(Object(s)instanceof Symbol)||!Symbol.sham&&a&&a<41}))},19931:(s,o,i)=>{var a=i(31769),u=i(68090),_=i(68969),w=i(77797);s.exports=function baseUnset(s,o){return o=a(o,s),null==(s=_(s,o))||delete s[w(u(o))]}},20181:(s,o,i)=>{var a=/^\s+|\s+$/g,u=/^[-+]0x[0-9a-f]+$/i,_=/^0b[01]+$/i,w=/^0o[0-7]+$/i,x=parseInt,C="object"==typeof i.g&&i.g&&i.g.Object===Object&&i.g,j="object"==typeof self&&self&&self.Object===Object&&self,L=C||j||Function("return this")(),B=Object.prototype.toString,$=Math.max,V=Math.min,now=function(){return L.Date.now()};function isObject(s){var o=typeof s;return!!s&&("object"==o||"function"==o)}function toNumber(s){if("number"==typeof s)return s;if(function isSymbol(s){return"symbol"==typeof s||function isObjectLike(s){return!!s&&"object"==typeof s}(s)&&"[object Symbol]"==B.call(s)}(s))return NaN;if(isObject(s)){var o="function"==typeof s.valueOf?s.valueOf():s;s=isObject(o)?o+"":o}if("string"!=typeof s)return 0===s?s:+s;s=s.replace(a,"");var i=_.test(s);return i||w.test(s)?x(s.slice(2),i?2:8):u.test(s)?NaN:+s}s.exports=function debounce(s,o,i){var a,u,_,w,x,C,j=0,L=!1,B=!1,U=!0;if("function"!=typeof s)throw new TypeError("Expected a function");function invokeFunc(o){var i=a,_=u;return a=u=void 0,j=o,w=s.apply(_,i)}function shouldInvoke(s){var i=s-C;return void 0===C||i>=o||i<0||B&&s-j>=_}function timerExpired(){var s=now();if(shouldInvoke(s))return trailingEdge(s);x=setTimeout(timerExpired,function remainingWait(s){var i=o-(s-C);return B?V(i,_-(s-j)):i}(s))}function trailingEdge(s){return x=void 0,U&&a?invokeFunc(s):(a=u=void 0,w)}function debounced(){var s=now(),i=shouldInvoke(s);if(a=arguments,u=this,C=s,i){if(void 0===x)return function leadingEdge(s){return j=s,x=setTimeout(timerExpired,o),L?invokeFunc(s):w}(C);if(B)return x=setTimeout(timerExpired,o),invokeFunc(C)}return void 0===x&&(x=setTimeout(timerExpired,o)),w}return o=toNumber(o)||0,isObject(i)&&(L=!!i.leading,_=(B="maxWait"in i)?$(toNumber(i.maxWait)||0,o):_,U="trailing"in i?!!i.trailing:U),debounced.cancel=function cancel(){void 0!==x&&clearTimeout(x),j=0,a=C=u=x=void 0},debounced.flush=function flush(){return void 0===x?w:trailingEdge(now())},debounced}},20317:s=>{s.exports=function mapToArray(s){var o=-1,i=Array(s.size);return s.forEach((function(s,a){i[++o]=[a,s]})),i}},20334:(s,o,i)=>{"use strict";var a=i(48287).Buffer;class NonError extends Error{constructor(s){super(NonError._prepareSuperMessage(s)),Object.defineProperty(this,"name",{value:"NonError",configurable:!0,writable:!0}),Error.captureStackTrace&&Error.captureStackTrace(this,NonError)}static _prepareSuperMessage(s){try{return JSON.stringify(s)}catch{return String(s)}}}const u=[{property:"name",enumerable:!1},{property:"message",enumerable:!1},{property:"stack",enumerable:!1},{property:"code",enumerable:!0}],_=Symbol(".toJSON called"),destroyCircular=({from:s,seen:o,to_:i,forceEnumerable:w,maxDepth:x,depth:C})=>{const j=i||(Array.isArray(s)?[]:{});if(o.push(s),C>=x)return j;if("function"==typeof s.toJSON&&!0!==s[_])return(s=>{s[_]=!0;const o=s.toJSON();return delete s[_],o})(s);for(const[i,u]of Object.entries(s))"function"==typeof a&&a.isBuffer(u)?j[i]="[object Buffer]":"function"!=typeof u&&(u&&"object"==typeof u?o.includes(s[i])?j[i]="[Circular]":(C++,j[i]=destroyCircular({from:s[i],seen:o.slice(),forceEnumerable:w,maxDepth:x,depth:C})):j[i]=u);for(const{property:o,enumerable:i}of u)"string"==typeof s[o]&&Object.defineProperty(j,o,{value:s[o],enumerable:!!w||i,configurable:!0,writable:!0});return j};s.exports={serializeError:(s,o={})=>{const{maxDepth:i=Number.POSITIVE_INFINITY}=o;return"object"==typeof s&&null!==s?destroyCircular({from:s,seen:[],forceEnumerable:!0,maxDepth:i,depth:0}):"function"==typeof s?`[Function: ${s.name||"anonymous"}]`:s},deserializeError:(s,o={})=>{const{maxDepth:i=Number.POSITIVE_INFINITY}=o;if(s instanceof Error)return s;if("object"==typeof s&&null!==s&&!Array.isArray(s)){const o=new Error;return destroyCircular({from:s,seen:[],to_:o,maxDepth:i,depth:0}),o}return new NonError(s)}}},20426:s=>{var o=Object.prototype.hasOwnProperty;s.exports=function baseHas(s,i){return null!=s&&o.call(s,i)}},20575:(s,o,i)=>{"use strict";var a=i(3121);s.exports=function(s){return a(s.length)}},20798:(s,o,i)=>{"use strict";var a,u,_=i(45951),w=i(96794),x=_.process,C=_.Deno,j=x&&x.versions||C&&C.version,L=j&&j.v8;L&&(u=(a=L.split("."))[0]>0&&a[0]<4?1:+(a[0]+a[1])),!u&&w&&(!(a=w.match(/Edge\/(\d+)/))||a[1]>=74)&&(a=w.match(/Chrome\/(\d+)/))&&(u=+a[1]),s.exports=u},20850:(s,o,i)=>{"use strict";s.exports=i(46076)},20999:(s,o,i)=>{var a=i(69302),u=i(36800);s.exports=function createAssigner(s){return a((function(o,i){var a=-1,_=i.length,w=_>1?i[_-1]:void 0,x=_>2?i[2]:void 0;for(w=s.length>3&&"function"==typeof w?(_--,w):void 0,x&&u(i[0],i[1],x)&&(w=_<3?void 0:w,_=1),o=Object(o);++a<_;){var C=i[a];C&&s(o,C,a,w)}return o}))}},21549:(s,o,i)=>{var a=i(22032),u=i(63862),_=i(66721),w=i(12749),x=i(35749);function Hash(s){var o=-1,i=null==s?0:s.length;for(this.clear();++o{var a=i(16547),u=i(43360);s.exports=function copyObject(s,o,i,_){var w=!i;i||(i={});for(var x=-1,C=o.length;++x{var a=i(51873),u=i(37828),_=i(75288),w=i(25911),x=i(20317),C=i(84247),j=a?a.prototype:void 0,L=j?j.valueOf:void 0;s.exports=function equalByTag(s,o,i,a,j,B,$){switch(i){case"[object DataView]":if(s.byteLength!=o.byteLength||s.byteOffset!=o.byteOffset)return!1;s=s.buffer,o=o.buffer;case"[object ArrayBuffer]":return!(s.byteLength!=o.byteLength||!B(new u(s),new u(o)));case"[object Boolean]":case"[object Date]":case"[object Number]":return _(+s,+o);case"[object Error]":return s.name==o.name&&s.message==o.message;case"[object RegExp]":case"[object String]":return s==o+"";case"[object Map]":var V=x;case"[object Set]":var U=1&a;if(V||(V=C),s.size!=o.size&&!U)return!1;var z=$.get(s);if(z)return z==o;a|=2,$.set(s,o);var Y=w(V(s),V(o),a,j,B,$);return $.delete(s),Y;case"[object Symbol]":if(L)return L.call(s)==L.call(o)}return!1}},22032:(s,o,i)=>{var a=i(81042);s.exports=function hashClear(){this.__data__=a?a(null):{},this.size=0}},22225:s=>{var o="\\ud800-\\udfff",i="\\u2700-\\u27bf",a="a-z\\xdf-\\xf6\\xf8-\\xff",u="A-Z\\xc0-\\xd6\\xd8-\\xde",_="\\xac\\xb1\\xd7\\xf7\\x00-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\xbf\\u2000-\\u206f \\t\\x0b\\f\\xa0\\ufeff\\n\\r\\u2028\\u2029\\u1680\\u180e\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200a\\u202f\\u205f\\u3000",w="["+_+"]",x="\\d+",C="["+i+"]",j="["+a+"]",L="[^"+o+_+x+i+a+u+"]",B="(?:\\ud83c[\\udde6-\\uddff]){2}",$="[\\ud800-\\udbff][\\udc00-\\udfff]",V="["+u+"]",U="(?:"+j+"|"+L+")",z="(?:"+V+"|"+L+")",Y="(?:['’](?:d|ll|m|re|s|t|ve))?",Z="(?:['’](?:D|LL|M|RE|S|T|VE))?",ee="(?:[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff]|\\ud83c[\\udffb-\\udfff])?",ie="[\\ufe0e\\ufe0f]?",ae=ie+ee+("(?:\\u200d(?:"+["[^"+o+"]",B,$].join("|")+")"+ie+ee+")*"),ce="(?:"+[C,B,$].join("|")+")"+ae,le=RegExp([V+"?"+j+"+"+Y+"(?="+[w,V,"$"].join("|")+")",z+"+"+Z+"(?="+[w,V+U,"$"].join("|")+")",V+"?"+U+"+"+Y,V+"+"+Z,"\\d*(?:1ST|2ND|3RD|(?![123])\\dTH)(?=\\b|[a-z_])","\\d*(?:1st|2nd|3rd|(?![123])\\dth)(?=\\b|[A-Z_])",x,ce].join("|"),"g");s.exports=function unicodeWords(s){return s.match(le)||[]}},22551:(s,o,i)=>{"use strict";var a=i(96540),u=i(69982);function p(s){for(var o="https://reactjs.org/docs/error-decoder.html?invariant="+s,i=1;i