Repository: moghtech/komodo Branch: main Commit: 34a9f8eb9ef3 Files: 673 Total size: 4.3 MB Directory structure: gitextract_7y0a1oom/ ├── .cargo/ │ └── config.toml ├── .devcontainer/ │ ├── dev.compose.yaml │ ├── devcontainer.json │ └── postCreate.sh ├── .github/ │ └── FUNDING.yml ├── .gitignore ├── .kminclude ├── .vscode/ │ ├── extensions.json │ ├── resolver.code-snippets │ └── tasks.json ├── Cargo.toml ├── LICENSE ├── bin/ │ ├── binaries.Dockerfile │ ├── chef.binaries.Dockerfile │ ├── cli/ │ │ ├── Cargo.toml │ │ ├── README.md │ │ ├── aio.Dockerfile │ │ ├── docs/ │ │ │ └── copy-database.md │ │ ├── multi-arch.Dockerfile │ │ ├── runfile.toml │ │ ├── single-arch.Dockerfile │ │ └── src/ │ │ ├── command/ │ │ │ ├── container.rs │ │ │ ├── database.rs │ │ │ ├── execute.rs │ │ │ ├── list.rs │ │ │ ├── mod.rs │ │ │ └── update/ │ │ │ ├── mod.rs │ │ │ ├── resource.rs │ │ │ ├── user.rs │ │ │ └── variable.rs │ │ ├── config.rs │ │ └── main.rs │ ├── core/ │ │ ├── Cargo.toml │ │ ├── aio.Dockerfile │ │ ├── debian-deps.sh │ │ ├── multi-arch.Dockerfile │ │ ├── single-arch.Dockerfile │ │ ├── src/ │ │ │ ├── alert/ │ │ │ │ ├── discord.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── ntfy.rs │ │ │ │ ├── pushover.rs │ │ │ │ └── slack.rs │ │ │ ├── api/ │ │ │ │ ├── auth.rs │ │ │ │ ├── execute/ │ │ │ │ │ ├── action.rs │ │ │ │ │ ├── alerter.rs │ │ │ │ │ ├── build.rs │ │ │ │ │ ├── deployment.rs │ │ │ │ │ ├── maintenance.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── procedure.rs │ │ │ │ │ ├── repo.rs │ │ │ │ │ ├── server.rs │ │ │ │ │ ├── stack.rs │ │ │ │ │ └── sync.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── read/ │ │ │ │ │ ├── action.rs │ │ │ │ │ ├── alert.rs │ │ │ │ │ ├── alerter.rs │ │ │ │ │ ├── build.rs │ │ │ │ │ ├── builder.rs │ │ │ │ │ ├── deployment.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── permission.rs │ │ │ │ │ ├── procedure.rs │ │ │ │ │ ├── provider.rs │ │ │ │ │ ├── repo.rs │ │ │ │ │ ├── schedule.rs │ │ │ │ │ ├── server.rs │ │ │ │ │ ├── stack.rs │ │ │ │ │ ├── sync.rs │ │ │ │ │ ├── tag.rs │ │ │ │ │ ├── toml.rs │ │ │ │ │ ├── update.rs │ │ │ │ │ ├── user.rs │ │ │ │ │ ├── user_group.rs │ │ │ │ │ └── variable.rs │ │ │ │ ├── terminal.rs │ │ │ │ ├── user.rs │ │ │ │ └── write/ │ │ │ │ ├── action.rs │ │ │ │ ├── alerter.rs │ │ │ │ ├── build.rs │ │ │ │ ├── builder.rs │ │ │ │ ├── deployment.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── permissions.rs │ │ │ │ ├── procedure.rs │ │ │ │ ├── provider.rs │ │ │ │ ├── repo.rs │ │ │ │ ├── resource.rs │ │ │ │ ├── server.rs │ │ │ │ ├── service_user.rs │ │ │ │ ├── stack.rs │ │ │ │ ├── sync.rs │ │ │ │ ├── tag.rs │ │ │ │ ├── user.rs │ │ │ │ ├── user_group.rs │ │ │ │ └── variable.rs │ │ │ ├── auth/ │ │ │ │ ├── github/ │ │ │ │ │ ├── client.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── google/ │ │ │ │ │ ├── client.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── jwt.rs │ │ │ │ ├── local.rs │ │ │ │ ├── mod.rs │ │ │ │ └── oidc/ │ │ │ │ ├── client.rs │ │ │ │ └── mod.rs │ │ │ ├── cloud/ │ │ │ │ ├── aws/ │ │ │ │ │ ├── ec2.rs │ │ │ │ │ └── mod.rs │ │ │ │ └── mod.rs │ │ │ ├── config.rs │ │ │ ├── helpers/ │ │ │ │ ├── action_state.rs │ │ │ │ ├── all_resources.rs │ │ │ │ ├── builder.rs │ │ │ │ ├── cache.rs │ │ │ │ ├── channel.rs │ │ │ │ ├── maintenance.rs │ │ │ │ ├── matcher.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── procedure.rs │ │ │ │ ├── prune.rs │ │ │ │ ├── query.rs │ │ │ │ └── update.rs │ │ │ ├── listener/ │ │ │ │ ├── integrations/ │ │ │ │ │ ├── github.rs │ │ │ │ │ ├── gitlab.rs │ │ │ │ │ └── mod.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── resources.rs │ │ │ │ └── router.rs │ │ │ ├── main.rs │ │ │ ├── monitor/ │ │ │ │ ├── alert/ │ │ │ │ │ ├── deployment.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── server.rs │ │ │ │ │ └── stack.rs │ │ │ │ ├── helpers.rs │ │ │ │ ├── lists.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── record.rs │ │ │ │ └── resources.rs │ │ │ ├── network.rs │ │ │ ├── permission.rs │ │ │ ├── resource/ │ │ │ │ ├── action.rs │ │ │ │ ├── alerter.rs │ │ │ │ ├── build.rs │ │ │ │ ├── builder.rs │ │ │ │ ├── deployment.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── procedure.rs │ │ │ │ ├── refresh.rs │ │ │ │ ├── repo.rs │ │ │ │ ├── server.rs │ │ │ │ ├── stack.rs │ │ │ │ └── sync.rs │ │ │ ├── schedule.rs │ │ │ ├── stack/ │ │ │ │ ├── execute.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── remote.rs │ │ │ │ └── services.rs │ │ │ ├── startup.rs │ │ │ ├── state.rs │ │ │ ├── sync/ │ │ │ │ ├── deploy.rs │ │ │ │ ├── execute.rs │ │ │ │ ├── file.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── remote.rs │ │ │ │ ├── resources.rs │ │ │ │ ├── toml.rs │ │ │ │ ├── user_groups.rs │ │ │ │ ├── variables.rs │ │ │ │ └── view.rs │ │ │ ├── ts_client.rs │ │ │ └── ws/ │ │ │ ├── container.rs │ │ │ ├── deployment.rs │ │ │ ├── mod.rs │ │ │ ├── stack.rs │ │ │ ├── terminal.rs │ │ │ └── update.rs │ │ └── starship.toml │ └── periphery/ │ ├── Cargo.toml │ ├── aio.Dockerfile │ ├── debian-deps.sh │ ├── multi-arch.Dockerfile │ ├── single-arch.Dockerfile │ ├── src/ │ │ ├── api/ │ │ │ ├── build.rs │ │ │ ├── compose.rs │ │ │ ├── container.rs │ │ │ ├── deploy.rs │ │ │ ├── git.rs │ │ │ ├── image.rs │ │ │ ├── mod.rs │ │ │ ├── network.rs │ │ │ ├── router.rs │ │ │ ├── stats.rs │ │ │ ├── terminal.rs │ │ │ └── volume.rs │ │ ├── build.rs │ │ ├── compose/ │ │ │ ├── mod.rs │ │ │ ├── up.rs │ │ │ └── write.rs │ │ ├── config.rs │ │ ├── docker/ │ │ │ ├── containers.rs │ │ │ ├── images.rs │ │ │ ├── mod.rs │ │ │ ├── networks.rs │ │ │ ├── stats.rs │ │ │ └── volumes.rs │ │ ├── git.rs │ │ ├── helpers.rs │ │ ├── main.rs │ │ ├── ssl.rs │ │ ├── stats.rs │ │ └── terminal.rs │ └── starship.toml ├── client/ │ ├── core/ │ │ ├── rs/ │ │ │ ├── Cargo.toml │ │ │ ├── README.md │ │ │ └── src/ │ │ │ ├── api/ │ │ │ │ ├── auth.rs │ │ │ │ ├── execute/ │ │ │ │ │ ├── action.rs │ │ │ │ │ ├── alerter.rs │ │ │ │ │ ├── build.rs │ │ │ │ │ ├── deployment.rs │ │ │ │ │ ├── maintenance.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── procedure.rs │ │ │ │ │ ├── repo.rs │ │ │ │ │ ├── server.rs │ │ │ │ │ ├── stack.rs │ │ │ │ │ └── sync.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── read/ │ │ │ │ │ ├── action.rs │ │ │ │ │ ├── alert.rs │ │ │ │ │ ├── alerter.rs │ │ │ │ │ ├── build.rs │ │ │ │ │ ├── builder.rs │ │ │ │ │ ├── deployment.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── permission.rs │ │ │ │ │ ├── procedure.rs │ │ │ │ │ ├── provider.rs │ │ │ │ │ ├── repo.rs │ │ │ │ │ ├── schedule.rs │ │ │ │ │ ├── server.rs │ │ │ │ │ ├── stack.rs │ │ │ │ │ ├── sync.rs │ │ │ │ │ ├── tag.rs │ │ │ │ │ ├── toml.rs │ │ │ │ │ ├── update.rs │ │ │ │ │ ├── user.rs │ │ │ │ │ ├── user_group.rs │ │ │ │ │ └── variable.rs │ │ │ │ ├── terminal.rs │ │ │ │ ├── user.rs │ │ │ │ └── write/ │ │ │ │ ├── action.rs │ │ │ │ ├── alerter.rs │ │ │ │ ├── api_key.rs │ │ │ │ ├── build.rs │ │ │ │ ├── builder.rs │ │ │ │ ├── deployment.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── permissions.rs │ │ │ │ ├── procedure.rs │ │ │ │ ├── provider.rs │ │ │ │ ├── repo.rs │ │ │ │ ├── resource.rs │ │ │ │ ├── server.rs │ │ │ │ ├── stack.rs │ │ │ │ ├── sync.rs │ │ │ │ ├── tags.rs │ │ │ │ ├── user.rs │ │ │ │ ├── user_group.rs │ │ │ │ └── variable.rs │ │ │ ├── busy.rs │ │ │ ├── deserializers/ │ │ │ │ ├── conversion.rs │ │ │ │ ├── environment.rs │ │ │ │ ├── file_contents.rs │ │ │ │ ├── forgiving_vec.rs │ │ │ │ ├── item_or_vec.rs │ │ │ │ ├── labels.rs │ │ │ │ ├── maybe_string_i64.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── permission.rs │ │ │ │ ├── string_list.rs │ │ │ │ └── term_signal_labels.rs │ │ │ ├── entities/ │ │ │ │ ├── action.rs │ │ │ │ ├── alert.rs │ │ │ │ ├── alerter.rs │ │ │ │ ├── api_key.rs │ │ │ │ ├── build.rs │ │ │ │ ├── builder.rs │ │ │ │ ├── config/ │ │ │ │ │ ├── cli/ │ │ │ │ │ │ ├── args/ │ │ │ │ │ │ │ ├── container.rs │ │ │ │ │ │ │ ├── database.rs │ │ │ │ │ │ │ ├── list.rs │ │ │ │ │ │ │ ├── mod.rs │ │ │ │ │ │ │ └── update.rs │ │ │ │ │ │ └── mod.rs │ │ │ │ │ ├── core.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ └── periphery.rs │ │ │ │ ├── deployment.rs │ │ │ │ ├── docker/ │ │ │ │ │ ├── container.rs │ │ │ │ │ ├── image.rs │ │ │ │ │ ├── mod.rs │ │ │ │ │ ├── network.rs │ │ │ │ │ ├── stats.rs │ │ │ │ │ └── volume.rs │ │ │ │ ├── logger.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── permission.rs │ │ │ │ ├── procedure.rs │ │ │ │ ├── provider.rs │ │ │ │ ├── repo.rs │ │ │ │ ├── resource.rs │ │ │ │ ├── schedule.rs │ │ │ │ ├── server.rs │ │ │ │ ├── stack.rs │ │ │ │ ├── stats.rs │ │ │ │ ├── sync.rs │ │ │ │ ├── tag.rs │ │ │ │ ├── toml.rs │ │ │ │ ├── update.rs │ │ │ │ ├── user.rs │ │ │ │ ├── user_group.rs │ │ │ │ └── variable.rs │ │ │ ├── lib.rs │ │ │ ├── parsers.rs │ │ │ ├── request.rs │ │ │ ├── terminal.rs │ │ │ └── ws.rs │ │ └── ts/ │ │ ├── README.md │ │ ├── generate_types.mjs │ │ ├── package.json │ │ ├── runfile.toml │ │ ├── src/ │ │ │ ├── lib.ts │ │ │ ├── responses.ts │ │ │ ├── terminal.ts │ │ │ └── types.ts │ │ └── tsconfig.json │ └── periphery/ │ └── rs/ │ ├── Cargo.toml │ └── src/ │ ├── api/ │ │ ├── build.rs │ │ ├── compose.rs │ │ ├── container.rs │ │ ├── git.rs │ │ ├── image.rs │ │ ├── mod.rs │ │ ├── network.rs │ │ ├── stats.rs │ │ ├── terminal.rs │ │ └── volume.rs │ ├── lib.rs │ └── terminal.rs ├── compose/ │ ├── compose.env │ ├── ferretdb.compose.yaml │ ├── mongo.compose.yaml │ └── periphery.compose.yaml ├── config/ │ ├── core.config.toml │ ├── komodo.cli.toml │ └── periphery.config.toml ├── deploy/ │ ├── deno.json │ └── komodo.ts ├── dev.compose.yaml ├── docsite/ │ ├── .gitignore │ ├── README.md │ ├── babel.config.js │ ├── docs/ │ │ ├── ecosystem/ │ │ │ ├── api.md │ │ │ ├── cli.mdx │ │ │ ├── community.md │ │ │ ├── development.md │ │ │ └── index.mdx │ │ ├── intro.md │ │ ├── resources/ │ │ │ ├── auto-update.md │ │ │ ├── build-images/ │ │ │ │ ├── builders.md │ │ │ │ ├── configuration.md │ │ │ │ ├── index.mdx │ │ │ │ ├── pre-build.md │ │ │ │ └── versioning.md │ │ │ ├── deploy-containers/ │ │ │ │ ├── configuration.md │ │ │ │ ├── index.mdx │ │ │ │ └── lifetime-management.md │ │ │ ├── docker-compose.md │ │ │ ├── index.md │ │ │ ├── permissioning.md │ │ │ ├── procedures.md │ │ │ ├── sync-resources.md │ │ │ ├── variables.md │ │ │ └── webhooks.md │ │ └── setup/ │ │ ├── advanced.mdx │ │ ├── backup.md │ │ ├── connect-servers.mdx │ │ ├── ferretdb.mdx │ │ ├── index.mdx │ │ ├── mongo.mdx │ │ └── version-upgrades.md │ ├── docusaurus.config.ts │ ├── package.json │ ├── runfile.toml │ ├── sidebars.ts │ ├── src/ │ │ ├── components/ │ │ │ ├── ComposeAndEnv.tsx │ │ │ ├── Divider.tsx │ │ │ ├── HomepageFeatures/ │ │ │ │ ├── index.tsx │ │ │ │ └── styles.module.css │ │ │ ├── KomodoLogo.tsx │ │ │ ├── RemoteCodeFile.tsx │ │ │ └── SummaryImg.tsx │ │ ├── css/ │ │ │ └── custom.css │ │ └── pages/ │ │ ├── index.module.css │ │ └── index.tsx │ ├── static/ │ │ └── .nojekyll │ └── tsconfig.json ├── example/ │ ├── alerter/ │ │ ├── Cargo.toml │ │ ├── Dockerfile │ │ ├── README.md │ │ └── src/ │ │ └── main.rs │ └── update_logger/ │ ├── Cargo.toml │ ├── Dockerfile │ └── src/ │ └── main.rs ├── expose.compose.yaml ├── frontend/ │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── Dockerfile │ ├── README.md │ ├── components.json │ ├── index.html │ ├── package.json │ ├── postcss.config.js │ ├── public/ │ │ ├── client/ │ │ │ ├── lib.d.ts │ │ │ ├── lib.js │ │ │ ├── responses.d.ts │ │ │ ├── responses.js │ │ │ ├── terminal.d.ts │ │ │ ├── terminal.js │ │ │ ├── types.d.ts │ │ │ └── types.js │ │ ├── deno.d.ts │ │ ├── index.d.ts │ │ ├── manifest.json │ │ ├── robots.txt │ │ └── schema/ │ │ └── compose-spec.json │ ├── runfile.toml │ ├── src/ │ │ ├── components/ │ │ │ ├── alert/ │ │ │ │ ├── details.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── table.tsx │ │ │ ├── config/ │ │ │ │ ├── env_vars.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── linked_repo.tsx │ │ │ │ ├── maintenance.tsx │ │ │ │ └── util.tsx │ │ │ ├── export.tsx │ │ │ ├── group-actions.tsx │ │ │ ├── inspect.tsx │ │ │ ├── keys/ │ │ │ │ └── table.tsx │ │ │ ├── layouts.tsx │ │ │ ├── log.tsx │ │ │ ├── monaco.tsx │ │ │ ├── omnibar.tsx │ │ │ ├── resources/ │ │ │ │ ├── action/ │ │ │ │ │ ├── config.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── info.tsx │ │ │ │ │ └── table.tsx │ │ │ │ ├── alerter/ │ │ │ │ │ ├── config/ │ │ │ │ │ │ ├── alert_types.tsx │ │ │ │ │ │ ├── endpoint.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ └── resources.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── table.tsx │ │ │ │ ├── build/ │ │ │ │ │ ├── actions.tsx │ │ │ │ │ ├── chart.tsx │ │ │ │ │ ├── config.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── info.tsx │ │ │ │ │ └── table.tsx │ │ │ │ ├── builder/ │ │ │ │ │ ├── config.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── table.tsx │ │ │ │ ├── common.tsx │ │ │ │ ├── deployment/ │ │ │ │ │ ├── actions.tsx │ │ │ │ │ ├── config/ │ │ │ │ │ │ ├── components/ │ │ │ │ │ │ │ ├── image.tsx │ │ │ │ │ │ │ ├── network.tsx │ │ │ │ │ │ │ ├── restart.tsx │ │ │ │ │ │ │ └── term-signal.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── inspect.tsx │ │ │ │ │ ├── log.tsx │ │ │ │ │ └── table.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── procedure/ │ │ │ │ │ ├── config.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── table.tsx │ │ │ │ ├── repo/ │ │ │ │ │ ├── actions.tsx │ │ │ │ │ ├── config.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ └── table.tsx │ │ │ │ ├── resource-sync/ │ │ │ │ │ ├── actions.tsx │ │ │ │ │ ├── config.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── info.tsx │ │ │ │ │ ├── pending.tsx │ │ │ │ │ └── table.tsx │ │ │ │ ├── server/ │ │ │ │ │ ├── actions.tsx │ │ │ │ │ ├── config.tsx │ │ │ │ │ ├── hooks.ts │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── info/ │ │ │ │ │ │ ├── containers.tsx │ │ │ │ │ │ ├── images.tsx │ │ │ │ │ │ ├── index.tsx │ │ │ │ │ │ ├── networks.tsx │ │ │ │ │ │ └── volumes.tsx │ │ │ │ │ ├── monitoring-table.tsx │ │ │ │ │ ├── stat-chart.tsx │ │ │ │ │ ├── stats-mini.tsx │ │ │ │ │ ├── stats.tsx │ │ │ │ │ └── table.tsx │ │ │ │ └── stack/ │ │ │ │ ├── actions.tsx │ │ │ │ ├── config.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── info.tsx │ │ │ │ ├── log.tsx │ │ │ │ ├── services.tsx │ │ │ │ └── table.tsx │ │ │ ├── sidebar.tsx │ │ │ ├── tags/ │ │ │ │ └── index.tsx │ │ │ ├── terminal/ │ │ │ │ ├── container.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── server.tsx │ │ │ ├── topbar/ │ │ │ │ ├── components.tsx │ │ │ │ └── index.tsx │ │ │ ├── updates/ │ │ │ │ ├── details.tsx │ │ │ │ ├── resource.tsx │ │ │ │ └── table.tsx │ │ │ ├── users/ │ │ │ │ ├── delete-user-group.tsx │ │ │ │ ├── hooks.ts │ │ │ │ ├── new.tsx │ │ │ │ ├── permissions-selector.tsx │ │ │ │ ├── permissions-table.tsx │ │ │ │ ├── service-api-key.tsx │ │ │ │ └── table.tsx │ │ │ └── util.tsx │ │ ├── globals.css │ │ ├── lib/ │ │ │ ├── color.ts │ │ │ ├── dashboard-preferences.ts │ │ │ ├── formatting.ts │ │ │ ├── hooks.ts │ │ │ ├── socket.tsx │ │ │ └── utils.ts │ │ ├── main.tsx │ │ ├── monaco/ │ │ │ ├── fancy_toml.ts │ │ │ ├── index.ts │ │ │ ├── init.ts │ │ │ ├── key_value.ts │ │ │ ├── shell.ts │ │ │ ├── string_list.ts │ │ │ ├── theme.ts │ │ │ ├── toml.ts │ │ │ └── yaml.ts │ │ ├── pages/ │ │ │ ├── alerts.tsx │ │ │ ├── containers.tsx │ │ │ ├── home/ │ │ │ │ ├── all_resources.tsx │ │ │ │ ├── dashboard.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── tree.tsx │ │ │ ├── login.tsx │ │ │ ├── resource-notifications.tsx │ │ │ ├── resource.tsx │ │ │ ├── resources.tsx │ │ │ ├── schedules.tsx │ │ │ ├── server-info/ │ │ │ │ ├── container/ │ │ │ │ │ ├── actions.tsx │ │ │ │ │ ├── index.tsx │ │ │ │ │ ├── inspect.tsx │ │ │ │ │ └── log.tsx │ │ │ │ ├── image.tsx │ │ │ │ ├── network.tsx │ │ │ │ └── volume.tsx │ │ │ ├── settings/ │ │ │ │ ├── index.tsx │ │ │ │ ├── profile.tsx │ │ │ │ ├── providers.tsx │ │ │ │ ├── tags.tsx │ │ │ │ ├── users.tsx │ │ │ │ └── variables.tsx │ │ │ ├── stack-service/ │ │ │ │ ├── index.tsx │ │ │ │ ├── inspect.tsx │ │ │ │ └── log.tsx │ │ │ ├── update.tsx │ │ │ ├── updates.tsx │ │ │ ├── user-group.tsx │ │ │ ├── user.tsx │ │ │ └── user_disabled.tsx │ │ ├── router.tsx │ │ ├── types.d.ts │ │ ├── ui/ │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── card.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── command.tsx │ │ │ ├── data-table.tsx │ │ │ ├── dialog.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input.tsx │ │ │ ├── json.tsx │ │ │ ├── label.tsx │ │ │ ├── multi-select.tsx │ │ │ ├── pagination.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── textarea.tsx │ │ │ ├── theme.tsx │ │ │ ├── toast.tsx │ │ │ ├── toaster.tsx │ │ │ ├── toggle-group.tsx │ │ │ ├── toggle.tsx │ │ │ ├── tooltip.tsx │ │ │ └── use-toast.ts │ │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── tsconfig.node.json │ └── vite.config.ts ├── komodo.code-workspace ├── lib/ │ ├── cache/ │ │ ├── Cargo.toml │ │ ├── README.md │ │ └── src/ │ │ └── lib.rs │ ├── command/ │ │ ├── Cargo.toml │ │ ├── README.md │ │ └── src/ │ │ └── lib.rs │ ├── config/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── error.rs │ │ ├── includes.rs │ │ ├── lib.rs │ │ ├── load.rs │ │ └── merge.rs │ ├── database/ │ │ ├── Cargo.toml │ │ └── src/ │ │ ├── lib.rs │ │ └── utils/ │ │ ├── backup.rs │ │ ├── copy.rs │ │ ├── mod.rs │ │ └── restore.rs │ ├── environment/ │ │ ├── Cargo.toml │ │ └── src/ │ │ └── lib.rs │ ├── environment_file/ │ │ ├── Cargo.toml │ │ ├── README.md │ │ └── src/ │ │ └── lib.rs │ ├── formatting/ │ │ ├── Cargo.toml │ │ ├── README.md │ │ └── src/ │ │ └── lib.rs │ ├── git/ │ │ ├── Cargo.toml │ │ ├── README.md │ │ └── src/ │ │ ├── clone.rs │ │ ├── commit.rs │ │ ├── init.rs │ │ ├── lib.rs │ │ ├── pull.rs │ │ └── pull_or_clone.rs │ ├── interpolate/ │ │ ├── Cargo.toml │ │ └── src/ │ │ └── lib.rs │ ├── logger/ │ │ ├── Cargo.toml │ │ ├── README.md │ │ └── src/ │ │ ├── lib.rs │ │ └── otel.rs │ └── response/ │ ├── Cargo.toml │ └── src/ │ └── lib.rs ├── readme.md ├── roadmap.md ├── runfile.toml ├── rustfmt.toml ├── scripts/ │ ├── install-cli.py │ ├── readme.md │ └── setup-periphery.py └── typeshare.toml ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cargo/config.toml ================================================ [build] rustflags = ["-Wunused-crate-dependencies"] ================================================ FILE: .devcontainer/dev.compose.yaml ================================================ services: dev: image: mcr.microsoft.com/devcontainers/rust:1-1-bullseye volumes: # Mount the root folder that contains .git - ../:/workspace:cached - /var/run/docker.sock:/var/run/docker.sock - /proc:/proc - repos:/etc/komodo/repos - stacks:/etc/komodo/stacks command: sleep infinity ports: - "9121:9121" environment: KOMODO_FIRST_SERVER: http://localhost:8120 KOMODO_DATABASE_ADDRESS: db KOMODO_ENABLE_NEW_USERS: true KOMODO_LOCAL_AUTH: true KOMODO_JWT_SECRET: a_random_secret links: - db # ... db: extends: file: ../dev.compose.yaml service: ferretdb volumes: data: repo-cache: repos: stacks: ================================================ FILE: .devcontainer/devcontainer.json ================================================ // For format details, see https://aka.ms/devcontainer.json. For config options, see the // README at: https://github.com/devcontainers/templates/tree/main/src/rust { "name": "Komodo", // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile //"image": "mcr.microsoft.com/devcontainers/rust:1-1-bullseye", "dockerComposeFile": ["dev.compose.yaml"], "workspaceFolder": "/workspace", "service": "dev", // Features to add to the dev container. More info: https://containers.dev/features. "features": { "ghcr.io/devcontainers/features/node:1": { "version": "20.12.2" }, "ghcr.io/devcontainers-community/features/deno:1": { } }, // Use 'mounts' to make the cargo cache persistent in a Docker Volume. "mounts": [ { "source": "devcontainer-cargo-cache-${devcontainerId}", "target": "/usr/local/cargo", "type": "volume" } ], // Use 'forwardPorts' to make a list of ports inside the container available locally. "forwardPorts": [ 9121 ], // Use 'postCreateCommand' to run commands after the container is created. "postCreateCommand": "./.devcontainer/postCreate.sh", "runServices": [ "db" ] // Configure tool-specific properties. // "customizations": {}, // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. // "remoteUser": "root" } ================================================ FILE: .devcontainer/postCreate.sh ================================================ #!/bin/sh cargo install typeshare-cli ================================================ FILE: .github/FUNDING.yml ================================================ # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository open_collective: komodo ================================================ FILE: .gitignore ================================================ target node_modules dist deno.lock .env .env.development .DS_Store .idea /frontend/build /lib/ts_client/build .dev ================================================ FILE: .kminclude ================================================ .dev ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "rust-lang.rust-analyzer", "tamasfe.even-better-toml", "vadimcn.vscode-lldb", "denoland.vscode-deno" ] } ================================================ FILE: .vscode/resolver.code-snippets ================================================ { "resolve": { "scope": "rust", "prefix": "resolve", "body": [ "impl Resolve<${1}, User> for State {", "\tasync fn resolve(&self, ${1} { ${0} }: ${1}, _: User) -> anyhow::Result<${2}> {", "\t\ttodo!()", "\t}", "}" ] }, "static": { "scope": "rust", "prefix": "static", "body": [ "fn ${1}() -> &'static ${2} {", "\tstatic ${3}: OnceLock<${2}> = OnceLock::new();", "\t${3}.get_or_init(|| {", "\t\t${0}", "\t})", "}" ] } } ================================================ FILE: .vscode/tasks.json ================================================ { "version": "2.0.0", "tasks": [ { "label": "Run Core", "command": "cargo", "args": [ "run", "-p", "komodo_core", "--release" ], "options": { "cwd": "${workspaceFolder}", "env": { "KOMODO_CONFIG_PATH": "test.core.config.toml" } }, "problemMatcher": [ "$rustc" ] }, { "label": "Build Core", "command": "cargo", "args": [ "build", "-p", "komodo_core", "--release" ], "options": { "cwd": "${workspaceFolder}", "env": { "KOMODO_CONFIG_PATH": "test.core.config.toml" } }, "problemMatcher": [ "$rustc" ] }, { "label": "Run Periphery", "command": "cargo", "args": [ "run", "-p", "komodo_periphery", "--release" ], "options": { "cwd": "${workspaceFolder}", "env": { "KOMODO_CONFIG_PATH": "test.periphery.config.toml" } }, "problemMatcher": [ "$rustc" ] }, { "label": "Build Periphery", "command": "cargo", "args": [ "build", "-p", "komodo_periphery", "--release" ], "options": { "cwd": "${workspaceFolder}", "env": { "KOMODO_CONFIG_PATH": "test.periphery.config.toml" } }, "problemMatcher": [ "$rustc" ] }, { "label": "Run Backend", "dependsOn": [ "Run Core", "Run Periphery" ], "problemMatcher": [ "$rustc" ] }, { "label": "Build TS Client Types", "type": "process", "command": "node", "args": [ "./client/core/ts/generate_types.mjs" ], "problemMatcher": [] }, { "label": "Init TS Client", "type": "shell", "command": "yarn && yarn build && yarn link", "options": { "cwd": "${workspaceFolder}/client/core/ts", }, "problemMatcher": [] }, { "label": "Init Frontend Client", "type": "shell", "command": "yarn link komodo_client && yarn install", "options": { "cwd": "${workspaceFolder}/frontend", }, "problemMatcher": [] }, { "label": "Init Frontend", "dependsOn": [ "Build TS Client Types", "Init TS Client", "Init Frontend Client" ], "dependsOrder": "sequence", "problemMatcher": [] }, { "label": "Build Frontend", "type": "shell", "command": "yarn build", "options": { "cwd": "${workspaceFolder}/frontend", }, "problemMatcher": [] }, { "label": "Prepare Frontend For Run", "type": "shell", "command": "cp -r ./client/core/ts/dist/. frontend/public/client/.", "options": { "cwd": "${workspaceFolder}", }, "dependsOn": [ "Build TS Client Types", "Build Frontend" ], "dependsOrder": "sequence", "problemMatcher": [] }, { "label": "Run Frontend", "type": "shell", "command": "yarn dev", "options": { "cwd": "${workspaceFolder}/frontend", }, "dependsOn": ["Prepare Frontend For Run"], "problemMatcher": [] }, { "label": "Init", "dependsOn": [ "Build Backend", "Init Frontend" ], "dependsOrder": "sequence", "problemMatcher": [] }, { "label": "Run Komodo", "dependsOn": [ "Run Core", "Run Periphery", "Run Frontend" ], "problemMatcher": [] }, ] } ================================================ FILE: Cargo.toml ================================================ [workspace] resolver = "2" members = [ "bin/*", "lib/*", "client/core/rs", "client/periphery/rs", ] [workspace.package] version = "1.19.5" edition = "2024" authors = ["mbecker20 "] license = "GPL-3.0-or-later" repository = "https://github.com/moghtech/komodo" homepage = "https://komo.do" [profile.release] strip = "debuginfo" [workspace.dependencies] # LOCAL komodo_client = { path = "client/core/rs" } periphery_client = { path = "client/periphery/rs" } environment_file = { path = "lib/environment_file" } environment = { path = "lib/environment" } interpolate = { path = "lib/interpolate" } formatting = { path = "lib/formatting" } database = { path = "lib/database" } response = { path = "lib/response" } command = { path = "lib/command" } config = { path = "lib/config" } logger = { path = "lib/logger" } cache = { path = "lib/cache" } git = { path = "lib/git" } # MOGH run_command = { version = "0.0.6", features = ["async_tokio"] } serror = { version = "0.5.1", default-features = false } slack = { version = "0.4.0", package = "slack_client_rs", default-features = false, features = ["rustls"] } derive_default_builder = "0.1.8" derive_empty_traits = "0.1.0" async_timing_util = "1.0.0" partial_derive2 = "0.4.3" derive_variants = "1.0.0" mongo_indexed = "2.0.2" resolver_api = "3.0.0" toml_pretty = "1.2.0" mungos = "3.2.2" svi = "1.2.0" # ASYNC reqwest = { version = "0.12.23", default-features = false, features = ["json", "stream", "rustls-tls-native-roots"] } tokio = { version = "1.47.1", features = ["full"] } tokio-util = { version = "0.7.16", features = ["io", "codec"] } tokio-stream = { version = "0.1.17", features = ["sync"] } pin-project-lite = "0.2.16" futures = "0.3.31" futures-util = "0.3.31" arc-swap = "1.7.1" # SERVER tokio-tungstenite = { version = "0.27.0", features = ["rustls-tls-native-roots"] } axum-extra = { version = "0.10.1", features = ["typed-header"] } tower-http = { version = "0.6.6", features = ["fs", "cors"] } axum-server = { version = "0.7.2", features = ["tls-rustls"] } axum = { version = "0.8.4", features = ["ws", "json", "macros"] } # SER/DE ipnetwork = { version = "0.21.1", features = ["serde"] } indexmap = { version = "2.11.1", features = ["serde"] } serde = { version = "1.0.219", features = ["derive"] } strum = { version = "0.27.2", features = ["derive"] } bson = { version = "2.15.0" } # must keep in sync with mongodb version serde_yaml_ng = "0.10.0" serde_json = "1.0.145" serde_qs = "0.15.0" toml = "0.9.5" # ERROR anyhow = "1.0.99" thiserror = "2.0.16" # LOGGING opentelemetry-otlp = { version = "0.30.0", features = ["tls-roots", "reqwest-rustls"] } opentelemetry_sdk = { version = "0.30.0", features = ["rt-tokio"] } tracing-subscriber = { version = "0.3.20", features = ["json"] } opentelemetry-semantic-conventions = "0.30.0" tracing-opentelemetry = "0.31.0" opentelemetry = "0.30.0" tracing = "0.1.41" # CONFIG clap = { version = "4.5.47", features = ["derive"] } dotenvy = "0.15.7" envy = "0.4.2" # CRYPTO / AUTH uuid = { version = "1.18.1", features = ["v4", "fast-rng", "serde"] } jsonwebtoken = { version = "9.3.1", default-features = false } openidconnect = "4.0.1" urlencoding = "2.1.3" nom_pem = "4.0.0" bcrypt = "0.17.1" base64 = "0.22.1" rustls = "0.23.31" hmac = "0.12.1" sha2 = "0.10.9" rand = "0.9.2" hex = "0.4.3" # SYSTEM portable-pty = "0.9.0" bollard = "0.19.2" sysinfo = "0.37.0" # CLOUD aws-config = "1.8.6" aws-sdk-ec2 = "1.167.0" aws-credential-types = "1.2.6" ## CRON english-to-cron = "0.1.6" chrono-tz = "0.10.4" chrono = "0.4.42" croner = "3.0.0" # MISC async-compression = { version = "0.4.30", features = ["tokio", "gzip"] } derive_builder = "0.20.2" comfy-table = "7.2.1" typeshare = "1.0.4" octorust = "0.10.0" dashmap = "6.1.0" wildcard = "0.3.0" colored = "3.0.0" regex = "1.11.2" bytes = "1.10.1" shell-escape = "0.1.5" ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: bin/binaries.Dockerfile ================================================ ## Builds the Komodo Core, Periphery, and Util binaries ## for a specific architecture. FROM rust:1.89.0-bullseye AS builder RUN cargo install cargo-strip WORKDIR /builder COPY Cargo.toml Cargo.lock ./ COPY ./lib ./lib COPY ./client/core/rs ./client/core/rs COPY ./client/periphery ./client/periphery COPY ./bin/core ./bin/core COPY ./bin/periphery ./bin/periphery COPY ./bin/cli ./bin/cli # Compile bin RUN \ cargo build -p komodo_core --release && \ cargo build -p komodo_periphery --release && \ cargo build -p komodo_cli --release && \ cargo strip # Copy just the binaries to scratch image FROM scratch COPY --from=builder /builder/target/release/core /core COPY --from=builder /builder/target/release/periphery /periphery COPY --from=builder /builder/target/release/km /km LABEL org.opencontainers.image.source=https://github.com/moghtech/komodo LABEL org.opencontainers.image.description="Komodo Binaries" LABEL org.opencontainers.image.licenses=GPL-3.0 ================================================ FILE: bin/chef.binaries.Dockerfile ================================================ ## Builds the Komodo Core, Periphery, and Util binaries ## for a specific architecture. ## Uses chef for dependency caching to help speed up back-to-back builds. FROM lukemathwalker/cargo-chef:latest-rust-1.89.0-bullseye AS chef WORKDIR /builder # Plan just the RECIPE to see if things have changed FROM chef AS planner COPY . . RUN cargo chef prepare --recipe-path recipe.json FROM chef AS builder RUN cargo install cargo-strip COPY --from=planner /builder/recipe.json recipe.json # Build JUST dependencies - cached layer RUN cargo chef cook --release --recipe-path recipe.json # NOW copy again (this time into builder) and build app COPY . . RUN \ cargo build --release --bin core && \ cargo build --release --bin periphery && \ cargo build --release --bin km && \ cargo strip # Copy just the binaries to scratch image FROM scratch COPY --from=builder /builder/target/release/core /core COPY --from=builder /builder/target/release/periphery /periphery COPY --from=builder /builder/target/release/km /km LABEL org.opencontainers.image.source=https://github.com/moghtech/komodo LABEL org.opencontainers.image.description="Komodo Binaries" LABEL org.opencontainers.image.licenses=GPL-3.0 ================================================ FILE: bin/cli/Cargo.toml ================================================ [package] name = "komodo_cli" description = "Command line tool for Komodo" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true [[bin]] name = "km" path = "src/main.rs" [dependencies] # local environment_file.workspace = true komodo_client.workspace = true database.workspace = true config.workspace = true logger.workspace = true # external futures-util.workspace = true comfy-table.workspace = true serde_json.workspace = true serde_qs.workspace = true wildcard.workspace = true tracing.workspace = true colored.workspace = true dotenvy.workspace = true anyhow.workspace = true chrono.workspace = true tokio.workspace = true serde.workspace = true clap.workspace = true envy.workspace = true ================================================ FILE: bin/cli/README.md ================================================ # Komodo CLI Komodo CLI is a tool to execute actions on your Komodo instance from shell scripts. ## Install ```sh cargo install komodo_cli ``` Note: On Ubuntu, also requires `apt install build-essential pkg-config libssl-dev`. ## Usage ### Credentials Configure a file `~/.config/komodo/creds.toml` file with contents: ```toml url = "https://your.komodo.address" key = "YOUR-API-KEY" secret = "YOUR-API-SECRET" ``` Note. You can specify a different creds file by using `--creds ./other/path.toml`. You can also bypass using any file and pass the information using `--url`, `--key`, `--secret`: ```sh komodo --url "https://your.komodo.address" --key "YOUR-API-KEY" --secret "YOUR-API-SECRET" ... ``` ### Run Executions ```sh # Triggers an example build komodo execute run-build test_build ``` #### Manual `komodo --help` ```md Command line tool to execute Komodo actions Usage: komodo [OPTIONS] Commands: execute Runs an execution help Print this message or the help of the given subcommand(s) Options: --creds The path to a creds file [default: /Users/max/.config/komodo/creds.toml] --url Pass url in args instead of creds file --key Pass api key in args instead of creds file --secret Pass api secret in args instead of creds file -y, --yes Always continue on user confirmation prompts -h, --help Print help (see more with '--help') -V, --version Print version ``` `komodo execute --help` ```md Runs an execution Usage: komodo execute Commands: none The "null" execution. Does nothing run-procedure Runs the target procedure. Response: [Update] run-build Runs the target build. Response: [Update] cancel-build Cancels the target build. Only does anything if the build is `building` when called. Response: [Update] deploy Deploys the container for the target deployment. Response: [Update] start-deployment Starts the container for the target deployment. Response: [Update] restart-deployment Restarts the container for the target deployment. Response: [Update] pause-deployment Pauses the container for the target deployment. Response: [Update] unpause-deployment Unpauses the container for the target deployment. Response: [Update] stop-deployment Stops the container for the target deployment. Response: [Update] destroy-deployment Stops and destroys the container for the target deployment. Reponse: [Update] clone-repo Clones the target repo. Response: [Update] pull-repo Pulls the target repo. Response: [Update] build-repo Builds the target repo, using the attached builder. Response: [Update] cancel-repo-build Cancels the target repo build. Only does anything if the repo build is `building` when called. Response: [Update] start-container Starts the container on the target server. Response: [Update] restart-container Restarts the container on the target server. Response: [Update] pause-container Pauses the container on the target server. Response: [Update] unpause-container Unpauses the container on the target server. Response: [Update] stop-container Stops the container on the target server. Response: [Update] destroy-container Stops and destroys the container on the target server. Reponse: [Update] start-all-containers Starts all containers on the target server. Response: [Update] restart-all-containers Restarts all containers on the target server. Response: [Update] pause-all-containers Pauses all containers on the target server. Response: [Update] unpause-all-containers Unpauses all containers on the target server. Response: [Update] stop-all-containers Stops all containers on the target server. Response: [Update] prune-containers Prunes the docker containers on the target server. Response: [Update] delete-network Delete a docker network. Response: [Update] prune-networks Prunes the docker networks on the target server. Response: [Update] delete-image Delete a docker image. Response: [Update] prune-images Prunes the docker images on the target server. Response: [Update] delete-volume Delete a docker volume. Response: [Update] prune-volumes Prunes the docker volumes on the target server. Response: [Update] prune-system Prunes the docker system on the target server, including volumes. Response: [Update] run-sync Runs the target resource sync. Response: [Update] deploy-stack Deploys the target stack. `docker compose up`. Response: [Update] start-stack Starts the target stack. `docker compose start`. Response: [Update] restart-stack Restarts the target stack. `docker compose restart`. Response: [Update] pause-stack Pauses the target stack. `docker compose pause`. Response: [Update] unpause-stack Unpauses the target stack. `docker compose unpause`. Response: [Update] stop-stack Starts the target stack. `docker compose stop`. Response: [Update] destroy-stack Destoys the target stack. `docker compose down`. Response: [Update] sleep help Print this message or the help of the given subcommand(s) Options: -h, --help Print help ``` ### --yes You can use `--yes` to avoid any human prompt to continue, for use in automated environments. ================================================ FILE: bin/cli/aio.Dockerfile ================================================ FROM rust:1.89.0-bullseye AS builder RUN cargo install cargo-strip WORKDIR /builder COPY Cargo.toml Cargo.lock ./ COPY ./lib ./lib COPY ./client/core/rs ./client/core/rs COPY ./client/periphery ./client/periphery COPY ./bin/cli ./bin/cli # Compile bin RUN cargo build -p komodo_cli --release && cargo strip # Copy binaries to distroless base FROM gcr.io/distroless/cc COPY --from=builder /builder/target/release/km /usr/local/bin/km ENV KOMODO_CLI_CONFIG_PATHS="/config" CMD [ "km" ] LABEL org.opencontainers.image.source=https://github.com/moghtech/komodo LABEL org.opencontainers.image.description="Komodo CLI" LABEL org.opencontainers.image.licenses=GPL-3.0 ================================================ FILE: bin/cli/docs/copy-database.md ================================================ # Copy Database Utility Copy the Komodo database contents between running, mongo-compatible databases. Can be used to move between MongoDB / FerretDB, or upgrade from FerretDB v1 to v2. ```yaml services: copy_database: image: ghcr.io/moghtech/komodo-cli command: km database copy -y environment: KOMODO_DATABASE_URI: mongodb://${KOMODO_DB_USERNAME}:${KOMODO_DB_PASSWORD}@source:27017 KOMODO_DATABASE_DB_NAME: ${KOMODO_DATABASE_DB_NAME:-komodo} KOMODO_CLI_DATABASE_TARGET_URI: mongodb://${KOMODO_DB_USERNAME}:${KOMODO_DB_PASSWORD}@target:27017 KOMODO_CLI_DATABASE_TARGET_DB_NAME: ${KOMODO_DATABASE_DB_NAME:-komodo} ``` ## FerretDB v2 Update Guide Up to Komodo 1.17.5, users who wanted to use Postgres / Sqlite were instructed to deploy FerretDB v1. Now that v2 is out however, v1 will go largely unsupported. Users are recommended to migrate to v2 for the best performance and ongoing support / updates, however the internal data structures have changed and this cannot be done in-place. Also note that FerretDB v2 no longer supports Sqlite, and only supports a [customized Postgres distribution](https://docs.ferretdb.io/installation/documentdb/docker/). Nonetheless, it remains a solid option for hosts which [do not support mongo](https://github.com/moghtech/komodo/issues/59). Also note, the same basic process outlined below can also be used to move between MongoDB and FerretDB, just replace FerretDB v2 with the database you wish to move to. ### **Step 1**: *Add* the new database to the top of your existing Komodo compose file. **Don't forget to also add the new volumes.** ```yaml ## In Komodo compose.yaml services: postgres2: # Recommended: Pin to a specific version # https://github.com/FerretDB/documentdb/pkgs/container/postgres-documentdb image: ghcr.io/ferretdb/postgres-documentdb labels: komodo.skip: # Prevent Komodo from stopping with StopAllContainers restart: unless-stopped # ports: # - 5432:5432 volumes: - postgres-data:/var/lib/postgresql/data environment: POSTGRES_USER: ${KOMODO_DB_USERNAME} POSTGRES_PASSWORD: ${KOMODO_DB_PASSWORD} POSTGRES_DB: postgres # Do not change ferretdb2: # Recommended: Pin to a specific version # https://github.com/FerretDB/FerretDB/pkgs/container/ferretdb image: ghcr.io/ferretdb/ferretdb labels: komodo.skip: # Prevent Komodo from stopping with StopAllContainers restart: unless-stopped depends_on: - postgres2 # ports: # - 27017:27017 volumes: - ferretdb-state:/state environment: FERRETDB_POSTGRESQL_URL: postgres://${KOMODO_DB_USERNAME}:${KOMODO_DB_PASSWORD}@postgres2:5432/postgres ...(unchanged) volumes: ...(unchanged) postgres-data: ferretdb-state: ``` ### **Step 2**: *Add* the database copy utility to Komodo compose file. The SOURCE_URI points to the existing database, ie the old FerretDB v1, and it depends on whether it was deployed using Postgres or Sqlite. The example below uses the Postgres one, but if you use Sqlite it should just be something like `mongodb://ferretdb:27017`. ```yaml ## In Komodo compose.yaml services: ...(new database) copy_database: image: ghcr.io/moghtech/komodo-cli command: km database copy -y environment: KOMODO_DATABASE_URI: mongodb://${KOMODO_DB_USERNAME}:${KOMODO_DB_PASSWORD}@ferretdb:27017/${KOMODO_DATABASE_DB_NAME:-komodo}?authMechanism=PLAIN KOMODO_DATABASE_DB_NAME: ${KOMODO_DATABASE_DB_NAME:-komodo} KOMODO_CLI_DATABASE_TARGET_URI: mongodb://${KOMODO_DB_USERNAME}:${KOMODO_DB_PASSWORD}@ferretdb2:27017 KOMODO_CLI_DATABASE_TARGET_DB_NAME: ${KOMODO_DATABASE_DB_NAME:-komodo} ...(unchanged) ``` ### **Step 3**: *Compose Up* the new additions Run `docker compose -p komodo --env-file compose.env -f xxxxx.compose.yaml up -d`, filling in the name of your compose.yaml. This will start up both the old and new database, and copy the data to the new one. Wait a few moments for the `copy_database` service to finish. When it exits, confirm the logs show the data was moved successfully, and move on to the next step. ### **Step 4**: Point Komodo Core to the new database In your Komodo compose.yaml, first *comment out* the `copy_database` service and old ferretdb v1 service/s. Then update the `core` service environment to point to `ferretdb2`. ```yaml services: ... core: ...(unchanged) environment: KOMODO_DATABASE_ADDRESS: ferretdb2:27017 KOMODO_DATABASE_USERNAME: ${KOMODO_DB_USERNAME} KOMODO_DATABASE_PASSWORD: ${KOMODO_DB_PASSWORD} ``` ### **Step 5**: Final *Compose Up* Repeat the same `docker compose` command as before to apply the changes, and then try navigating to your Komodo web page. If it works, congrats, **you are done**. You can clean up the compose file if you would like, removing the old volumes etc. If it does not work, check the logs for any obvious issues, and if necessary you can undo the previous steps to go back to using the previous database. ================================================ FILE: bin/cli/multi-arch.Dockerfile ================================================ ## Assumes the latest binaries for x86_64 and aarch64 are already built (by binaries.Dockerfile). ## Since theres no heavy build here, QEMU multi-arch builds are fine for this image. ARG BINARIES_IMAGE=ghcr.io/moghtech/komodo-binaries:latest ARG X86_64_BINARIES=${BINARIES_IMAGE}-x86_64 ARG AARCH64_BINARIES=${BINARIES_IMAGE}-aarch64 # This is required to work with COPY --from FROM ${X86_64_BINARIES} AS x86_64 FROM ${AARCH64_BINARIES} AS aarch64 FROM debian:bullseye-slim WORKDIR /app ## Copy both binaries initially, but only keep appropriate one for the TARGETPLATFORM. COPY --from=x86_64 /km /app/arch/linux/amd64 COPY --from=aarch64 /km /app/arch/linux/arm64 ARG TARGETPLATFORM RUN mv /app/arch/${TARGETPLATFORM} /usr/local/bin/km && rm -r /app/arch ENV KOMODO_CLI_CONFIG_PATHS="/config" CMD [ "km" ] LABEL org.opencontainers.image.source=https://github.com/moghtech/komodo LABEL org.opencontainers.image.description="Komodo CLI" LABEL org.opencontainers.image.licenses=GPL-3.0 ================================================ FILE: bin/cli/runfile.toml ================================================ [install-cli] alias = "ic" description = "installs the komodo-cli, available on the command line as 'km'" cmd = "cargo install --path ." ================================================ FILE: bin/cli/single-arch.Dockerfile ================================================ ## Assumes the latest binaries for the required arch are already built (by binaries.Dockerfile). ARG BINARIES_IMAGE=ghcr.io/moghtech/komodo-binaries:latest # This is required to work with COPY --from FROM ${BINARIES_IMAGE} AS binaries FROM gcr.io/distroless/cc COPY --from=binaries /km /usr/local/bin/km ENV KOMODO_CLI_CONFIG_PATHS="/config" CMD [ "km" ] LABEL org.opencontainers.image.source=https://github.com/moghtech/komodo LABEL org.opencontainers.image.description="Komodo CLI" LABEL org.opencontainers.image.licenses=GPL-3.0 ================================================ FILE: bin/cli/src/command/container.rs ================================================ use std::collections::{HashMap, HashSet}; use anyhow::Context; use colored::Colorize; use comfy_table::{Attribute, Cell, Color}; use futures_util::{ FutureExt, TryStreamExt, stream::FuturesUnordered, }; use komodo_client::{ api::read::{ InspectDockerContainer, ListAllDockerContainers, ListServers, }, entities::{ config::cli::args::container::{ Container, ContainerCommand, InspectContainer, }, docker::{ self, container::{ContainerListItem, ContainerStateStatusEnum}, }, }, }; use crate::{ command::{ PrintTable, clamp_sha, matches_wildcards, parse_wildcards, print_items, }, config::cli_config, }; pub async fn handle(container: &Container) -> anyhow::Result<()> { match &container.command { None => list_containers(container).await, Some(ContainerCommand::Inspect(inspect)) => { inspect_container(inspect).await } } } async fn list_containers( Container { all, down, links, reverse, containers: names, images, networks, servers, format, command: _, }: &Container, ) -> anyhow::Result<()> { let client = super::komodo_client().await?; let (server_map, containers) = tokio::try_join!( client .read(ListServers::default()) .map(|res| res.map(|res| res .into_iter() .map(|s| (s.id.clone(), s)) .collect::>())), client.read(ListAllDockerContainers { servers: Default::default() }), )?; // (Option, Container) let containers = containers.into_iter().map(|c| { let server = if let Some(server_id) = c.server_id.as_ref() && let Some(server) = server_map.get(server_id) { server } else { return (None, c); }; (Some(server.name.as_str()), c) }); let names = parse_wildcards(names); let servers = parse_wildcards(servers); let images = parse_wildcards(images); let networks = parse_wildcards(networks); let mut containers = containers .into_iter() .filter(|(server_name, c)| { let state_check = if *all { true } else if *down { !matches!(c.state, ContainerStateStatusEnum::Running) } else { matches!(c.state, ContainerStateStatusEnum::Running) }; let network_check = matches_wildcards( &networks, &c.network_mode .as_deref() .map(|n| vec![n]) .unwrap_or_default(), ) || matches_wildcards( &networks, &c.networks.iter().map(String::as_str).collect::>(), ); state_check && network_check && matches_wildcards(&names, &[c.name.as_str()]) && matches_wildcards( &servers, &server_name .as_deref() .map(|i| vec![i]) .unwrap_or_default(), ) && matches_wildcards( &images, &c.image.as_deref().map(|i| vec![i]).unwrap_or_default(), ) }) .collect::>(); containers.sort_by(|(a_s, a), (b_s, b)| { a.state .cmp(&b.state) .then(a.name.cmp(&b.name)) .then(a_s.cmp(b_s)) .then(a.network_mode.cmp(&b.network_mode)) .then(a.image.cmp(&b.image)) }); if *reverse { containers.reverse(); } print_items(containers, *format, *links)?; Ok(()) } pub async fn inspect_container( inspect: &InspectContainer, ) -> anyhow::Result<()> { let client = super::komodo_client().await?; let (server_map, mut containers) = tokio::try_join!( client .read(ListServers::default()) .map(|res| res.map(|res| res .into_iter() .map(|s| (s.id.clone(), s)) .collect::>())), client.read(ListAllDockerContainers { servers: Default::default() }), )?; containers.iter_mut().for_each(|c| { let Some(server_id) = c.server_id.as_ref() else { return; }; let Some(server) = server_map.get(server_id) else { c.server_id = Some(String::from("Unknown")); return; }; c.server_id = Some(server.name.clone()); }); let names = [inspect.container.to_string()]; let names = parse_wildcards(&names); let servers = parse_wildcards(&inspect.servers); let mut containers = containers .into_iter() .filter(|c| { matches_wildcards(&names, &[c.name.as_str()]) && matches_wildcards( &servers, &c.server_id .as_deref() .map(|i| vec![i]) .unwrap_or_default(), ) }) .map(|c| async move { client .read(InspectDockerContainer { container: c.name, server: c.server_id.context("No server...")?, }) .await }) .collect::>() .try_collect::>() .await?; containers.sort_by(|a, b| a.name.cmp(&b.name)); match containers.len() { 0 => { println!( "{}: Did not find any containers matching '{}'", "INFO".green(), inspect.container.bold() ); } 1 => { println!("{}", serialize_container(inspect, &containers[0])?); } _ => { let containers = containers .iter() .map(|c| serialize_container(inspect, c)) .collect::>>()? .join("\n"); println!("{containers}"); } } Ok(()) } fn serialize_container( inspect: &InspectContainer, container: &docker::container::Container, ) -> anyhow::Result { let res = if inspect.state { serde_json::to_string_pretty(&container.state) } else if inspect.mounts { serde_json::to_string_pretty(&container.mounts) } else if inspect.host_config { serde_json::to_string_pretty(&container.host_config) } else if inspect.config { serde_json::to_string_pretty(&container.config) } else if inspect.network_settings { serde_json::to_string_pretty(&container.network_settings) } else { serde_json::to_string_pretty(container) } .context("Failed to serialize items to JSON")?; Ok(res) } // (Option, Container) impl PrintTable for (Option<&'_ str>, ContainerListItem) { fn header(links: bool) -> &'static [&'static str] { if links { &[ "Container", "State", "Server", "Ports", "Networks", "Image", "Link", ] } else { &["Container", "State", "Server", "Ports", "Networks", "Image"] } } fn row(self, links: bool) -> Vec { let color = match self.1.state { ContainerStateStatusEnum::Running => Color::Green, ContainerStateStatusEnum::Paused => Color::DarkYellow, ContainerStateStatusEnum::Empty => Color::Grey, _ => Color::Red, }; let mut networks = HashSet::new(); if let Some(network) = self.1.network_mode { networks.insert(network); } for network in self.1.networks { networks.insert(network); } let mut networks = networks.into_iter().collect::>(); networks.sort(); let mut ports = self .1 .ports .into_iter() .flat_map(|p| p.public_port.map(|p| p.to_string())) .collect::>() .into_iter() .collect::>(); ports.sort(); let ports = if ports.is_empty() { Cell::new("") } else { Cell::new(format!(":{}", ports.join(", :"))) }; let image = self.1.image.as_deref().unwrap_or("Unknown"); let mut res = vec![ Cell::new(self.1.name.clone()).add_attribute(Attribute::Bold), Cell::new(self.1.state.to_string()) .fg(color) .add_attribute(Attribute::Bold), Cell::new(self.0.unwrap_or("Unknown")), ports, Cell::new(networks.join(", ")), Cell::new(clamp_sha(image)), ]; if !links { return res; } let link = if let Some(server_id) = self.1.server_id { format!( "{}/servers/{server_id}/container/{}", cli_config().host, self.1.name ) } else { String::new() }; res.push(Cell::new(link)); res } } ================================================ FILE: bin/cli/src/command/database.rs ================================================ use std::path::Path; use anyhow::Context; use colored::Colorize; use komodo_client::entities::{ config::cli::args::database::DatabaseCommand, optional_string, }; use crate::{command::sanitize_uri, config::cli_config}; pub async fn handle(command: &DatabaseCommand) -> anyhow::Result<()> { match command { DatabaseCommand::Backup { yes, .. } => backup(*yes).await, DatabaseCommand::Restore { restore_folder, index, yes, .. } => restore(restore_folder.as_deref(), *index, *yes).await, DatabaseCommand::Prune { yes, .. } => prune(*yes).await, DatabaseCommand::Copy { yes, index, .. } => { copy(*index, *yes).await } } } async fn backup(yes: bool) -> anyhow::Result<()> { let config = cli_config(); println!( "\n🦎 {} Database {} Utility 🦎", "Komodo".bold(), "Backup".green().bold() ); println!( "\n{}\n", " - Backup all database contents to gzip compressed files." .dimmed() ); if let Some(uri) = optional_string(&config.database.uri) { println!("{}: {}", " - Source URI".dimmed(), sanitize_uri(&uri)); } if let Some(address) = optional_string(&config.database.address) { println!("{}: {address}", " - Source Address".dimmed()); } if let Some(username) = optional_string(&config.database.username) { println!("{}: {username}", " - Source Username".dimmed()); } println!( "{}: {}\n", " - Source Db Name".dimmed(), config.database.db_name, ); println!( "{}: {:?}", " - Backups Folder".dimmed(), config.backups_folder ); if config.max_backups == 0 { println!( "{}{}", " - Backup pruning".dimmed(), "disabled".red().dimmed() ); } else { println!("{}: {}", " - Max Backups".dimmed(), config.max_backups); } crate::command::wait_for_enter("start backup", yes)?; let db = database::init(&config.database).await?; database::utils::backup(&db, &config.backups_folder).await?; // Early return if backup pruning disabled if config.max_backups == 0 { return Ok(()); } // Know that new backup was taken successfully at this point, // safe to prune old backup folders prune_inner().await } async fn restore( restore_folder: Option<&Path>, index: bool, yes: bool, ) -> anyhow::Result<()> { let config = cli_config(); println!( "\n🦎 {} Database {} Utility 🦎", "Komodo".bold(), "Restore".purple().bold() ); println!( "\n{}\n", " - Restores database contents from gzip compressed files." .dimmed() ); if let Some(uri) = optional_string(&config.database_target.uri) { println!("{}: {}", " - Target URI".dimmed(), sanitize_uri(&uri)); } if let Some(address) = optional_string(&config.database_target.address) { println!("{}: {address}", " - Target Address".dimmed()); } if let Some(username) = optional_string(&config.database_target.username) { println!("{}: {username}", " - Target Username".dimmed()); } println!( "{}: {}", " - Target Db Name".dimmed(), config.database_target.db_name, ); if !index { println!( "{}: {}", " - Target Db Indexing".dimmed(), "DISABLED".red(), ); } println!( "\n{}: {:?}", " - Backups Folder".dimmed(), config.backups_folder ); if let Some(restore_folder) = restore_folder { println!("{}: {restore_folder:?}", " - Restore Folder".dimmed()); } crate::command::wait_for_enter("start restore", yes)?; let db = if index { database::Client::new(&config.database_target).await?.db } else { database::init(&config.database_target).await? }; database::utils::restore( &db, &config.backups_folder, restore_folder, ) .await } async fn prune(yes: bool) -> anyhow::Result<()> { let config = cli_config(); println!( "\n🦎 {} Database {} Utility 🦎", "Komodo".bold(), "Backup Prune".cyan().bold() ); println!( "\n{}\n", " - Prunes database backup folders when greater than the configured amount." .dimmed() ); println!( "{}: {:?}", " - Backups Folder".dimmed(), config.backups_folder ); if config.max_backups == 0 { println!( "{}{}", " - Backup pruning".dimmed(), "disabled".red().dimmed() ); } else { println!("{}: {}", " - Max Backups".dimmed(), config.max_backups); } // Early return if backup pruning disabled if config.max_backups == 0 { info!( "Backup pruning is disabled, enabled using 'max_backups' (KOMODO_CLI_MAX_BACKUPS)" ); return Ok(()); } crate::command::wait_for_enter("start backup prune", yes)?; prune_inner().await } async fn prune_inner() -> anyhow::Result<()> { let config = cli_config(); let mut backups_dir = match tokio::fs::read_dir(&config.backups_folder) .await .context("Failed to read backups folder for prune") { Ok(backups_dir) => backups_dir, Err(e) => { warn!("{e:#}"); return Ok(()); } }; let mut backup_folders = Vec::new(); loop { match backups_dir.next_entry().await { Ok(Some(entry)) => { let Ok(metadata) = entry.metadata().await else { continue; }; if metadata.is_dir() { backup_folders.push(entry.path()); } } Ok(None) => break, Err(_) => { continue; } } } // Ordered from oldest -> newest backup_folders.sort(); let max_backups = config.max_backups as usize; let backup_folders_len = backup_folders.len(); // Early return if under the backup count threshold if backup_folders_len <= max_backups { info!("No backups to prune"); return Ok(()); } let to_delete = &backup_folders[..(backup_folders_len - max_backups)]; info!("Pruning old backups: {to_delete:?}"); for path in to_delete { if let Err(e) = tokio::fs::remove_dir_all(path).await.with_context(|| { format!("Failed to delete backup folder at {path:?}") }) { warn!("{e:#}"); } } Ok(()) } async fn copy(index: bool, yes: bool) -> anyhow::Result<()> { let config = cli_config(); println!( "\n🦎 {} Database {} Utility 🦎", "Komodo".bold(), "Copy".blue().bold() ); println!( "\n{}\n", " - Copies database contents to another database.".dimmed() ); if let Some(uri) = optional_string(&config.database.uri) { println!("{}: {}", " - Source URI".dimmed(), sanitize_uri(&uri)); } if let Some(address) = optional_string(&config.database.address) { println!("{}: {address}", " - Source Address".dimmed()); } if let Some(username) = optional_string(&config.database.username) { println!("{}: {username}", " - Source Username".dimmed()); } println!( "{}: {}\n", " - Source Db Name".dimmed(), config.database.db_name, ); if let Some(uri) = optional_string(&config.database_target.uri) { println!("{}: {}", " - Target URI".dimmed(), sanitize_uri(&uri)); } if let Some(address) = optional_string(&config.database_target.address) { println!("{}: {address}", " - Target Address".dimmed()); } if let Some(username) = optional_string(&config.database_target.username) { println!("{}: {username}", " - Target Username".dimmed()); } println!( "{}: {}", " - Target Db Name".dimmed(), config.database_target.db_name, ); if !index { println!( "{}: {}", " - Target Db Indexing".dimmed(), "DISABLED".red(), ); } crate::command::wait_for_enter("start copy", yes)?; let source_db = database::init(&config.database).await?; let target_db = if index { database::Client::new(&config.database_target).await?.db } else { database::init(&config.database_target).await? }; database::utils::copy(&source_db, &target_db).await } ================================================ FILE: bin/cli/src/command/execute.rs ================================================ use std::time::Duration; use colored::Colorize; use futures_util::{StreamExt, stream::FuturesUnordered}; use komodo_client::{ api::execute::{ BatchExecutionResponse, BatchExecutionResponseItem, Execution, }, entities::{resource_link, update::Update}, }; use crate::config::cli_config; enum ExecutionResult { Single(Box), Batch(BatchExecutionResponse), } pub async fn handle( execution: &Execution, yes: bool, ) -> anyhow::Result<()> { if matches!(execution, Execution::None(_)) { println!("Got 'none' execution. Doing nothing..."); tokio::time::sleep(Duration::from_secs(3)).await; println!("Finished doing nothing. Exiting..."); std::process::exit(0); } println!("\n{}: Execution", "Mode".dimmed()); match execution { Execution::None(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::RunAction(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::BatchRunAction(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::RunProcedure(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::BatchRunProcedure(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::RunBuild(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::BatchRunBuild(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::CancelBuild(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::Deploy(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::BatchDeploy(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::PullDeployment(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::StartDeployment(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::RestartDeployment(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::PauseDeployment(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::UnpauseDeployment(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::StopDeployment(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::DestroyDeployment(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::BatchDestroyDeployment(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::CloneRepo(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::BatchCloneRepo(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::PullRepo(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::BatchPullRepo(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::BuildRepo(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::BatchBuildRepo(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::CancelRepoBuild(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::StartContainer(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::RestartContainer(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::PauseContainer(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::UnpauseContainer(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::StopContainer(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::DestroyContainer(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::StartAllContainers(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::RestartAllContainers(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::PauseAllContainers(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::UnpauseAllContainers(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::StopAllContainers(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::PruneContainers(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::DeleteNetwork(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::PruneNetworks(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::DeleteImage(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::PruneImages(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::DeleteVolume(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::PruneVolumes(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::PruneDockerBuilders(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::PruneBuildx(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::PruneSystem(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::RunSync(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::CommitSync(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::DeployStack(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::BatchDeployStack(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::DeployStackIfChanged(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::BatchDeployStackIfChanged(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::PullStack(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::BatchPullStack(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::StartStack(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::RestartStack(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::PauseStack(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::UnpauseStack(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::StopStack(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::DestroyStack(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::BatchDestroyStack(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::RunStackService(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::TestAlerter(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::SendAlert(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::ClearRepoCache(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::BackupCoreDatabase(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::GlobalAutoUpdate(data) => { println!("{}: {data:?}", "Data".dimmed()) } Execution::Sleep(data) => { println!("{}: {data:?}", "Data".dimmed()) } } super::wait_for_enter("run execution", yes)?; info!("Running Execution..."); let client = super::komodo_client().await?; let res = match execution.clone() { Execution::RunAction(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::BatchRunAction(request) => { client.execute(request).await.map(ExecutionResult::Batch) } Execution::RunProcedure(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::BatchRunProcedure(request) => { client.execute(request).await.map(ExecutionResult::Batch) } Execution::RunBuild(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::BatchRunBuild(request) => { client.execute(request).await.map(ExecutionResult::Batch) } Execution::CancelBuild(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::Deploy(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::BatchDeploy(request) => { client.execute(request).await.map(ExecutionResult::Batch) } Execution::PullDeployment(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::StartDeployment(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::RestartDeployment(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::PauseDeployment(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::UnpauseDeployment(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::StopDeployment(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::DestroyDeployment(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::BatchDestroyDeployment(request) => { client.execute(request).await.map(ExecutionResult::Batch) } Execution::CloneRepo(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::BatchCloneRepo(request) => { client.execute(request).await.map(ExecutionResult::Batch) } Execution::PullRepo(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::BatchPullRepo(request) => { client.execute(request).await.map(ExecutionResult::Batch) } Execution::BuildRepo(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::BatchBuildRepo(request) => { client.execute(request).await.map(ExecutionResult::Batch) } Execution::CancelRepoBuild(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::StartContainer(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::RestartContainer(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::PauseContainer(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::UnpauseContainer(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::StopContainer(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::DestroyContainer(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::StartAllContainers(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::RestartAllContainers(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::PauseAllContainers(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::UnpauseAllContainers(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::StopAllContainers(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::PruneContainers(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::DeleteNetwork(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::PruneNetworks(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::DeleteImage(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::PruneImages(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::DeleteVolume(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::PruneVolumes(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::PruneDockerBuilders(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::PruneBuildx(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::PruneSystem(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::RunSync(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::CommitSync(request) => client .write(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::DeployStack(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::BatchDeployStack(request) => { client.execute(request).await.map(ExecutionResult::Batch) } Execution::DeployStackIfChanged(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::BatchDeployStackIfChanged(request) => { client.execute(request).await.map(ExecutionResult::Batch) } Execution::PullStack(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::BatchPullStack(request) => { client.execute(request).await.map(ExecutionResult::Batch) } Execution::StartStack(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::RestartStack(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::PauseStack(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::UnpauseStack(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::StopStack(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::DestroyStack(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::BatchDestroyStack(request) => { client.execute(request).await.map(ExecutionResult::Batch) } Execution::RunStackService(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::TestAlerter(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::SendAlert(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::ClearRepoCache(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::BackupCoreDatabase(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::GlobalAutoUpdate(request) => client .execute(request) .await .map(|u| ExecutionResult::Single(u.into())), Execution::Sleep(request) => { let duration = Duration::from_millis(request.duration_ms as u64); tokio::time::sleep(duration).await; println!("Finished sleeping!"); std::process::exit(0) } Execution::None(_) => unreachable!(), }; match res { Ok(ExecutionResult::Single(update)) => { poll_update_until_complete(&update).await } Ok(ExecutionResult::Batch(updates)) => { let mut handles = updates .iter() .map(|update| async move { match update { BatchExecutionResponseItem::Ok(update) => { poll_update_until_complete(update).await } BatchExecutionResponseItem::Err(e) => { error!("{e:#?}"); Ok(()) } } }) .collect::>(); while let Some(res) = handles.next().await { match res { Ok(()) => {} Err(e) => { error!("{e:#?}"); } } } Ok(()) } Err(e) => { error!("{e:#?}"); Ok(()) } } } async fn poll_update_until_complete( update: &Update, ) -> anyhow::Result<()> { let link = if update.id.is_empty() { let (resource_type, id) = update.target.extract_variant_id(); resource_link(&cli_config().host, resource_type, id) } else { format!("{}/updates/{}", cli_config().host, update.id) }; println!("Link: '{}'", link.bold()); let client = super::komodo_client().await?; let timer = tokio::time::Instant::now(); let update = client.poll_update_until_complete(&update.id).await?; if update.success { println!( "FINISHED in {}: {}", format!("{:.1?}", timer.elapsed()).bold(), "EXECUTION SUCCESSFUL".green(), ); } else { eprintln!( "FINISHED in {}: {}", format!("{:.1?}", timer.elapsed()).bold(), "EXECUTION FAILED".red(), ); } Ok(()) } ================================================ FILE: bin/cli/src/command/list.rs ================================================ use std::{cmp::Ordering, collections::HashMap}; use comfy_table::{Attribute, Cell, Color}; use futures_util::{FutureExt, try_join}; use komodo_client::{ KomodoClient, api::read::{ ListActions, ListAlerters, ListBuilders, ListBuilds, ListDeployments, ListProcedures, ListRepos, ListResourceSyncs, ListSchedules, ListServers, ListStacks, ListTags, }, entities::{ ResourceTargetVariant, action::{ActionListItem, ActionListItemInfo, ActionState}, alerter::{AlerterListItem, AlerterListItemInfo}, build::{BuildListItem, BuildListItemInfo, BuildState}, builder::{BuilderListItem, BuilderListItemInfo}, config::cli::args::{ self, list::{ListCommand, ResourceFilters}, }, deployment::{ DeploymentListItem, DeploymentListItemInfo, DeploymentState, }, procedure::{ ProcedureListItem, ProcedureListItemInfo, ProcedureState, }, repo::{RepoListItem, RepoListItemInfo, RepoState}, resource::{ResourceListItem, ResourceQuery}, resource_link, schedule::Schedule, server::{ServerListItem, ServerListItemInfo, ServerState}, stack::{StackListItem, StackListItemInfo, StackState}, sync::{ ResourceSyncListItem, ResourceSyncListItemInfo, ResourceSyncState, }, }, }; use serde::Serialize; use crate::{ command::{ PrintTable, format_timetamp, matches_wildcards, parse_wildcards, print_items, }, config::cli_config, }; pub async fn handle(list: &args::list::List) -> anyhow::Result<()> { match &list.command { None => list_all(list).await, Some(ListCommand::Servers(filters)) => { list_resources::(filters, false).await } Some(ListCommand::Stacks(filters)) => { list_resources::(filters, false).await } Some(ListCommand::Deployments(filters)) => { list_resources::(filters, false).await } Some(ListCommand::Builds(filters)) => { list_resources::(filters, false).await } Some(ListCommand::Repos(filters)) => { list_resources::(filters, false).await } Some(ListCommand::Procedures(filters)) => { list_resources::(filters, false).await } Some(ListCommand::Actions(filters)) => { list_resources::(filters, false).await } Some(ListCommand::Syncs(filters)) => { list_resources::(filters, false).await } Some(ListCommand::Builders(filters)) => { list_resources::(filters, false).await } Some(ListCommand::Alerters(filters)) => { list_resources::(filters, false).await } Some(ListCommand::Schedules(filters)) => { list_schedules(filters).await } } } /// Includes all resources besides builds and alerters. async fn list_all(list: &args::list::List) -> anyhow::Result<()> { let filters: ResourceFilters = list.clone().into(); let client = super::komodo_client().await?; let ( tags, mut servers, mut stacks, mut deployments, mut builds, mut repos, mut procedures, mut actions, mut syncs, ) = try_join!( client.read(ListTags::default()).map(|res| res.map(|res| res .into_iter() .map(|t| (t.id, t.name)) .collect::>())), ServerListItem::list(client, &filters, true), StackListItem::list(client, &filters, true), DeploymentListItem::list(client, &filters, true), BuildListItem::list(client, &filters, true), RepoListItem::list(client, &filters, true), ProcedureListItem::list(client, &filters, true), ActionListItem::list(client, &filters, true), ResourceSyncListItem::list(client, &filters, true), )?; if !servers.is_empty() { fix_tags(&mut servers, &tags); print_items(servers, filters.format, list.links)?; println!(); } if !stacks.is_empty() { fix_tags(&mut stacks, &tags); print_items(stacks, filters.format, list.links)?; println!(); } if !deployments.is_empty() { fix_tags(&mut deployments, &tags); print_items(deployments, filters.format, list.links)?; println!(); } if !builds.is_empty() { fix_tags(&mut builds, &tags); print_items(builds, filters.format, list.links)?; println!(); } if !repos.is_empty() { fix_tags(&mut repos, &tags); print_items(repos, filters.format, list.links)?; println!(); } if !procedures.is_empty() { fix_tags(&mut procedures, &tags); print_items(procedures, filters.format, list.links)?; println!(); } if !actions.is_empty() { fix_tags(&mut actions, &tags); print_items(actions, filters.format, list.links)?; println!(); } if !syncs.is_empty() { fix_tags(&mut syncs, &tags); print_items(syncs, filters.format, list.links)?; println!(); } Ok(()) } async fn list_resources( filters: &ResourceFilters, minimal: bool, ) -> anyhow::Result<()> where T: ListResources, ResourceListItem: PrintTable + Serialize, { let client = crate::command::komodo_client().await?; let (mut resources, tags) = tokio::try_join!( T::list(client, filters, minimal), client.read(ListTags::default()).map(|res| res.map(|res| res .into_iter() .map(|t| (t.id, t.name)) .collect::>())) )?; fix_tags(&mut resources, &tags); if !resources.is_empty() { print_items(resources, filters.format, filters.links)?; } Ok(()) } async fn list_schedules( filters: &ResourceFilters, ) -> anyhow::Result<()> { let client = crate::command::komodo_client().await?; let (mut schedules, tags) = tokio::try_join!( client .read(ListSchedules { tags: filters.tags.clone(), tag_behavior: Default::default(), }) .map(|res| res.map(|res| res .into_iter() .filter(|s| s.next_scheduled_run.is_some()) .collect::>())), client.read(ListTags::default()).map(|res| res.map(|res| res .into_iter() .map(|t| (t.id, t.name)) .collect::>())) )?; schedules.iter_mut().for_each(|resource| { resource.tags.iter_mut().for_each(|id| { let Some(name) = tags.get(id) else { *id = String::new(); return; }; id.clone_from(name); }); }); schedules.sort_by(|a, b| { match (a.next_scheduled_run, b.next_scheduled_run) { (Some(_), None) => return Ordering::Less, (None, Some(_)) => return Ordering::Greater, (Some(a), Some(b)) => return a.cmp(&b), (None, None) => {} } a.name.cmp(&b.name).then(a.enabled.cmp(&b.enabled)) }); if !schedules.is_empty() { print_items(schedules, filters.format, filters.links)?; } Ok(()) } fn fix_tags( resources: &mut [ResourceListItem], tags: &HashMap, ) { resources.iter_mut().for_each(|resource| { resource.tags.iter_mut().for_each(|id| { let Some(name) = tags.get(id) else { *id = String::new(); return; }; id.clone_from(name); }); }); } trait ListResources: Sized where ResourceListItem: PrintTable, { type Info; async fn list( client: &KomodoClient, filters: &ResourceFilters, // For use with root `km ls` minimal: bool, ) -> anyhow::Result>>; } // LIST impl ListResources for ServerListItem { type Info = ServerListItemInfo; async fn list( client: &KomodoClient, filters: &ResourceFilters, _minimal: bool, ) -> anyhow::Result> { let servers = client .read(ListServers { query: ResourceQuery::builder() .tags(filters.tags.clone()) // .tag_behavior(TagQueryBehavior::Any) .templates(filters.templates) .build(), }) .await?; let names = parse_wildcards(&filters.names); let server_wildcards = parse_wildcards(&filters.servers); let mut servers = servers .into_iter() .filter(|server| { let state_check = if filters.all { true } else if filters.down { !matches!(server.info.state, ServerState::Ok) } else if filters.in_progress { false } else { matches!(server.info.state, ServerState::Ok) }; let name_items = &[server.name.as_str()]; state_check && matches_wildcards(&names, name_items) && matches_wildcards(&server_wildcards, name_items) }) .collect::>(); servers.sort_by(|a, b| { a.info.state.cmp(&b.info.state).then(a.name.cmp(&b.name)) }); Ok(servers) } } impl ListResources for StackListItem { type Info = StackListItemInfo; async fn list( client: &KomodoClient, filters: &ResourceFilters, _minimal: bool, ) -> anyhow::Result> { let (servers, mut stacks) = tokio::try_join!( client .read(ListServers { query: ResourceQuery::builder().build(), }) .map(|res| res.map(|res| res .into_iter() .map(|s| (s.id.clone(), s)) .collect::>())), client.read(ListStacks { query: ResourceQuery::builder() .tags(filters.tags.clone()) // .tag_behavior(TagQueryBehavior::Any) .templates(filters.templates) .build(), }) )?; stacks.iter_mut().for_each(|stack| { if stack.info.server_id.is_empty() { return; } let Some(server) = servers.get(&stack.info.server_id) else { return; }; stack.info.server_id.clone_from(&server.name); }); let names = parse_wildcards(&filters.names); let servers = parse_wildcards(&filters.servers); let mut stacks = stacks .into_iter() .filter(|stack| { let state_check = if filters.all { true } else if filters.down { !matches!( stack.info.state, StackState::Running | StackState::Deploying ) } else if filters.in_progress { matches!(stack.info.state, StackState::Deploying) } else { matches!( stack.info.state, StackState::Running | StackState::Deploying ) }; state_check && matches_wildcards(&names, &[stack.name.as_str()]) && matches_wildcards( &servers, &[stack.info.server_id.as_str()], ) }) .collect::>(); stacks.sort_by(|a, b| { a.info .state .cmp(&b.info.state) .then(a.name.cmp(&b.name)) .then(a.info.server_id.cmp(&b.info.server_id)) }); Ok(stacks) } } impl ListResources for DeploymentListItem { type Info = DeploymentListItemInfo; async fn list( client: &KomodoClient, filters: &ResourceFilters, _minimal: bool, ) -> anyhow::Result> { let (servers, mut deployments) = tokio::try_join!( client .read(ListServers { query: ResourceQuery::builder().build(), }) .map(|res| res.map(|res| res .into_iter() .map(|s| (s.id.clone(), s)) .collect::>())), client.read(ListDeployments { query: ResourceQuery::builder() .tags(filters.tags.clone()) // .tag_behavior(TagQueryBehavior::Any) .templates(filters.templates) .build(), }) )?; deployments.iter_mut().for_each(|deployment| { if deployment.info.server_id.is_empty() { return; } let Some(server) = servers.get(&deployment.info.server_id) else { return; }; deployment.info.server_id.clone_from(&server.name); }); let names = parse_wildcards(&filters.names); let servers = parse_wildcards(&filters.servers); let mut deployments = deployments .into_iter() .filter(|deployment| { let state_check = if filters.all { true } else if filters.down { !matches!( deployment.info.state, DeploymentState::Running | DeploymentState::Deploying ) } else if filters.in_progress { matches!(deployment.info.state, DeploymentState::Deploying) } else { matches!( deployment.info.state, DeploymentState::Running | DeploymentState::Deploying ) }; state_check && matches_wildcards(&names, &[deployment.name.as_str()]) && matches_wildcards( &servers, &[deployment.info.server_id.as_str()], ) }) .collect::>(); deployments.sort_by(|a, b| { a.info .state .cmp(&b.info.state) .then(a.name.cmp(&b.name)) .then(a.info.server_id.cmp(&b.info.server_id)) }); Ok(deployments) } } impl ListResources for BuildListItem { type Info = BuildListItemInfo; async fn list( client: &KomodoClient, filters: &ResourceFilters, minimal: bool, ) -> anyhow::Result> { let (builders, mut builds) = tokio::try_join!( client .read(ListBuilders { query: ResourceQuery::builder().build(), }) .map(|res| res.map(|res| res .into_iter() .map(|s| (s.id.clone(), s)) .collect::>())), client.read(ListBuilds { query: ResourceQuery::builder() .tags(filters.tags.clone()) // .tag_behavior(TagQueryBehavior::Any) .templates(filters.templates) .build(), }) )?; builds.iter_mut().for_each(|build| { if build.info.builder_id.is_empty() { return; } let Some(builder) = builders.get(&build.info.builder_id) else { return; }; build.info.builder_id.clone_from(&builder.name); }); let names = parse_wildcards(&filters.names); let builders = parse_wildcards(&filters.builders); let mut builds = builds .into_iter() .filter(|build| { let state_check = if filters.all { true } else if filters.down { matches!( build.info.state, BuildState::Failed | BuildState::Unknown ) } else if minimal || filters.in_progress { matches!(build.info.state, BuildState::Building) } else { true }; state_check && matches_wildcards(&names, &[build.name.as_str()]) && matches_wildcards( &builders, &[build.info.builder_id.as_str()], ) }) .collect::>(); builds.sort_by(|a, b| { a.name .cmp(&b.name) .then(a.info.builder_id.cmp(&b.info.builder_id)) .then(a.info.state.cmp(&b.info.state)) }); Ok(builds) } } impl ListResources for RepoListItem { type Info = RepoListItemInfo; async fn list( client: &KomodoClient, filters: &ResourceFilters, minimal: bool, ) -> anyhow::Result> { let names = parse_wildcards(&filters.names); let mut repos = client .read(ListRepos { query: ResourceQuery::builder() .tags(filters.tags.clone()) // .tag_behavior(TagQueryBehavior::Any) .templates(filters.templates) .build(), }) .await? .into_iter() .filter(|repo| { let state_check = if filters.all { true } else if filters.down { matches!( repo.info.state, RepoState::Failed | RepoState::Unknown ) } else if minimal || filters.in_progress { matches!( repo.info.state, RepoState::Building | RepoState::Cloning ) } else { true }; state_check && matches_wildcards(&names, &[repo.name.as_str()]) }) .collect::>(); repos.sort_by(|a, b| { a.name .cmp(&b.name) .then(a.info.server_id.cmp(&b.info.server_id)) .then(a.info.builder_id.cmp(&b.info.builder_id)) }); Ok(repos) } } impl ListResources for ProcedureListItem { type Info = ProcedureListItemInfo; async fn list( client: &KomodoClient, filters: &ResourceFilters, minimal: bool, ) -> anyhow::Result> { let names = parse_wildcards(&filters.names); let mut procedures = client .read(ListProcedures { query: ResourceQuery::builder() .tags(filters.tags.clone()) // .tag_behavior(TagQueryBehavior::Any) .templates(filters.templates) .build(), }) .await? .into_iter() .filter(|procedure| { let state_check = if filters.all { true } else if filters.down { matches!( procedure.info.state, ProcedureState::Failed | ProcedureState::Unknown ) } else if minimal || filters.in_progress { matches!(procedure.info.state, ProcedureState::Running) } else { true }; state_check && matches_wildcards(&names, &[procedure.name.as_str()]) }) .collect::>(); procedures.sort_by(|a, b| { match (a.info.next_scheduled_run, b.info.next_scheduled_run) { (Some(_), None) => return Ordering::Less, (None, Some(_)) => return Ordering::Greater, (Some(a), Some(b)) => return a.cmp(&b), (None, None) => {} } a.name.cmp(&b.name).then(a.info.state.cmp(&b.info.state)) }); Ok(procedures) } } impl ListResources for ActionListItem { type Info = ActionListItemInfo; async fn list( client: &KomodoClient, filters: &ResourceFilters, minimal: bool, ) -> anyhow::Result> { let names = parse_wildcards(&filters.names); let mut actions = client .read(ListActions { query: ResourceQuery::builder() .tags(filters.tags.clone()) // .tag_behavior(TagQueryBehavior::Any) .templates(filters.templates) .build(), }) .await? .into_iter() .filter(|action| { let state_check = if filters.all { true } else if filters.down { matches!( action.info.state, ActionState::Failed | ActionState::Unknown ) } else if minimal || filters.in_progress { matches!(action.info.state, ActionState::Running) } else { true }; state_check && matches_wildcards(&names, &[action.name.as_str()]) }) .collect::>(); actions.sort_by(|a, b| { match (a.info.next_scheduled_run, b.info.next_scheduled_run) { (Some(_), None) => return Ordering::Less, (None, Some(_)) => return Ordering::Greater, (Some(a), Some(b)) => return a.cmp(&b), (None, None) => {} } a.name.cmp(&b.name).then(a.info.state.cmp(&b.info.state)) }); Ok(actions) } } impl ListResources for ResourceSyncListItem { type Info = ResourceSyncListItemInfo; async fn list( client: &KomodoClient, filters: &ResourceFilters, minimal: bool, ) -> anyhow::Result> { let names = parse_wildcards(&filters.names); let mut syncs = client .read(ListResourceSyncs { query: ResourceQuery::builder() .tags(filters.tags.clone()) // .tag_behavior(TagQueryBehavior::Any) .templates(filters.templates) .build(), }) .await? .into_iter() .filter(|sync| { let state_check = if filters.all { true } else if filters.down { matches!( sync.info.state, ResourceSyncState::Failed | ResourceSyncState::Unknown ) } else if minimal || filters.in_progress { matches!( sync.info.state, ResourceSyncState::Syncing | ResourceSyncState::Pending ) } else { true }; state_check && matches_wildcards(&names, &[sync.name.as_str()]) }) .collect::>(); syncs.sort_by(|a, b| { a.name.cmp(&b.name).then(a.info.state.cmp(&b.info.state)) }); Ok(syncs) } } impl ListResources for BuilderListItem { type Info = BuilderListItemInfo; async fn list( client: &KomodoClient, filters: &ResourceFilters, minimal: bool, ) -> anyhow::Result> { let names = parse_wildcards(&filters.names); let mut builders = client .read(ListBuilders { query: ResourceQuery::builder() .tags(filters.tags.clone()) // .tag_behavior(TagQueryBehavior::Any) .templates(filters.templates) .build(), }) .await? .into_iter() .filter(|builder| { (!minimal || filters.all) && matches_wildcards(&names, &[builder.name.as_str()]) }) .collect::>(); builders.sort_by(|a, b| { a.name .cmp(&b.name) .then(a.info.builder_type.cmp(&b.info.builder_type)) }); Ok(builders) } } impl ListResources for AlerterListItem { type Info = AlerterListItemInfo; async fn list( client: &KomodoClient, filters: &ResourceFilters, minimal: bool, ) -> anyhow::Result> { let names = parse_wildcards(&filters.names); let mut syncs = client .read(ListAlerters { query: ResourceQuery::builder() .tags(filters.tags.clone()) // .tag_behavior(TagQueryBehavior::Any) .templates(filters.templates) .build(), }) .await? .into_iter() .filter(|sync| { (!minimal || filters.all) && matches_wildcards(&names, &[sync.name.as_str()]) }) .collect::>(); syncs.sort_by(|a, b| { a.info .enabled .cmp(&b.info.enabled) .then(a.name.cmp(&b.name)) .then(a.info.endpoint_type.cmp(&b.info.endpoint_type)) }); Ok(syncs) } } // TABLE impl PrintTable for ResourceListItem { fn header(links: bool) -> &'static [&'static str] { if links { &["Server", "State", "Address", "Tags", "Link"] } else { &["Server", "State", "Address", "Tags"] } } fn row(self, links: bool) -> Vec { let color = match self.info.state { ServerState::Ok => Color::Green, ServerState::NotOk => Color::Red, ServerState::Disabled => Color::Blue, }; let mut res = vec![ Cell::new(self.name).add_attribute(Attribute::Bold), Cell::new(self.info.state.to_string()) .fg(color) .add_attribute(Attribute::Bold), Cell::new(self.info.address), Cell::new(self.tags.join(", ")), ]; if links { res.push(Cell::new(resource_link( &cli_config().host, ResourceTargetVariant::Server, &self.id, ))) } res } } impl PrintTable for ResourceListItem { fn header(links: bool) -> &'static [&'static str] { if links { &["Stack", "State", "Server", "Tags", "Link"] } else { &["Stack", "State", "Server", "Tags"] } } fn row(self, links: bool) -> Vec { let color = match self.info.state { StackState::Down => Color::Blue, StackState::Running => Color::Green, StackState::Deploying => Color::DarkYellow, StackState::Paused => Color::DarkYellow, StackState::Unknown => Color::Magenta, _ => Color::Red, }; // let source = if self.info.files_on_host { // "On Host" // } else if !self.info.repo.is_empty() { // self.info.repo_link.as_str() // } else { // "UI Defined" // }; let mut res = vec![ Cell::new(self.name).add_attribute(Attribute::Bold), Cell::new(self.info.state.to_string()) .fg(color) .add_attribute(Attribute::Bold), Cell::new(self.info.server_id), // Cell::new(source), Cell::new(self.tags.join(", ")), ]; if links { res.push(Cell::new(resource_link( &cli_config().host, ResourceTargetVariant::Stack, &self.id, ))) } res } } impl PrintTable for ResourceListItem { fn header(links: bool) -> &'static [&'static str] { if links { &["Deployment", "State", "Server", "Tags", "Link"] } else { &["Deployment", "State", "Server", "Tags"] } } fn row(self, links: bool) -> Vec { let color = match self.info.state { DeploymentState::NotDeployed => Color::Blue, DeploymentState::Running => Color::Green, DeploymentState::Deploying => Color::DarkYellow, DeploymentState::Paused => Color::DarkYellow, DeploymentState::Unknown => Color::Magenta, _ => Color::Red, }; let mut res = vec![ Cell::new(self.name).add_attribute(Attribute::Bold), Cell::new(self.info.state.to_string()) .fg(color) .add_attribute(Attribute::Bold), Cell::new(self.info.server_id), Cell::new(self.tags.join(", ")), ]; if links { res.push(Cell::new(resource_link( &cli_config().host, ResourceTargetVariant::Deployment, &self.id, ))) } res } } impl PrintTable for ResourceListItem { fn header(links: bool) -> &'static [&'static str] { if links { &["Build", "State", "Builder", "Tags", "Link"] } else { &["Build", "State", "Builder", "Tags"] } } fn row(self, links: bool) -> Vec { let color = match self.info.state { BuildState::Ok => Color::Green, BuildState::Building => Color::DarkYellow, BuildState::Unknown => Color::Magenta, BuildState::Failed => Color::Red, }; let mut res = vec![ Cell::new(self.name).add_attribute(Attribute::Bold), Cell::new(self.info.state.to_string()) .fg(color) .add_attribute(Attribute::Bold), Cell::new(self.info.builder_id), Cell::new(self.tags.join(", ")), ]; if links { res.push(Cell::new(resource_link( &cli_config().host, ResourceTargetVariant::Build, &self.id, ))); } res } } impl PrintTable for ResourceListItem { fn header(links: bool) -> &'static [&'static str] { if links { &["Repo", "State", "Link", "Tags", "Link"] } else { &["Repo", "State", "Link", "Tags"] } } fn row(self, links: bool) -> Vec { let color = match self.info.state { RepoState::Ok => Color::Green, RepoState::Building | RepoState::Cloning | RepoState::Pulling => Color::DarkYellow, RepoState::Unknown => Color::Magenta, RepoState::Failed => Color::Red, }; let mut res = vec![ Cell::new(self.name).add_attribute(Attribute::Bold), Cell::new(self.info.state.to_string()) .fg(color) .add_attribute(Attribute::Bold), Cell::new(self.info.repo_link), Cell::new(self.tags.join(", ")), ]; if links { res.push(Cell::new(resource_link( &cli_config().host, ResourceTargetVariant::Repo, &self.id, ))) } res } } impl PrintTable for ResourceListItem { fn header(links: bool) -> &'static [&'static str] { if links { &["Procedure", "State", "Next Run", "Tags", "Link"] } else { &["Procedure", "State", "Next Run", "Tags"] } } fn row(self, links: bool) -> Vec { let color = match self.info.state { ProcedureState::Ok => Color::Green, ProcedureState::Running => Color::DarkYellow, ProcedureState::Unknown => Color::Magenta, ProcedureState::Failed => Color::Red, }; let next_run = if let Some(ts) = self.info.next_scheduled_run { Cell::new( format_timetamp(ts) .unwrap_or(String::from("Invalid next ts")), ) .add_attribute(Attribute::Bold) } else { Cell::new(String::from("None")) }; let mut res = vec![ Cell::new(self.name).add_attribute(Attribute::Bold), Cell::new(self.info.state.to_string()) .fg(color) .add_attribute(Attribute::Bold), next_run, Cell::new(self.tags.join(", ")), ]; if links { res.push(Cell::new(resource_link( &cli_config().host, ResourceTargetVariant::Procedure, &self.id, ))) } res } } impl PrintTable for ResourceListItem { fn header(links: bool) -> &'static [&'static str] { if links { &["Action", "State", "Next Run", "Tags", "Link"] } else { &["Action", "State", "Next Run", "Tags"] } } fn row(self, links: bool) -> Vec { let color = match self.info.state { ActionState::Ok => Color::Green, ActionState::Running => Color::DarkYellow, ActionState::Unknown => Color::Magenta, ActionState::Failed => Color::Red, }; let next_run = if let Some(ts) = self.info.next_scheduled_run { Cell::new( format_timetamp(ts) .unwrap_or(String::from("Invalid next ts")), ) .add_attribute(Attribute::Bold) } else { Cell::new(String::from("None")) }; let mut res = vec![ Cell::new(self.name).add_attribute(Attribute::Bold), Cell::new(self.info.state.to_string()) .fg(color) .add_attribute(Attribute::Bold), next_run, Cell::new(self.tags.join(", ")), ]; if links { res.push(Cell::new(resource_link( &cli_config().host, ResourceTargetVariant::Action, &self.id, ))); } res } } impl PrintTable for ResourceListItem { fn header(links: bool) -> &'static [&'static str] { if links { &["Sync", "State", "Tags", "Link"] } else { &["Sync", "State", "Tags"] } } fn row(self, links: bool) -> Vec { let color = match self.info.state { ResourceSyncState::Ok => Color::Green, ResourceSyncState::Pending | ResourceSyncState::Syncing => { Color::DarkYellow } ResourceSyncState::Unknown => Color::Magenta, ResourceSyncState::Failed => Color::Red, }; let mut res = vec![ Cell::new(self.name).add_attribute(Attribute::Bold), Cell::new(self.info.state.to_string()) .fg(color) .add_attribute(Attribute::Bold), Cell::new(self.tags.join(", ")), ]; if links { res.push(Cell::new(resource_link( &cli_config().host, ResourceTargetVariant::ResourceSync, &self.id, ))) } res } } impl PrintTable for ResourceListItem { fn header(links: bool) -> &'static [&'static str] { if links { &["Builder", "Type", "Tags", "Link"] } else { &["Builder", "Type", "Tags"] } } fn row(self, links: bool) -> Vec { let mut res = vec![ Cell::new(self.name).add_attribute(Attribute::Bold), Cell::new(self.info.builder_type), Cell::new(self.tags.join(", ")), ]; if links { res.push(Cell::new(resource_link( &cli_config().host, ResourceTargetVariant::Builder, &self.id, ))); } res } } impl PrintTable for ResourceListItem { fn header(links: bool) -> &'static [&'static str] { if links { &["Alerter", "Type", "Enabled", "Tags", "Link"] } else { &["Alerter", "Type", "Enabled", "Tags"] } } fn row(self, links: bool) -> Vec { let mut row = vec![ Cell::new(self.name).add_attribute(Attribute::Bold), Cell::new(self.info.endpoint_type), if self.info.enabled { Cell::new(self.info.enabled.to_string()).fg(Color::Green) } else { Cell::new(self.info.enabled.to_string()).fg(Color::Red) }, Cell::new(self.tags.join(", ")), ]; if links { row.push(Cell::new(resource_link( &cli_config().host, ResourceTargetVariant::Alerter, &self.id, ))); } row } } impl PrintTable for Schedule { fn header(links: bool) -> &'static [&'static str] { if links { &["Name", "Type", "Next Run", "Tags", "Link"] } else { &["Name", "Type", "Next Run", "Tags"] } } fn row(self, links: bool) -> Vec { let next_run = if let Some(ts) = self.next_scheduled_run { Cell::new( format_timetamp(ts) .unwrap_or(String::from("Invalid next ts")), ) .add_attribute(Attribute::Bold) } else { Cell::new(String::from("None")) }; let (resource_type, id) = self.target.extract_variant_id(); let mut res = vec![ Cell::new(self.name).add_attribute(Attribute::Bold), Cell::new(self.target.extract_variant_id().0), next_run, Cell::new(self.tags.join(", ")), ]; if links { res.push(Cell::new(resource_link( &cli_config().host, resource_type, id, ))); } res } } ================================================ FILE: bin/cli/src/command/mod.rs ================================================ use std::io::Read; use anyhow::{Context, anyhow}; use chrono::TimeZone; use colored::Colorize; use comfy_table::{Attribute, Cell, Table}; use komodo_client::{ KomodoClient, entities::config::cli::{CliTableBorders, args::CliFormat}, }; use serde::Serialize; use tokio::sync::OnceCell; use wildcard::Wildcard; use crate::config::cli_config; pub mod container; pub mod database; pub mod execute; pub mod list; pub mod update; async fn komodo_client() -> anyhow::Result<&'static KomodoClient> { static KOMODO_CLIENT: OnceCell = OnceCell::const_new(); KOMODO_CLIENT .get_or_try_init(|| async { let config = cli_config(); let (Some(key), Some(secret)) = (&config.cli_key, &config.cli_secret) else { return Err(anyhow!( "Must provide both cli_key and cli_secret" )); }; KomodoClient::new(&config.host, key, secret) .with_healthcheck() .await }) .await } fn wait_for_enter( press_enter_to: &str, skip: bool, ) -> anyhow::Result<()> { if skip { println!(); return Ok(()); } println!( "\nPress {} to {}\n", "ENTER".green(), press_enter_to.bold() ); let buffer = &mut [0u8]; std::io::stdin() .read_exact(buffer) .context("failed to read ENTER")?; Ok(()) } /// Sanitizes uris of the form: /// `protocol://username:password@address` fn sanitize_uri(uri: &str) -> String { // protocol: `mongodb` // credentials_address: `username:password@address` let Some((protocol, credentials_address)) = uri.split_once("://") else { // If no protocol, return as-is return uri.to_string(); }; // credentials: `username:password` let Some((credentials, address)) = credentials_address.split_once('@') else { // If no credentials, return as-is return uri.to_string(); }; match credentials.split_once(':') { Some((username, _)) => { format!("{protocol}://{username}:*****@{address}") } None => { format!("{protocol}://*****@{address}") } } } fn print_items( items: Vec, format: CliFormat, links: bool, ) -> anyhow::Result<()> { match format { CliFormat::Table => { let mut table = Table::new(); let preset = { use comfy_table::presets::*; match cli_config().table_borders { None | Some(CliTableBorders::Horizontal) => { UTF8_HORIZONTAL_ONLY } Some(CliTableBorders::Vertical) => UTF8_FULL_CONDENSED, Some(CliTableBorders::Inside) => UTF8_NO_BORDERS, Some(CliTableBorders::Outside) => UTF8_BORDERS_ONLY, Some(CliTableBorders::All) => UTF8_FULL, } }; table.load_preset(preset).set_header( T::header(links) .iter() .map(|h| Cell::new(h).add_attribute(Attribute::Bold)), ); for item in items { table.add_row(item.row(links)); } println!("{table}"); } CliFormat::Json => { println!( "{}", serde_json::to_string_pretty(&items) .context("Failed to serialize items to JSON")? ); } } Ok(()) } trait PrintTable { fn header(links: bool) -> &'static [&'static str]; fn row(self, links: bool) -> Vec; } fn parse_wildcards(items: &[String]) -> Vec> { items .iter() .flat_map(|i| { Wildcard::new(i.as_bytes()).inspect_err(|e| { warn!("Failed to parse wildcard: {i} | {e:?}") }) }) .collect::>() } fn matches_wildcards( wildcards: &[Wildcard<'_>], items: &[&str], ) -> bool { if wildcards.is_empty() { return true; } items.iter().any(|item| { wildcards.iter().any(|wc| wc.is_match(item.as_bytes())) }) } fn format_timetamp(ts: i64) -> anyhow::Result { let ts = chrono::Local .timestamp_millis_opt(ts) .single() .context("Invalid ts")? .format("%m/%d %H:%M:%S") .to_string(); Ok(ts) } fn clamp_sha(maybe_sha: &str) -> String { if maybe_sha.starts_with("sha256:") { maybe_sha[0..20].to_string() + "..." } else { maybe_sha.to_string() } } // fn text_link(link: &str, text: &str) -> String { // format!("\x1b]8;;{link}\x07{text}\x1b]8;;\x07") // } ================================================ FILE: bin/cli/src/command/update/mod.rs ================================================ use komodo_client::entities::{ build::PartialBuildConfig, config::cli::args::update::UpdateCommand, deployment::PartialDeploymentConfig, repo::PartialRepoConfig, server::PartialServerConfig, stack::PartialStackConfig, sync::PartialResourceSyncConfig, }; mod resource; mod user; mod variable; pub async fn handle(command: &UpdateCommand) -> anyhow::Result<()> { match command { UpdateCommand::Build(update) => { resource::update::(update).await } UpdateCommand::Deployment(update) => { resource::update::(update).await } UpdateCommand::Repo(update) => { resource::update::(update).await } UpdateCommand::Server(update) => { resource::update::(update).await } UpdateCommand::Stack(update) => { resource::update::(update).await } UpdateCommand::Sync(update) => { resource::update::(update).await } UpdateCommand::Variable { name, value, secret, yes, } => variable::update(name, value, *secret, *yes).await, UpdateCommand::User { username, command } => { user::update(username, command).await } } } ================================================ FILE: bin/cli/src/command/update/resource.rs ================================================ use anyhow::Context; use colored::Colorize; use komodo_client::{ api::write::{ UpdateBuild, UpdateDeployment, UpdateRepo, UpdateResourceSync, UpdateServer, UpdateStack, }, entities::{ build::PartialBuildConfig, config::cli::args::update::UpdateResource, deployment::PartialDeploymentConfig, repo::PartialRepoConfig, server::PartialServerConfig, stack::PartialStackConfig, sync::PartialResourceSyncConfig, }, }; use serde::{Serialize, de::DeserializeOwned}; pub async fn update< T: std::fmt::Debug + Serialize + DeserializeOwned + ResourceUpdate, >( UpdateResource { resource, update, yes, }: &UpdateResource, ) -> anyhow::Result<()> { println!("\n{}: Update {}\n", "Mode".dimmed(), T::resource_type()); println!(" - {}: {resource}", "Name".dimmed()); let config = serde_qs::from_str::(update) .context("Failed to deserialize config")?; match serde_json::to_string_pretty(&config) { Ok(config) => { println!(" - {}: {config}", "Update".dimmed()); } Err(_) => { println!(" - {}: {config:#?}", "Update".dimmed()); } } crate::command::wait_for_enter("update resource", *yes)?; config.apply(resource).await } pub trait ResourceUpdate { fn resource_type() -> &'static str; async fn apply(self, resource: &str) -> anyhow::Result<()>; } impl ResourceUpdate for PartialBuildConfig { fn resource_type() -> &'static str { "Build" } async fn apply(self, resource: &str) -> anyhow::Result<()> { let client = crate::command::komodo_client().await?; client .write(UpdateBuild { id: resource.to_string(), config: self, }) .await .context("Failed to update build config")?; Ok(()) } } impl ResourceUpdate for PartialDeploymentConfig { fn resource_type() -> &'static str { "Deployment" } async fn apply(self, resource: &str) -> anyhow::Result<()> { let client = crate::command::komodo_client().await?; client .write(UpdateDeployment { id: resource.to_string(), config: self, }) .await .context("Failed to update deployment config")?; Ok(()) } } impl ResourceUpdate for PartialRepoConfig { fn resource_type() -> &'static str { "Repo" } async fn apply(self, resource: &str) -> anyhow::Result<()> { let client = crate::command::komodo_client().await?; client .write(UpdateRepo { id: resource.to_string(), config: self, }) .await .context("Failed to update repo config")?; Ok(()) } } impl ResourceUpdate for PartialServerConfig { fn resource_type() -> &'static str { "Server" } async fn apply(self, resource: &str) -> anyhow::Result<()> { let client = crate::command::komodo_client().await?; client .write(UpdateServer { id: resource.to_string(), config: self, }) .await .context("Failed to update server config")?; Ok(()) } } impl ResourceUpdate for PartialStackConfig { fn resource_type() -> &'static str { "Stack" } async fn apply(self, resource: &str) -> anyhow::Result<()> { let client = crate::command::komodo_client().await?; client .write(UpdateStack { id: resource.to_string(), config: self, }) .await .context("Failed to update stack config")?; Ok(()) } } impl ResourceUpdate for PartialResourceSyncConfig { fn resource_type() -> &'static str { "Sync" } async fn apply(self, resource: &str) -> anyhow::Result<()> { let client = crate::command::komodo_client().await?; client .write(UpdateResourceSync { id: resource.to_string(), config: self, }) .await .context("Failed to update sync config")?; Ok(()) } } ================================================ FILE: bin/cli/src/command/update/user.rs ================================================ use anyhow::Context; use colored::Colorize; use database::mungos::mongodb::bson::doc; use komodo_client::entities::{ config::{ cli::args::{CliEnabled, update::UpdateUserCommand}, empty_or_redacted, }, optional_string, }; use crate::{command::sanitize_uri, config::cli_config}; pub async fn update( username: &str, command: &UpdateUserCommand, ) -> anyhow::Result<()> { match command { UpdateUserCommand::Password { password, unsanitized, yes, } => { update_password(username, password, *unsanitized, *yes).await } UpdateUserCommand::SuperAdmin { enabled, yes } => { update_super_admin(username, *enabled, *yes).await } } } async fn update_password( username: &str, password: &str, unsanitized: bool, yes: bool, ) -> anyhow::Result<()> { println!("\n{}: Update Password\n", "Mode".dimmed()); println!(" - {}: {username}", "Username".dimmed()); if unsanitized { println!(" - {}: {password}", "Password".dimmed()); } else { println!( " - {}: {}", "Password".dimmed(), empty_or_redacted(password) ); } crate::command::wait_for_enter("update password", yes)?; info!("Updating password..."); let db = database::Client::new(&cli_config().database).await?; let user = db .users .find_one(doc! { "username": username }) .await .context("Failed to query database for user")? .context("No user found with given username")?; db.set_user_password(&user, password).await?; info!("Password updated ✅"); Ok(()) } async fn update_super_admin( username: &str, super_admin: CliEnabled, yes: bool, ) -> anyhow::Result<()> { let config = cli_config(); println!("\n{}: Update Super Admin\n", "Mode".dimmed()); println!(" - {}: {username}", "Username".dimmed()); println!(" - {}: {super_admin}\n", "Super Admin".dimmed()); if let Some(uri) = optional_string(&config.database.uri) { println!("{}: {}", " - Source URI".dimmed(), sanitize_uri(&uri)); } if let Some(address) = optional_string(&config.database.address) { println!("{}: {address}", " - Source Address".dimmed()); } if let Some(username) = optional_string(&config.database.username) { println!("{}: {username}", " - Source Username".dimmed()); } println!( "{}: {}", " - Source Db Name".dimmed(), config.database.db_name, ); crate::command::wait_for_enter("update super admin", yes)?; info!("Updating super admin..."); let db = database::Client::new(&config.database).await?; // Make sure the user exists first before saying it is successful. let user = db .users .find_one(doc! { "username": username }) .await .context("Failed to query database for user")? .context("No user found with given username")?; let super_admin: bool = super_admin.into(); db.users .update_one( doc! { "username": user.username }, doc! { "$set": { "super_admin": super_admin } }, ) .await .context("Failed to update user super admin on db")?; info!("Super admin updated ✅"); Ok(()) } ================================================ FILE: bin/cli/src/command/update/variable.rs ================================================ use anyhow::Context; use colored::Colorize; use komodo_client::api::{ read::GetVariable, write::{ CreateVariable, UpdateVariableIsSecret, UpdateVariableValue, }, }; pub async fn update( name: &str, value: &str, secret: Option, yes: bool, ) -> anyhow::Result<()> { println!("\n{}: Update Variable\n", "Mode".dimmed()); println!(" - {}: {name}", "Name".dimmed()); println!(" - {}: {value}", "Value".dimmed()); if let Some(secret) = secret { println!(" - {}: {secret}", "Is Secret".dimmed()); } crate::command::wait_for_enter("update variable", yes)?; let client = crate::command::komodo_client().await?; let Ok(existing) = client .read(GetVariable { name: name.to_string(), }) .await else { // Create the variable client .write(CreateVariable { name: name.to_string(), value: value.to_string(), is_secret: secret.unwrap_or_default(), description: Default::default(), }) .await .context("Failed to create variable")?; info!("Variable created ✅"); return Ok(()); }; client .write(UpdateVariableValue { name: name.to_string(), value: value.to_string(), }) .await .context("Failed to update variable 'value'")?; info!("Variable 'value' updated ✅"); let Some(secret) = secret else { return Ok(()) }; if secret != existing.is_secret { client .write(UpdateVariableIsSecret { name: name.to_string(), is_secret: secret, }) .await .context("Failed to update variable 'is_secret'")?; info!("Variable 'is_secret' updated to {secret} ✅"); } Ok(()) } ================================================ FILE: bin/cli/src/config.rs ================================================ use std::{path::PathBuf, sync::OnceLock}; use anyhow::Context; use clap::Parser; use colored::Colorize; use environment_file::maybe_read_item_from_file; use komodo_client::entities::{ config::{ DatabaseConfig, cli::{ CliConfig, Env, args::{CliArgs, Command, Execute, database::DatabaseCommand}, }, }, logger::LogConfig, }; pub fn cli_args() -> &'static CliArgs { static CLI_ARGS: OnceLock = OnceLock::new(); CLI_ARGS.get_or_init(CliArgs::parse) } pub fn cli_env() -> &'static Env { static CLI_ARGS: OnceLock = OnceLock::new(); CLI_ARGS.get_or_init(|| { match envy::from_env() .context("Failed to parse Komodo CLI environment") { Ok(env) => env, Err(e) => { panic!("{e:?}"); } } }) } pub fn cli_config() -> &'static CliConfig { static CLI_CONFIG: OnceLock = OnceLock::new(); CLI_CONFIG.get_or_init(|| { let args = cli_args(); let env = cli_env().clone(); let config_paths = args .config_path .clone() .unwrap_or(env.komodo_cli_config_paths); let debug_startup = args.debug_startup.unwrap_or(env.komodo_cli_debug_startup); if debug_startup { println!( "{}: Komodo CLI version: {}", "DEBUG".cyan(), env!("CARGO_PKG_VERSION").blue().bold() ); println!( "{}: {}: {config_paths:?}", "DEBUG".cyan(), "Config Paths".dimmed(), ); } let config_keywords = args .config_keyword .clone() .unwrap_or(env.komodo_cli_config_keywords); let config_keywords = config_keywords .iter() .map(String::as_str) .collect::>(); if debug_startup { println!( "{}: {}: {config_keywords:?}", "DEBUG".cyan(), "Config File Keywords".dimmed(), ); } let mut unparsed_config = (config::ConfigLoader { paths: &config_paths .iter() .map(PathBuf::as_path) .collect::>(), match_wildcards: &config_keywords, include_file_name: ".kminclude", merge_nested: env.komodo_cli_merge_nested_config, extend_array: env.komodo_cli_extend_config_arrays, debug_print: debug_startup, }) .load::>() .expect("failed at parsing config from paths"); let init_parsed_config = serde_json::from_value::( serde_json::Value::Object(unparsed_config.clone()), ) .context("Failed to parse config") .unwrap(); let (host, key, secret) = match &args.command { Command::Execute(Execute { host, key, secret, .. }) => (host.clone(), key.clone(), secret.clone()), _ => (None, None, None), }; let backups_folder = match &args.command { Command::Database { command: DatabaseCommand::Backup { backups_folder, .. }, } => backups_folder.clone(), Command::Database { command: DatabaseCommand::Restore { backups_folder, .. }, } => backups_folder.clone(), _ => None, }; let (uri, address, username, password, db_name) = match &args.command { Command::Database { command: DatabaseCommand::Copy { uri, address, username, password, db_name, .. }, } => ( uri.clone(), address.clone(), username.clone(), password.clone(), db_name.clone(), ), _ => (None, None, None, None, None), }; let profile = args .profile .as_ref() .or(init_parsed_config.default_profile.as_ref()); let unparsed_config = if let Some(profile) = profile && !profile.is_empty() { // Find the profile config, // then merge it with the Default config. let serde_json::Value::Array(profiles) = unparsed_config .remove("profile") .context("Config has no profiles, but a profile is required") .unwrap() else { panic!("`config.profile` is not array"); }; let Some(profile_config) = profiles.into_iter().find(|p| { let Ok(parsed) = serde_json::from_value::(p.clone()) else { return false; }; &parsed.config_profile == profile || parsed .config_aliases .iter() .any(|alias| alias == profile) }) else { panic!("No profile matching '{profile}' was found."); }; let serde_json::Value::Object(profile_config) = profile_config else { panic!("Profile config is not Object type."); }; config::merge_config( unparsed_config, profile_config.clone(), env.komodo_cli_merge_nested_config, env.komodo_cli_extend_config_arrays, ) .unwrap_or(profile_config) } else { unparsed_config }; let config = serde_json::from_value::( serde_json::Value::Object(unparsed_config), ) .context("Failed to parse final config") .unwrap(); let config_profile = if config.config_profile.is_empty() { String::from("None") } else { config.config_profile }; CliConfig { config_profile, config_aliases: config.config_aliases, default_profile: config.default_profile, table_borders: env .komodo_cli_table_borders .or(config.table_borders), host: host .or(env.komodo_cli_host) .or(env.komodo_host) .unwrap_or(config.host), cli_key: key.or(env.komodo_cli_key).or(config.cli_key), cli_secret: secret .or(env.komodo_cli_secret) .or(config.cli_secret), backups_folder: backups_folder .or(env.komodo_cli_backups_folder) .unwrap_or(config.backups_folder), max_backups: env .komodo_cli_max_backups .unwrap_or(config.max_backups), database_target: DatabaseConfig { uri: uri .or(env.komodo_cli_database_target_uri) .unwrap_or(config.database_target.uri), address: address .or(env.komodo_cli_database_target_address) .unwrap_or(config.database_target.address), username: username .or(env.komodo_cli_database_target_username) .unwrap_or(config.database_target.username), password: password .or(env.komodo_cli_database_target_password) .unwrap_or(config.database_target.password), db_name: db_name .or(env.komodo_cli_database_target_db_name) .unwrap_or(config.database_target.db_name), app_name: config.database_target.app_name, }, database: DatabaseConfig { uri: maybe_read_item_from_file( env.komodo_database_uri_file, env.komodo_database_uri, ) .unwrap_or(config.database.uri), address: env .komodo_database_address .unwrap_or(config.database.address), username: maybe_read_item_from_file( env.komodo_database_username_file, env.komodo_database_username, ) .unwrap_or(config.database.username), password: maybe_read_item_from_file( env.komodo_database_password_file, env.komodo_database_password, ) .unwrap_or(config.database.password), db_name: env .komodo_database_db_name .unwrap_or(config.database.db_name), app_name: config.database.app_name, }, cli_logging: LogConfig { level: env .komodo_cli_logging_level .unwrap_or(config.cli_logging.level), stdio: env .komodo_cli_logging_stdio .unwrap_or(config.cli_logging.stdio), pretty: env .komodo_cli_logging_pretty .unwrap_or(config.cli_logging.pretty), location: false, otlp_endpoint: env .komodo_cli_logging_otlp_endpoint .unwrap_or(config.cli_logging.otlp_endpoint), opentelemetry_service_name: env .komodo_cli_logging_opentelemetry_service_name .unwrap_or(config.cli_logging.opentelemetry_service_name), }, profile: config.profile, } }) } ================================================ FILE: bin/cli/src/main.rs ================================================ #[macro_use] extern crate tracing; use anyhow::Context; use komodo_client::entities::config::cli::args; use crate::config::cli_config; mod command; mod config; async fn app() -> anyhow::Result<()> { dotenvy::dotenv().ok(); logger::init(&config::cli_config().cli_logging)?; let args = config::cli_args(); let env = config::cli_env(); let debug_load = args.debug_startup.unwrap_or(env.komodo_cli_debug_startup); match &args.command { args::Command::Config { all_profiles, unsanitized, } => { let mut config = if *unsanitized { cli_config().clone() } else { cli_config().sanitized() }; if !*all_profiles { config.profile = Default::default(); } if debug_load { println!("\n{config:#?}"); } else { println!( "\nCLI Config {}", serde_json::to_string_pretty(&config) .context("Failed to serialize config for pretty print")? ); } Ok(()) } args::Command::Container(container) => { command::container::handle(container).await } args::Command::Inspect(inspect) => { command::container::inspect_container(inspect).await } args::Command::List(list) => command::list::handle(list).await, args::Command::Execute(args) => { command::execute::handle(&args.execution, args.yes).await } args::Command::Update { command } => { command::update::handle(command).await } args::Command::Database { command } => { command::database::handle(command).await } } } #[tokio::main] async fn main() -> anyhow::Result<()> { let mut term_signal = tokio::signal::unix::signal( tokio::signal::unix::SignalKind::terminate(), )?; tokio::select! { res = tokio::spawn(app()) => res?, _ = term_signal.recv() => Ok(()), } } ================================================ FILE: bin/core/Cargo.toml ================================================ [package] name = "komodo_core" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true homepage.workspace = true repository.workspace = true [[bin]] name = "core" path = "src/main.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] # local komodo_client = { workspace = true, features = ["mongo"] } periphery_client.workspace = true environment_file.workspace = true interpolate.workspace = true formatting.workspace = true database.workspace = true response.workspace = true command.workspace = true config.workspace = true logger.workspace = true cache.workspace = true git.workspace = true # mogh serror = { workspace = true, features = ["axum"] } async_timing_util.workspace = true partial_derive2.workspace = true derive_variants.workspace = true resolver_api.workspace = true toml_pretty.workspace = true slack.workspace = true svi.workspace = true # external aws-credential-types.workspace = true tokio-tungstenite.workspace = true english-to-cron.workspace = true openidconnect.workspace = true jsonwebtoken.workspace = true axum-server.workspace = true urlencoding.workspace = true aws-sdk-ec2.workspace = true aws-config.workspace = true tokio-util.workspace = true axum-extra.workspace = true tower-http.workspace = true serde_json.workspace = true serde_yaml_ng.workspace = true typeshare.workspace = true chrono-tz.workspace = true indexmap.workspace = true octorust.workspace = true wildcard.workspace = true arc-swap.workspace = true colored.workspace = true dashmap.workspace = true tracing.workspace = true reqwest.workspace = true futures.workspace = true nom_pem.workspace = true dotenvy.workspace = true anyhow.workspace = true croner.workspace = true chrono.workspace = true bcrypt.workspace = true base64.workspace = true rustls.workspace = true tokio.workspace = true serde.workspace = true regex.workspace = true axum.workspace = true toml.workspace = true uuid.workspace = true envy.workspace = true rand.workspace = true hmac.workspace = true sha2.workspace = true hex.workspace = true ================================================ FILE: bin/core/aio.Dockerfile ================================================ ## All in one, multi stage compile + runtime Docker build for your architecture. # Build Core FROM rust:1.89.0-bullseye AS core-builder RUN cargo install cargo-strip WORKDIR /builder COPY Cargo.toml Cargo.lock ./ COPY ./lib ./lib COPY ./client/core/rs ./client/core/rs COPY ./client/periphery ./client/periphery COPY ./bin/core ./bin/core COPY ./bin/cli ./bin/cli # Compile app RUN cargo build -p komodo_core --release && \ cargo build -p komodo_cli --release && \ cargo strip # Build Frontend FROM node:20.12-alpine AS frontend-builder WORKDIR /builder COPY ./frontend ./frontend COPY ./client/core/ts ./client RUN cd client && yarn && yarn build && yarn link RUN cd frontend && yarn link komodo_client && yarn && yarn build # Final Image FROM debian:bullseye-slim COPY ./bin/core/starship.toml /starship.toml COPY ./bin/core/debian-deps.sh . RUN sh ./debian-deps.sh && rm ./debian-deps.sh # Setup an application directory WORKDIR /app # Copy COPY ./config/core.config.toml /config/.default.config.toml COPY --from=frontend-builder /builder/frontend/dist /app/frontend COPY --from=core-builder /builder/target/release/core /usr/local/bin/core COPY --from=core-builder /builder/target/release/km /usr/local/bin/km COPY --from=denoland/deno:bin /deno /usr/local/bin/deno # Set $DENO_DIR and preload external Deno deps ENV DENO_DIR=/action-cache/deno RUN mkdir /action-cache && \ cd /action-cache && \ deno install jsr:@std/yaml jsr:@std/toml # Hint at the port EXPOSE 9120 ENV KOMODO_CLI_CONFIG_PATHS="/config" # This ensures any `komodo.cli.*` takes precedence over the Core `/config/*config.*` ENV KOMODO_CLI_CONFIG_KEYWORDS="*config.*,*komodo.cli*.*" CMD [ "core" ] # Label for Ghcr LABEL org.opencontainers.image.source=https://github.com/moghtech/komodo LABEL org.opencontainers.image.description="Komodo Core" LABEL org.opencontainers.image.licenses=GPL-3.0 ================================================ FILE: bin/core/debian-deps.sh ================================================ #!/bin/bash ## Core deps installer apt-get update apt-get install -y git curl ca-certificates iproute2 rm -rf /var/lib/apt/lists/* # Starship prompt curl -sS https://starship.rs/install.sh | sh -s -- --yes --bin-dir /usr/local/bin echo 'export STARSHIP_CONFIG=/starship.toml' >> /root/.bashrc echo 'eval "$(starship init bash)"' >> /root/.bashrc ================================================ FILE: bin/core/multi-arch.Dockerfile ================================================ ## Assumes the latest binaries for x86_64 and aarch64 are already built (by binaries.Dockerfile). ## Sets up the necessary runtime container dependencies for Komodo Core. ## Since theres no heavy build here, QEMU multi-arch builds are fine for this image. ARG BINARIES_IMAGE=ghcr.io/moghtech/komodo-binaries:latest ARG FRONTEND_IMAGE=ghcr.io/moghtech/komodo-frontend:latest ARG X86_64_BINARIES=${BINARIES_IMAGE}-x86_64 ARG AARCH64_BINARIES=${BINARIES_IMAGE}-aarch64 # This is required to work with COPY --from FROM ${X86_64_BINARIES} AS x86_64 FROM ${AARCH64_BINARIES} AS aarch64 FROM ${FRONTEND_IMAGE} AS frontend # Final Image FROM debian:bullseye-slim COPY ./bin/core/starship.toml /starship.toml COPY ./bin/core/debian-deps.sh . RUN sh ./debian-deps.sh && rm ./debian-deps.sh WORKDIR /app ARG TARGETPLATFORM # Copy both binaries initially, but only keep appropriate one for the TARGETPLATFORM. COPY --from=x86_64 /core /app/core/linux/amd64 COPY --from=aarch64 /core /app/core/linux/arm64 RUN mv /app/core/${TARGETPLATFORM} /usr/local/bin/core && rm -r /app/core # Same for util COPY --from=x86_64 /km /app/km/linux/amd64 COPY --from=aarch64 /km /app/km/linux/arm64 RUN mv /app/km/${TARGETPLATFORM} /usr/local/bin/km && rm -r /app/km # Copy default config / static frontend / deno binary COPY ./config/core.config.toml /config/.default.config.toml COPY --from=frontend /frontend /app/frontend COPY --from=denoland/deno:bin /deno /usr/local/bin/deno # Set $DENO_DIR and preload external Deno deps ENV DENO_DIR=/action-cache/deno RUN mkdir /action-cache && \ cd /action-cache && \ deno install jsr:@std/yaml jsr:@std/toml # Hint at the port EXPOSE 9120 ENV KOMODO_CLI_CONFIG_PATHS="/config" # This ensures any `komodo.cli.*` takes precedence over the Core `/config/*config.*` ENV KOMODO_CLI_CONFIG_KEYWORDS="*config.*,*komodo.cli*.*" CMD [ "core" ] # Label for Ghcr LABEL org.opencontainers.image.source=https://github.com/moghtech/komodo LABEL org.opencontainers.image.description="Komodo Core" LABEL org.opencontainers.image.licenses=GPL-3.0 ================================================ FILE: bin/core/single-arch.Dockerfile ================================================ ## Assumes the latest binaries for the required arch are already built (by binaries.Dockerfile). ## Sets up the necessary runtime container dependencies for Komodo Core. ARG BINARIES_IMAGE=ghcr.io/moghtech/komodo-binaries:latest # This is required to work with COPY --from FROM ${BINARIES_IMAGE} AS binaries # Build Frontend FROM node:20.12-alpine AS frontend-builder WORKDIR /builder COPY ./frontend ./frontend COPY ./client/core/ts ./client RUN cd client && yarn && yarn build && yarn link RUN cd frontend && yarn link komodo_client && yarn && yarn build FROM debian:bullseye-slim COPY ./bin/core/starship.toml /starship.toml COPY ./bin/core/debian-deps.sh . RUN sh ./debian-deps.sh && rm ./debian-deps.sh # Copy COPY ./config/core.config.toml /config/.default.config.toml COPY --from=frontend-builder /builder/frontend/dist /app/frontend COPY --from=binaries /core /usr/local/bin/core COPY --from=binaries /km /usr/local/bin/km COPY --from=denoland/deno:bin /deno /usr/local/bin/deno # Set $DENO_DIR and preload external Deno deps ENV DENO_DIR=/action-cache/deno RUN mkdir /action-cache && \ cd /action-cache && \ deno install jsr:@std/yaml jsr:@std/toml # Hint at the port EXPOSE 9120 ENV KOMODO_CLI_CONFIG_PATHS="/config" # This ensures any `komodo.cli.*` takes precedence over the Core `/config/*config.*` ENV KOMODO_CLI_CONFIG_KEYWORDS="*config.*,*komodo.cli*.*" CMD [ "core" ] # Label for Ghcr LABEL org.opencontainers.image.source=https://github.com/moghtech/komodo LABEL org.opencontainers.image.description="Komodo Core" LABEL org.opencontainers.image.licenses=GPL-3.0 ================================================ FILE: bin/core/src/alert/discord.rs ================================================ use std::sync::OnceLock; use serde::Serialize; use super::*; #[instrument(level = "debug")] pub async fn send_alert( url: &str, alert: &Alert, ) -> anyhow::Result<()> { let level = fmt_level(alert.level); let content = match &alert.data { AlertData::Test { id, name } => { let link = resource_link(ResourceTargetVariant::Alerter, id); format!( "{level} | If you see this message, then Alerter **{name}** is **working**\n{link}" ) } AlertData::ServerVersionMismatch { id, name, region, server_version, core_version, } => { let region = fmt_region(region); let link = resource_link(ResourceTargetVariant::Server, id); match alert.level { SeverityLevel::Ok => { format!( "{level} | **{name}**{region} | Periphery version now matches Core version ✅\n{link}" ) } _ => { format!( "{level} | **{name}**{region} | Version mismatch detected ⚠️\nPeriphery: **{server_version}** | Core: **{core_version}**\n{link}" ) } } } AlertData::ServerUnreachable { id, name, region, err, } => { let region = fmt_region(region); let link = resource_link(ResourceTargetVariant::Server, id); match alert.level { SeverityLevel::Ok => { format!( "{level} | **{name}**{region} is now **reachable**\n{link}" ) } SeverityLevel::Critical => { let err = err .as_ref() .map(|e| format!("\n**error**: {e:#?}")) .unwrap_or_default(); format!( "{level} | **{name}**{region} is **unreachable** ❌\n{link}{err}" ) } _ => unreachable!(), } } AlertData::ServerCpu { id, name, region, percentage, } => { let region = fmt_region(region); let link = resource_link(ResourceTargetVariant::Server, id); format!( "{level} | **{name}**{region} cpu usage at **{percentage:.1}%**\n{link}" ) } AlertData::ServerMem { id, name, region, used_gb, total_gb, } => { let region = fmt_region(region); let link = resource_link(ResourceTargetVariant::Server, id); let percentage = 100.0 * used_gb / total_gb; format!( "{level} | **{name}**{region} memory usage at **{percentage:.1}%** 💾\n\nUsing **{used_gb:.1} GiB** / **{total_gb:.1} GiB**\n{link}" ) } AlertData::ServerDisk { id, name, region, path, used_gb, total_gb, } => { let region = fmt_region(region); let link = resource_link(ResourceTargetVariant::Server, id); let percentage = 100.0 * used_gb / total_gb; format!( "{level} | **{name}**{region} disk usage at **{percentage:.1}%** 💿\nmount point: `{path:?}`\nusing **{used_gb:.1} GiB** / **{total_gb:.1} GiB**\n{link}" ) } AlertData::ContainerStateChange { id, name, server_id: _server_id, server_name, from, to, } => { let link = resource_link(ResourceTargetVariant::Deployment, id); let to = fmt_docker_container_state(to); format!( "📦 Deployment **{name}** is now **{to}**\nserver: **{server_name}**\nprevious: **{from}**\n{link}" ) } AlertData::DeploymentImageUpdateAvailable { id, name, server_id: _server_id, server_name, image, } => { let link = resource_link(ResourceTargetVariant::Deployment, id); format!( "⬆ Deployment **{name}** has an update available\nserver: **{server_name}**\nimage: **{image}**\n{link}" ) } AlertData::DeploymentAutoUpdated { id, name, server_id: _server_id, server_name, image, } => { let link = resource_link(ResourceTargetVariant::Deployment, id); format!( "⬆ Deployment **{name}** was updated automatically ⏫\nserver: **{server_name}**\nimage: **{image}**\n{link}" ) } AlertData::StackStateChange { id, name, server_id: _server_id, server_name, from, to, } => { let link = resource_link(ResourceTargetVariant::Stack, id); let to = fmt_stack_state(to); format!( "🥞 Stack **{name}** is now {to}\nserver: **{server_name}**\nprevious: **{from}**\n{link}" ) } AlertData::StackImageUpdateAvailable { id, name, server_id: _server_id, server_name, service, image, } => { let link = resource_link(ResourceTargetVariant::Stack, id); format!( "⬆ Stack **{name}** has an update available\nserver: **{server_name}**\nservice: **{service}**\nimage: **{image}**\n{link}" ) } AlertData::StackAutoUpdated { id, name, server_id: _server_id, server_name, images, } => { let link = resource_link(ResourceTargetVariant::Stack, id); let images_label = if images.len() > 1 { "images" } else { "image" }; let images = images.join(", "); format!( "⬆ Stack **{name}** was updated automatically ⏫\nserver: **{server_name}**\n{images_label}: **{images}**\n{link}" ) } AlertData::AwsBuilderTerminationFailed { instance_id, message, } => { format!( "{level} | Failed to terminated AWS builder instance\ninstance id: **{instance_id}**\n{message}" ) } AlertData::ResourceSyncPendingUpdates { id, name } => { let link = resource_link(ResourceTargetVariant::ResourceSync, id); format!( "{level} | Pending resource sync updates on **{name}**\n{link}" ) } AlertData::BuildFailed { id, name, version } => { let link = resource_link(ResourceTargetVariant::Build, id); format!( "{level} | Build **{name}** failed\nversion: **v{version}**\n{link}" ) } AlertData::RepoBuildFailed { id, name } => { let link = resource_link(ResourceTargetVariant::Repo, id); format!("{level} | Repo build for **{name}** failed\n{link}") } AlertData::ProcedureFailed { id, name } => { let link = resource_link(ResourceTargetVariant::Procedure, id); format!("{level} | Procedure **{name}** failed\n{link}") } AlertData::ActionFailed { id, name } => { let link = resource_link(ResourceTargetVariant::Action, id); format!("{level} | Action **{name}** failed\n{link}") } AlertData::ScheduleRun { resource_type, id, name, } => { let link = resource_link(*resource_type, id); format!( "{level} | **{name}** ({resource_type}) | Scheduled run started 🕝\n{link}" ) } AlertData::Custom { message, details } => { format!( "{level} | {message}{}", if details.is_empty() { format_args!("") } else { format_args!("\n{details}") } ) } AlertData::None {} => Default::default(), }; if !content.is_empty() { let VariablesAndSecrets { variables, secrets } = get_variables_and_secrets().await?; let mut url_interpolated = url.to_string(); let mut interpolator = Interpolator::new(Some(&variables), &secrets); interpolator.interpolate_string(&mut url_interpolated)?; send_message(&url_interpolated, &content) .await .map_err(|e| { let replacers = interpolator .secret_replacers .into_iter() .collect::>(); let sanitized_error = svi::replace_in_string(&format!("{e:?}"), &replacers); anyhow::Error::msg(format!( "Error with slack request: {sanitized_error}" )) })?; } Ok(()) } async fn send_message( url: &str, content: &str, ) -> anyhow::Result<()> { let body = DiscordMessageBody { content }; let response = http_client() .post(url) .json(&body) .send() .await .context("Failed to send message")?; let status = response.status(); if status.is_success() { Ok(()) } else { let text = response.text().await.with_context(|| { format!("Failed to send message to Discord | {status} | failed to get response text") })?; Err(anyhow::anyhow!( "Failed to send message to Discord | {status} | {text}" )) } } fn http_client() -> &'static reqwest::Client { static CLIENT: OnceLock = OnceLock::new(); CLIENT.get_or_init(reqwest::Client::new) } #[derive(Serialize)] struct DiscordMessageBody<'a> { content: &'a str, } ================================================ FILE: bin/core/src/alert/mod.rs ================================================ use ::slack::types::Block; use anyhow::{Context, anyhow}; use database::mungos::{find::find_collect, mongodb::bson::doc}; use derive_variants::ExtractVariant; use futures::future::join_all; use interpolate::Interpolator; use komodo_client::entities::{ ResourceTargetVariant, alert::{Alert, AlertData, AlertDataVariant, SeverityLevel}, alerter::*, deployment::DeploymentState, komodo_timestamp, stack::StackState, }; use tracing::Instrument; use crate::helpers::query::get_variables_and_secrets; use crate::helpers::{ maintenance::is_in_maintenance, query::VariablesAndSecrets, }; use crate::{config::core_config, state::db_client}; mod discord; mod ntfy; mod pushover; mod slack; #[instrument(level = "debug")] pub async fn send_alerts(alerts: &[Alert]) { if alerts.is_empty() { return; } let span = info_span!("send_alerts", alerts = format!("{alerts:?}")); async { let Ok(alerters) = find_collect( &db_client().alerters, doc! { "config.enabled": true }, None, ) .await .inspect_err(|e| { error!( "ERROR sending alerts | failed to get alerters from db | {e:#}" ) }) else { return; }; let handles = alerts .iter() .map(|alert| send_alert_to_alerters(&alerters, alert)); join_all(handles).await; } .instrument(span) .await } #[instrument(level = "debug")] async fn send_alert_to_alerters(alerters: &[Alerter], alert: &Alert) { if alerters.is_empty() { return; } let handles = alerters .iter() .map(|alerter| send_alert_to_alerter(alerter, alert)); join_all(handles) .await .into_iter() .filter_map(|res| res.err()) .for_each(|e| error!("{e:#}")); } pub async fn send_alert_to_alerter( alerter: &Alerter, alert: &Alert, ) -> anyhow::Result<()> { // Don't send if not enabled if !alerter.config.enabled { return Ok(()); } if is_in_maintenance( &alerter.config.maintenance_windows, komodo_timestamp(), ) { return Ok(()); } let alert_type = alert.data.extract_variant(); // In the test case, we don't want the filters inside this // block to stop the test from being sent to the alerting endpoint. if alert_type != AlertDataVariant::Test { // Don't send if alert type not configured on the alerter if !alerter.config.alert_types.is_empty() && !alerter.config.alert_types.contains(&alert_type) { return Ok(()); } // Don't send if resource is in the blacklist if alerter.config.except_resources.contains(&alert.target) { return Ok(()); } // Don't send if whitelist configured and target is not included if !alerter.config.resources.is_empty() && !alerter.config.resources.contains(&alert.target) { return Ok(()); } } match &alerter.config.endpoint { AlerterEndpoint::Custom(CustomAlerterEndpoint { url }) => { send_custom_alert(url, alert).await.with_context(|| { format!( "Failed to send alert to Custom Alerter {}", alerter.name ) }) } AlerterEndpoint::Slack(SlackAlerterEndpoint { url }) => { slack::send_alert(url, alert).await.with_context(|| { format!( "Failed to send alert to Slack Alerter {}", alerter.name ) }) } AlerterEndpoint::Discord(DiscordAlerterEndpoint { url }) => { discord::send_alert(url, alert).await.with_context(|| { format!( "Failed to send alert to Discord Alerter {}", alerter.name ) }) } AlerterEndpoint::Ntfy(NtfyAlerterEndpoint { url, email }) => { ntfy::send_alert(url, email.as_deref(), alert) .await .with_context(|| { format!( "Failed to send alert to ntfy Alerter {}", alerter.name ) }) } AlerterEndpoint::Pushover(PushoverAlerterEndpoint { url }) => { pushover::send_alert(url, alert).await.with_context(|| { format!( "Failed to send alert to Pushover Alerter {}", alerter.name ) }) } } } #[instrument(level = "debug")] async fn send_custom_alert( url: &str, alert: &Alert, ) -> anyhow::Result<()> { let VariablesAndSecrets { variables, secrets } = get_variables_and_secrets().await?; let mut url_interpolated = url.to_string(); let mut interpolator = Interpolator::new(Some(&variables), &secrets); interpolator.interpolate_string(&mut url_interpolated)?; let res = reqwest::Client::new() .post(url_interpolated) .json(alert) .send() .await .map_err(|e| { let replacers = interpolator .secret_replacers .into_iter() .collect::>(); let sanitized_error = svi::replace_in_string(&format!("{e:?}"), &replacers); anyhow::Error::msg(format!( "Error with request: {sanitized_error}" )) }) .context("failed at post request to alerter")?; let status = res.status(); if !status.is_success() { let text = res .text() .await .context("failed to get response text on alerter response")?; return Err(anyhow!( "post to alerter failed | {status} | {text}" )); } Ok(()) } fn fmt_region(region: &Option) -> String { match region { Some(region) => format!(" ({region})"), None => String::new(), } } fn fmt_docker_container_state(state: &DeploymentState) -> String { match state { DeploymentState::Running => String::from("Running ▶️"), DeploymentState::Exited => String::from("Exited 🛑"), DeploymentState::Restarting => String::from("Restarting 🔄"), DeploymentState::NotDeployed => String::from("Not Deployed"), _ => state.to_string(), } } fn fmt_stack_state(state: &StackState) -> String { match state { StackState::Running => String::from("Running ▶️"), StackState::Stopped => String::from("Stopped 🛑"), StackState::Restarting => String::from("Restarting 🔄"), StackState::Down => String::from("Down ⬇️"), _ => state.to_string(), } } fn fmt_level(level: SeverityLevel) -> &'static str { match level { SeverityLevel::Critical => "CRITICAL 🚨", SeverityLevel::Warning => "WARNING ‼️", SeverityLevel::Ok => "OK ✅", } } fn resource_link( resource_type: ResourceTargetVariant, id: &str, ) -> String { komodo_client::entities::resource_link( &core_config().host, resource_type, id, ) } /// Standard message content format /// used by Ntfy, Pushover. fn standard_alert_content(alert: &Alert) -> String { let level = fmt_level(alert.level); match &alert.data { AlertData::Test { id, name } => { let link = resource_link(ResourceTargetVariant::Alerter, id); format!( "{level} | If you see this message, then Alerter {name} is working\n{link}", ) } AlertData::ServerVersionMismatch { id, name, region, server_version, core_version, } => { let region = fmt_region(region); let link = resource_link(ResourceTargetVariant::Server, id); match alert.level { SeverityLevel::Ok => { format!( "{level} | {name}{region} | Periphery version now matches Core version ✅\n{link}" ) } _ => { format!( "{level} | {name}{region} | Version mismatch detected ⚠️\nPeriphery: {server_version} | Core: {core_version}\n{link}" ) } } } AlertData::ServerUnreachable { id, name, region, err, } => { let region = fmt_region(region); let link = resource_link(ResourceTargetVariant::Server, id); match alert.level { SeverityLevel::Ok => { format!("{level} | {name}{region} is now reachable\n{link}") } SeverityLevel::Critical => { let err = err .as_ref() .map(|e| format!("\nerror: {e:#?}")) .unwrap_or_default(); format!( "{level} | {name}{region} is unreachable ❌\n{link}{err}" ) } _ => unreachable!(), } } AlertData::ServerCpu { id, name, region, percentage, } => { let region = fmt_region(region); let link = resource_link(ResourceTargetVariant::Server, id); format!( "{level} | {name}{region} cpu usage at {percentage:.1}%\n{link}", ) } AlertData::ServerMem { id, name, region, used_gb, total_gb, } => { let region = fmt_region(region); let link = resource_link(ResourceTargetVariant::Server, id); let percentage = 100.0 * used_gb / total_gb; format!( "{level} | {name}{region} memory usage at {percentage:.1}%💾\n\nUsing {used_gb:.1} GiB / {total_gb:.1} GiB\n{link}", ) } AlertData::ServerDisk { id, name, region, path, used_gb, total_gb, } => { let region = fmt_region(region); let link = resource_link(ResourceTargetVariant::Server, id); let percentage = 100.0 * used_gb / total_gb; format!( "{level} | {name}{region} disk usage at {percentage:.1}%💿\nmount point: {path:?}\nusing {used_gb:.1} GiB / {total_gb:.1} GiB\n{link}", ) } AlertData::ContainerStateChange { id, name, server_id: _server_id, server_name, from, to, } => { let link = resource_link(ResourceTargetVariant::Deployment, id); let to_state = fmt_docker_container_state(to); format!( "📦Deployment {name} is now {to_state}\nserver: {server_name}\nprevious: {from}\n{link}", ) } AlertData::DeploymentImageUpdateAvailable { id, name, server_id: _server_id, server_name, image, } => { let link = resource_link(ResourceTargetVariant::Deployment, id); format!( "⬆ Deployment {name} has an update available\nserver: {server_name}\nimage: {image}\n{link}", ) } AlertData::DeploymentAutoUpdated { id, name, server_id: _server_id, server_name, image, } => { let link = resource_link(ResourceTargetVariant::Deployment, id); format!( "⬆ Deployment {name} was updated automatically\nserver: {server_name}\nimage: {image}\n{link}", ) } AlertData::StackStateChange { id, name, server_id: _server_id, server_name, from, to, } => { let link = resource_link(ResourceTargetVariant::Stack, id); let to_state = fmt_stack_state(to); format!( "🥞 Stack {name} is now {to_state}\nserver: {server_name}\nprevious: {from}\n{link}", ) } AlertData::StackImageUpdateAvailable { id, name, server_id: _server_id, server_name, service, image, } => { let link = resource_link(ResourceTargetVariant::Stack, id); format!( "⬆ Stack {name} has an update available\nserver: {server_name}\nservice: {service}\nimage: {image}\n{link}", ) } AlertData::StackAutoUpdated { id, name, server_id: _server_id, server_name, images, } => { let link = resource_link(ResourceTargetVariant::Stack, id); let images_label = if images.len() > 1 { "images" } else { "image" }; let images_str = images.join(", "); format!( "⬆ Stack {name} was updated automatically ⏫\nserver: {server_name}\n{images_label}: {images_str}\n{link}", ) } AlertData::AwsBuilderTerminationFailed { instance_id, message, } => { format!( "{level} | Failed to terminate AWS builder instance\ninstance id: {instance_id}\n{message}", ) } AlertData::ResourceSyncPendingUpdates { id, name } => { let link = resource_link(ResourceTargetVariant::ResourceSync, id); format!( "{level} | Pending resource sync updates on {name}\n{link}", ) } AlertData::BuildFailed { id, name, version } => { let link = resource_link(ResourceTargetVariant::Build, id); format!( "{level} | Build {name} failed\nversion: v{version}\n{link}", ) } AlertData::RepoBuildFailed { id, name } => { let link = resource_link(ResourceTargetVariant::Repo, id); format!("{level} | Repo build for {name} failed\n{link}",) } AlertData::ProcedureFailed { id, name } => { let link = resource_link(ResourceTargetVariant::Procedure, id); format!("{level} | Procedure {name} failed\n{link}") } AlertData::ActionFailed { id, name } => { let link = resource_link(ResourceTargetVariant::Action, id); format!("{level} | Action {name} failed\n{link}") } AlertData::ScheduleRun { resource_type, id, name, } => { let link = resource_link(*resource_type, id); format!( "{level} | {name} ({resource_type}) | Scheduled run started 🕝\n{link}" ) } AlertData::Custom { message, details } => { format!( "{level} | {message}{}", if details.is_empty() { format_args!("") } else { format_args!("\n{details}") } ) } AlertData::None {} => Default::default(), } } ================================================ FILE: bin/core/src/alert/ntfy.rs ================================================ use std::sync::OnceLock; use super::*; #[instrument(level = "debug")] pub async fn send_alert( url: &str, email: Option<&str>, alert: &Alert, ) -> anyhow::Result<()> { let content = standard_alert_content(alert); if !content.is_empty() { send_message(url, email, content).await?; } Ok(()) } async fn send_message( url: &str, email: Option<&str>, content: String, ) -> anyhow::Result<()> { let mut request = http_client() .post(url) .header("Title", "ntfy Alert") .body(content); if let Some(email) = email { request = request.header("X-Email", email); } let response = request.send().await.context("Failed to send message")?; let status = response.status(); if status.is_success() { debug!("ntfy alert sent successfully: {}", status); Ok(()) } else { let text = response.text().await.with_context(|| { format!( "Failed to send message to ntfy | {status} | failed to get response text" ) })?; Err(anyhow!( "Failed to send message to ntfy | {} | {}", status, text )) } } fn http_client() -> &'static reqwest::Client { static CLIENT: OnceLock = OnceLock::new(); CLIENT.get_or_init(reqwest::Client::new) } ================================================ FILE: bin/core/src/alert/pushover.rs ================================================ use std::sync::OnceLock; use super::*; #[instrument(level = "debug")] pub async fn send_alert( url: &str, alert: &Alert, ) -> anyhow::Result<()> { let content = standard_alert_content(alert); if !content.is_empty() { send_message(url, content).await?; } Ok(()) } async fn send_message( url: &str, content: String, ) -> anyhow::Result<()> { // pushover needs all information to be encoded in the URL. At minimum they need // the user key, the application token, and the message (url encoded). // other optional params here: https://pushover.net/api (just add them to the // webhook url along with the application token and the user key). let content = [("message", content)]; let response = http_client() .post(url) .form(&content) .send() .await .context("Failed to send message")?; let status = response.status(); if status.is_success() { debug!("pushover alert sent successfully: {}", status); Ok(()) } else { let text = response.text().await.with_context(|| { format!( "Failed to send message to pushover | {status} | failed to get response text" ) })?; Err(anyhow!( "Failed to send message to pushover | {} | {}", status, text )) } } fn http_client() -> &'static reqwest::Client { static CLIENT: OnceLock = OnceLock::new(); CLIENT.get_or_init(reqwest::Client::new) } ================================================ FILE: bin/core/src/alert/slack.rs ================================================ use super::*; #[instrument(level = "debug")] pub async fn send_alert( url: &str, alert: &Alert, ) -> anyhow::Result<()> { let level = fmt_level(alert.level); let (text, blocks): (_, Option<_>) = match &alert.data { AlertData::Test { id, name } => { let text = format!( "{level} | If you see this message, then Alerter *{name}* is *working*" ); let blocks = vec![ Block::header(level), Block::section(format!( "If you see this message, then Alerter *{name}* is *working*" )), Block::section(resource_link( ResourceTargetVariant::Alerter, id, )), ]; (text, blocks.into()) } AlertData::ServerVersionMismatch { id, name, region, server_version, core_version, } => { let region = fmt_region(region); let text = match alert.level { SeverityLevel::Ok => { format!( "{level} | *{name}*{region} | Periphery version now matches Core version ✅" ) } _ => { format!( "{level} | *{name}*{region} | Version mismatch detected ⚠️\nPeriphery: {server_version} | Core: {core_version}" ) } }; let blocks = vec![ Block::header(text.clone()), Block::section(resource_link( ResourceTargetVariant::Server, id, )), ]; (text, blocks.into()) } AlertData::ServerUnreachable { id, name, region, err, } => { let region = fmt_region(region); match alert.level { SeverityLevel::Ok => { let text = format!("{level} | *{name}*{region} is now *reachable*"); let blocks = vec![ Block::header(level), Block::section(format!( "*{name}*{region} is now *reachable*" )), ]; (text, blocks.into()) } SeverityLevel::Critical => { let text = format!("{level} | *{name}*{region} is *unreachable* ❌"); let err = err .as_ref() .map(|e| format!("\nerror: {e:#?}")) .unwrap_or_default(); let blocks = vec![ Block::header(level), Block::section(format!( "*{name}*{region} is *unreachable* ❌{err}" )), Block::section(resource_link( ResourceTargetVariant::Server, id, )), ]; (text, blocks.into()) } _ => unreachable!(), } } AlertData::ServerCpu { id, name, region, percentage, } => { let region = fmt_region(region); match alert.level { SeverityLevel::Ok => { let text = format!( "{level} | *{name}*{region} cpu usage at *{percentage:.1}%*" ); let blocks = vec![ Block::header(level), Block::section(format!( "*{name}*{region} cpu usage at *{percentage:.1}%*" )), Block::section(resource_link( ResourceTargetVariant::Server, id, )), ]; (text, blocks.into()) } _ => { let text = format!( "{level} | *{name}*{region} cpu usage at *{percentage:.1}%* 📈" ); let blocks = vec![ Block::header(level), Block::section(format!( "*{name}*{region} cpu usage at *{percentage:.1}%* 📈" )), Block::section(resource_link( ResourceTargetVariant::Server, id, )), ]; (text, blocks.into()) } } } AlertData::ServerMem { id, name, region, used_gb, total_gb, } => { let region = fmt_region(region); let percentage = 100.0 * used_gb / total_gb; match alert.level { SeverityLevel::Ok => { let text = format!( "{level} | *{name}*{region} memory usage at *{percentage:.1}%* 💾" ); let blocks = vec![ Block::header(level), Block::section(format!( "*{name}*{region} memory usage at *{percentage:.1}%* 💾" )), Block::section(format!( "using *{used_gb:.1} GiB* / *{total_gb:.1} GiB*" )), Block::section(resource_link( ResourceTargetVariant::Server, id, )), ]; (text, blocks.into()) } _ => { let text = format!( "{level} | *{name}*{region} memory usage at *{percentage:.1}%* 💾" ); let blocks = vec![ Block::header(level), Block::section(format!( "*{name}*{region} memory usage at *{percentage:.1}%* 💾" )), Block::section(format!( "using *{used_gb:.1} GiB* / *{total_gb:.1} GiB*" )), Block::section(resource_link( ResourceTargetVariant::Server, id, )), ]; (text, blocks.into()) } } } AlertData::ServerDisk { id, name, region, path, used_gb, total_gb, } => { let region = fmt_region(region); let percentage = 100.0 * used_gb / total_gb; match alert.level { SeverityLevel::Ok => { let text = format!( "{level} | *{name}*{region} disk usage at *{percentage:.1}%* | mount point: *{path:?}* 💿" ); let blocks = vec![ Block::header(level), Block::section(format!( "*{name}*{region} disk usage at *{percentage:.1}%* 💿" )), Block::section(format!( "mount point: {path:?} | using *{used_gb:.1} GiB* / *{total_gb:.1} GiB*" )), Block::section(resource_link( ResourceTargetVariant::Server, id, )), ]; (text, blocks.into()) } _ => { let text = format!( "{level} | *{name}*{region} disk usage at *{percentage:.1}%* | mount point: *{path:?}* 💿" ); let blocks = vec![ Block::header(level), Block::section(format!( "*{name}*{region} disk usage at *{percentage:.1}%* 💿" )), Block::section(format!( "mount point: {path:?} | using *{used_gb:.1} GiB* / *{total_gb:.1} GiB*" )), Block::section(resource_link( ResourceTargetVariant::Server, id, )), ]; (text, blocks.into()) } } } AlertData::ContainerStateChange { name, server_name, from, to, id, .. } => { let to = fmt_docker_container_state(to); let text = format!("📦 Container *{name}* is now *{to}*"); let blocks = vec![ Block::header(text.clone()), Block::section(format!( "server: {server_name}\nprevious: {from}", )), Block::section(resource_link( ResourceTargetVariant::Deployment, id, )), ]; (text, blocks.into()) } AlertData::DeploymentImageUpdateAvailable { id, name, server_name, server_id: _server_id, image, } => { let text = format!("⬆ Deployment *{name}* has an update available"); let blocks = vec![ Block::header(text.clone()), Block::section(format!( "server: *{server_name}*\nimage: *{image}*", )), Block::section(resource_link( ResourceTargetVariant::Deployment, id, )), ]; (text, blocks.into()) } AlertData::DeploymentAutoUpdated { id, name, server_name, server_id: _server_id, image, } => { let text = format!("⬆ Deployment *{name}* was updated automatically ⏫"); let blocks = vec![ Block::header(text.clone()), Block::section(format!( "server: *{server_name}*\nimage: *{image}*", )), Block::section(resource_link( ResourceTargetVariant::Deployment, id, )), ]; (text, blocks.into()) } AlertData::StackStateChange { name, server_name, from, to, id, .. } => { let to = fmt_stack_state(to); let text = format!("🥞 Stack *{name}* is now *{to}*"); let blocks = vec![ Block::header(text.clone()), Block::section(format!( "server: *{server_name}*\nprevious: *{from}*", )), Block::section(resource_link( ResourceTargetVariant::Stack, id, )), ]; (text, blocks.into()) } AlertData::StackImageUpdateAvailable { id, name, server_name, server_id: _server_id, service, image, } => { let text = format!("⬆ Stack *{name}* has an update available"); let blocks = vec![ Block::header(text.clone()), Block::section(format!( "server: *{server_name}*\nservice: *{service}*\nimage: *{image}*", )), Block::section(resource_link( ResourceTargetVariant::Stack, id, )), ]; (text, blocks.into()) } AlertData::StackAutoUpdated { id, name, server_name, server_id: _server_id, images, } => { let text = format!("⬆ Stack *{name}* was updated automatically ⏫"); let images_label = if images.len() > 1 { "images" } else { "image" }; let images = images.join(", "); let blocks = vec![ Block::header(text.clone()), Block::section(format!( "server: *{server_name}*\n{images_label}: *{images}*", )), Block::section(resource_link( ResourceTargetVariant::Stack, id, )), ]; (text, blocks.into()) } AlertData::AwsBuilderTerminationFailed { instance_id, message, } => { let text = format!( "{level} | Failed to terminated AWS builder instance " ); let blocks = vec![ Block::header(text.clone()), Block::section(format!( "instance id: *{instance_id}*\n{message}" )), ]; (text, blocks.into()) } AlertData::ResourceSyncPendingUpdates { id, name } => { let text = format!( "{level} | Pending resource sync updates on *{name}*" ); let blocks = vec![ Block::header(text.clone()), Block::section(format!( "sync id: *{id}*\nsync name: *{name}*", )), Block::section(resource_link( ResourceTargetVariant::ResourceSync, id, )), ]; (text, blocks.into()) } AlertData::BuildFailed { id, name, version } => { let text = format!("{level} | Build {name} has failed"); let blocks = vec![ Block::header(text.clone()), Block::section(format!("version: *v{version}*",)), Block::section(resource_link( ResourceTargetVariant::Build, id, )), ]; (text, blocks.into()) } AlertData::RepoBuildFailed { id, name } => { let text = format!("{level} | Repo build for *{name}* has *failed*"); let blocks = vec![ Block::header(text.clone()), Block::section(resource_link( ResourceTargetVariant::Repo, id, )), ]; (text, blocks.into()) } AlertData::ProcedureFailed { id, name } => { let text = format!("{level} | Procedure *{name}* has *failed*"); let blocks = vec![ Block::header(text.clone()), Block::section(resource_link( ResourceTargetVariant::Procedure, id, )), ]; (text, blocks.into()) } AlertData::ActionFailed { id, name } => { let text = format!("{level} | Action *{name}* has *failed*"); let blocks = vec![ Block::header(text.clone()), Block::section(resource_link( ResourceTargetVariant::Action, id, )), ]; (text, blocks.into()) } AlertData::ScheduleRun { resource_type, id, name, } => { let text = format!( "{level} | *{name}* ({resource_type}) | Scheduled run started 🕝" ); let blocks = vec![ Block::header(text.clone()), Block::section(resource_link(*resource_type, id)), ]; (text, blocks.into()) } AlertData::Custom { message, details } => { let text = format!("{level} | {message}"); let blocks = vec![Block::header(text.clone()), Block::section(details)]; (text, blocks.into()) } AlertData::None {} => Default::default(), }; if !text.is_empty() { let VariablesAndSecrets { variables, secrets } = get_variables_and_secrets().await?; let mut url_interpolated = url.to_string(); let mut interpolator = Interpolator::new(Some(&variables), &secrets); interpolator.interpolate_string(&mut url_interpolated)?; let slack = ::slack::Client::new(url_interpolated); slack.send_message(text, blocks).await.map_err(|e| { let replacers = interpolator .secret_replacers .into_iter() .collect::>(); let sanitized_error = svi::replace_in_string(&format!("{e:?}"), &replacers); anyhow::Error::msg(format!( "Error with slack request: {sanitized_error}" )) })?; } Ok(()) } ================================================ FILE: bin/core/src/api/auth.rs ================================================ use std::{sync::OnceLock, time::Instant}; use axum::{Router, extract::Path, http::HeaderMap, routing::post}; use derive_variants::{EnumVariants, ExtractVariant}; use komodo_client::{api::auth::*, entities::user::User}; use reqwest::StatusCode; use resolver_api::Resolve; use response::Response; use serde::{Deserialize, Serialize}; use serde_json::json; use serror::{AddStatusCode, Json}; use typeshare::typeshare; use uuid::Uuid; use crate::{ auth::{ get_user_id_from_headers, github::{self, client::github_oauth_client}, google::{self, client::google_oauth_client}, oidc::{self, client::oidc_client}, }, config::core_config, helpers::query::get_user, state::jwt_client, }; use super::Variant; #[derive(Default)] pub struct AuthArgs { pub headers: HeaderMap, } #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EnumVariants, )] #[args(AuthArgs)] #[response(Response)] #[error(serror::Error)] #[variant_derive(Debug)] #[serde(tag = "type", content = "params")] #[allow(clippy::enum_variant_names, clippy::large_enum_variant)] pub enum AuthRequest { GetLoginOptions(GetLoginOptions), SignUpLocalUser(SignUpLocalUser), LoginLocalUser(LoginLocalUser), ExchangeForJwt(ExchangeForJwt), GetUser(GetUser), } pub fn router() -> Router { let mut router = Router::new() .route("/", post(handler)) .route("/{variant}", post(variant_handler)); if core_config().local_auth { info!("🔑 Local Login Enabled"); } if github_oauth_client().is_some() { info!("🔑 Github Login Enabled"); router = router.nest("/github", github::router()) } if google_oauth_client().is_some() { info!("🔑 Google Login Enabled"); router = router.nest("/google", google::router()) } if core_config().oidc_enabled { info!("🔑 OIDC Login Enabled"); router = router.nest("/oidc", oidc::router()) } router } async fn variant_handler( headers: HeaderMap, Path(Variant { variant }): Path, Json(params): Json, ) -> serror::Result { let req: AuthRequest = serde_json::from_value(json!({ "type": variant, "params": params, }))?; handler(headers, Json(req)).await } #[instrument(name = "AuthHandler", level = "debug", skip(headers))] async fn handler( headers: HeaderMap, Json(request): Json, ) -> serror::Result { let timer = Instant::now(); let req_id = Uuid::new_v4(); debug!( "/auth request {req_id} | METHOD: {:?}", request.extract_variant() ); let res = request.resolve(&AuthArgs { headers }).await; if let Err(e) = &res { debug!("/auth request {req_id} | error: {:#}", e.error); } let elapsed = timer.elapsed(); debug!("/auth request {req_id} | resolve time: {elapsed:?}"); res.map(|res| res.0) } fn login_options_reponse() -> &'static GetLoginOptionsResponse { static GET_LOGIN_OPTIONS_RESPONSE: OnceLock< GetLoginOptionsResponse, > = OnceLock::new(); GET_LOGIN_OPTIONS_RESPONSE.get_or_init(|| { let config = core_config(); GetLoginOptionsResponse { local: config.local_auth, github: github_oauth_client().is_some(), google: google_oauth_client().is_some(), oidc: oidc_client().load().is_some(), registration_disabled: config.disable_user_registration, } }) } impl Resolve for GetLoginOptions { #[instrument(name = "GetLoginOptions", level = "debug", skip(self))] async fn resolve( self, _: &AuthArgs, ) -> serror::Result { Ok(*login_options_reponse()) } } impl Resolve for ExchangeForJwt { #[instrument(name = "ExchangeForJwt", level = "debug", skip(self))] async fn resolve( self, _: &AuthArgs, ) -> serror::Result { jwt_client() .redeem_exchange_token(&self.token) .await .map_err(Into::into) } } impl Resolve for GetUser { #[instrument(name = "GetUser", level = "debug", skip(self))] async fn resolve( self, AuthArgs { headers }: &AuthArgs, ) -> serror::Result { let user_id = get_user_id_from_headers(headers) .await .status_code(StatusCode::UNAUTHORIZED)?; get_user(&user_id) .await .status_code(StatusCode::UNAUTHORIZED) } } ================================================ FILE: bin/core/src/api/execute/action.rs ================================================ use std::{ collections::HashSet, path::{Path, PathBuf}, str::FromStr, sync::OnceLock, }; use anyhow::Context; use command::run_komodo_command; use config::merge_objects; use database::mungos::{ by_id::update_one_by_id, mongodb::bson::to_document, }; use interpolate::Interpolator; use komodo_client::{ api::{ execute::{BatchExecutionResponse, BatchRunAction, RunAction}, user::{CreateApiKey, CreateApiKeyResponse, DeleteApiKey}, }, entities::{ FileFormat, JsonObject, action::Action, alert::{Alert, AlertData, SeverityLevel}, config::core::CoreConfig, komodo_timestamp, permission::PermissionLevel, update::Update, user::action_user, }, parsers::parse_key_value_list, }; use resolver_api::Resolve; use tokio::fs; use crate::{ alert::send_alerts, api::{execute::ExecuteRequest, user::UserArgs}, config::core_config, helpers::{ query::{VariablesAndSecrets, get_variables_and_secrets}, random_string, update::update_update, }, permission::get_check_permissions, resource::refresh_action_state_cache, state::{action_states, db_client}, }; use super::ExecuteArgs; impl super::BatchExecute for BatchRunAction { type Resource = Action; fn single_request(action: String) -> ExecuteRequest { ExecuteRequest::RunAction(RunAction { action, args: Default::default(), }) } } impl Resolve for BatchRunAction { #[instrument(name = "BatchRunAction", skip(self, user), fields(user_id = user.id))] async fn resolve( self, ExecuteArgs { user, .. }: &ExecuteArgs, ) -> serror::Result { Ok( super::batch_execute::(&self.pattern, user) .await?, ) } } impl Resolve for RunAction { #[instrument(name = "RunAction", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let mut action = get_check_permissions::( &self.action, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the action (or insert default). let action_state = action_states() .action .get_or_insert_default(&action.id) .await; // This will set action state back to default when dropped. // Will also check to ensure action not already busy before updating. let _action_guard = action_state.update_custom( |state| state.running += 1, |state| state.running -= 1, false, )?; let mut update = update.clone(); update_update(update.clone()).await?; let default_args = parse_action_arguments( &action.config.arguments, action.config.arguments_format, ) .context("Failed to parse default Action arguments")?; let args = merge_objects( default_args, self.args.unwrap_or_default(), true, true, ) .context("Failed to merge request args with default args")?; let args = serde_json::to_string(&args) .context("Failed to serialize action run arguments")?; let CreateApiKeyResponse { key, secret } = CreateApiKey { name: update.id.clone(), expires: 0, } .resolve(&UserArgs { user: action_user().to_owned(), }) .await?; let contents = &mut action.config.file_contents; // Wrap the file contents in the execution context. *contents = full_contents(contents, &args, &key, &secret); let replacers = interpolate(contents, &mut update, key.clone(), secret.clone()) .await? .into_iter() .collect::>(); let file = format!("{}.ts", random_string(10)); let path = core_config().action_directory.join(&file); if let Some(parent) = path.parent() { fs::create_dir_all(parent) .await .with_context(|| format!("Failed to initialize Action file parent directory {parent:?}"))?; } fs::write(&path, contents).await.with_context(|| { format!("Failed to write action file to {path:?}") })?; let CoreConfig { ssl_enabled, .. } = core_config(); let https_cert_flag = if *ssl_enabled { " --unsafely-ignore-certificate-errors=localhost" } else { "" }; let reload = if action.config.reload_deno_deps { " --reload" } else { "" }; let mut res = run_komodo_command( // Keep this stage name as is, the UI will find the latest update log by matching the stage name "Execute Action", None, format!( "deno run --allow-all{https_cert_flag}{reload} {}", path.display() ), ) .await; res.stdout = svi::replace_in_string(&res.stdout, &replacers) .replace(&key, ""); res.stderr = svi::replace_in_string(&res.stderr, &replacers) .replace(&secret, ""); cleanup_run(file + ".js", &path).await; if let Err(e) = (DeleteApiKey { key }) .resolve(&UserArgs { user: action_user().to_owned(), }) .await { warn!( "Failed to delete API key after action execution | {:#}", e.error ); }; update.logs.push(res); update.finalize(); // Need to manually update the update before cache refresh, // and before broadcast with update_update. // The Err case of to_document should be unreachable, // but will fail to update cache in that case. if let Ok(update_doc) = to_document(&update) { let _ = update_one_by_id( &db_client().updates, &update.id, database::mungos::update::Update::Set(update_doc), None, ) .await; refresh_action_state_cache().await; } update_update(update.clone()).await?; if !update.success && action.config.failure_alert { warn!("action unsuccessful, alerting..."); let target = update.target.clone(); tokio::spawn(async move { let alert = Alert { id: Default::default(), target, ts: komodo_timestamp(), resolved_ts: Some(komodo_timestamp()), resolved: true, level: SeverityLevel::Warning, data: AlertData::ActionFailed { id: action.id, name: action.name, }, }; send_alerts(&[alert]).await }); } Ok(update) } } async fn interpolate( contents: &mut String, update: &mut Update, key: String, secret: String, ) -> serror::Result> { let VariablesAndSecrets { variables, mut secrets, } = get_variables_and_secrets().await?; secrets.insert(String::from("ACTION_API_KEY"), key); secrets.insert(String::from("ACTION_API_SECRET"), secret); let mut interpolator = Interpolator::new(Some(&variables), &secrets); interpolator .interpolate_string(contents)? .push_logs(&mut update.logs); Ok(interpolator.secret_replacers) } fn full_contents( contents: &str, // Pre-serialized to JSON string. args: &str, key: &str, secret: &str, ) -> String { let CoreConfig { port, ssl_enabled, .. } = core_config(); let protocol = if *ssl_enabled { "https" } else { "http" }; let base_url = format!("{protocol}://localhost:{port}"); format!( "import {{ KomodoClient, Types }} from '{base_url}/client/lib.js'; import * as __YAML__ from 'jsr:@std/yaml'; import * as __TOML__ from 'jsr:@std/toml'; const YAML = {{ stringify: __YAML__.stringify, parse: __YAML__.parse, parseAll: __YAML__.parseAll, parseDockerCompose: __YAML__.parse, }} const TOML = {{ stringify: __TOML__.stringify, parse: __TOML__.parse, parseResourceToml: __TOML__.parse, parseCargoToml: __TOML__.parse, }} const ARGS = {args}; const komodo = KomodoClient('{base_url}', {{ type: 'api-key', params: {{ key: '{key}', secret: '{secret}' }} }}); async function main() {{ {contents} console.log('🦎 Action completed successfully 🦎'); }} main() .catch(error => {{ console.error('🚨 Action exited early with errors 🚨') if (error.status !== undefined && error.result !== undefined) {{ console.error('Status:', error.status); console.error(JSON.stringify(error.result, null, 2)); }} else {{ console.error(error); }} Deno.exit(1) }});" ) } /// Cleans up file at given path. /// ALSO if $DENO_DIR is set, /// will clean up the generated file matching "file" async fn cleanup_run(file: String, path: &Path) { if let Err(e) = fs::remove_file(path).await { warn!( "Failed to delete action file after action execution | {e:#}" ); } // If $DENO_DIR is set (will be in container), // will clean up the generated file matching "file" (NOT under path) let Some(deno_dir) = deno_dir() else { return; }; delete_file(deno_dir.join("gen/file"), file).await; } fn deno_dir() -> Option<&'static Path> { static DENO_DIR: OnceLock> = OnceLock::new(); DENO_DIR .get_or_init(|| { let deno_dir = std::env::var("DENO_DIR").ok()?; PathBuf::from_str(&deno_dir).ok() }) .as_deref() } /// file is just the terminating file path, /// it may be nested multiple folder under path, /// this will find the nested file and delete it. /// Assumes the file is only there once. fn delete_file( dir: PathBuf, file: String, ) -> std::pin::Pin + Send>> { Box::pin(async move { let Ok(mut dir) = fs::read_dir(dir).await else { return false; }; // Collect the nested folders for recursing // only after checking all the files in directory. let mut folders = Vec::::new(); while let Ok(Some(entry)) = dir.next_entry().await { let Ok(meta) = entry.metadata().await else { continue; }; if meta.is_file() { let Ok(name) = entry.file_name().into_string() else { continue; }; if name == file { if let Err(e) = fs::remove_file(entry.path()).await { warn!( "Failed to clean up generated file after action execution | {e:#}" ); }; return true; } } else { folders.push(entry.path()); } } if folders.len() == 1 { // unwrap ok, folders definitely is not empty let folder = folders.pop().unwrap(); delete_file(folder, file).await } else { // Check folders with file.clone for folder in folders { if delete_file(folder, file.clone()).await { return true; } } false } }) } fn parse_action_arguments( args: &str, format: FileFormat, ) -> anyhow::Result { match format { FileFormat::KeyValue => { let args = parse_key_value_list(args) .context("Failed to parse args as key value list")? .into_iter() .map(|(k, v)| (k, serde_json::Value::String(v))) .collect(); Ok(args) } FileFormat::Toml => toml::from_str(args) .context("Failed to parse Toml to Action args"), FileFormat::Yaml => serde_yaml_ng::from_str(args) .context("Failed to parse Yaml to action args"), FileFormat::Json => serde_json::from_str(args) .context("Failed to parse Json to action args"), } } ================================================ FILE: bin/core/src/api/execute/alerter.rs ================================================ use anyhow::{Context, anyhow}; use formatting::format_serror; use futures::{TryStreamExt, stream::FuturesUnordered}; use komodo_client::{ api::execute::{SendAlert, TestAlerter}, entities::{ alert::{Alert, AlertData, AlertDataVariant, SeverityLevel}, alerter::Alerter, komodo_timestamp, permission::PermissionLevel, }, }; use reqwest::StatusCode; use resolver_api::Resolve; use serror::AddStatusCodeError; use crate::{ alert::send_alert_to_alerter, helpers::update::update_update, permission::get_check_permissions, resource::list_full_for_user, }; use super::ExecuteArgs; impl Resolve for TestAlerter { #[instrument(name = "TestAlerter", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> Result { let alerter = get_check_permissions::( &self.alerter, user, PermissionLevel::Execute.into(), ) .await?; let mut update = update.clone(); if !alerter.config.enabled { update.push_error_log( "Test Alerter", String::from( "Alerter is disabled. Enable the Alerter to send alerts.", ), ); update.finalize(); update_update(update.clone()).await?; return Ok(update); } let ts = komodo_timestamp(); let alert = Alert { id: Default::default(), ts, resolved: true, level: SeverityLevel::Ok, target: update.target.clone(), data: AlertData::Test { id: alerter.id.clone(), name: alerter.name.clone(), }, resolved_ts: Some(ts), }; if let Err(e) = send_alert_to_alerter(&alerter, &alert).await { update.push_error_log("Test Alerter", format_serror(&e.into())); } else { update.push_simple_log("Test Alerter", String::from("Alert sent successfully. It should be visible at your alerting destination.")); }; update.finalize(); update_update(update.clone()).await?; Ok(update) } } // impl Resolve for SendAlert { #[instrument(name = "SendAlert", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> Result { let alerters = list_full_for_user::( Default::default(), user, PermissionLevel::Execute.into(), &[], ) .await? .into_iter() .filter(|a| { a.config.enabled && (self.alerters.is_empty() || self.alerters.contains(&a.name) || self.alerters.contains(&a.id)) && (a.config.alert_types.is_empty() || a.config.alert_types.contains(&AlertDataVariant::Custom)) }) .collect::>(); if alerters.is_empty() { return Err(anyhow!( "Could not find any valid alerters to send to, this required Execute permissions on the Alerter" ).status_code(StatusCode::BAD_REQUEST)); } let mut update = update.clone(); let ts = komodo_timestamp(); let alert = Alert { id: Default::default(), ts, resolved: true, level: self.level, target: update.target.clone(), data: AlertData::Custom { message: self.message, details: self.details, }, resolved_ts: Some(ts), }; update.push_simple_log( "Send alert", serde_json::to_string_pretty(&alert) .context("Failed to serialize alert to JSON")?, ); if let Err(e) = alerters .iter() .map(|alerter| send_alert_to_alerter(alerter, &alert)) .collect::>() .try_collect::>() .await { update.push_error_log("Send Error", format_serror(&e.into())); }; update.finalize(); update_update(update.clone()).await?; Ok(update) } } ================================================ FILE: bin/core/src/api/execute/build.rs ================================================ use std::{ collections::{HashMap, HashSet}, future::IntoFuture, time::Duration, }; use anyhow::{Context, anyhow}; use database::mungos::{ by_id::update_one_by_id, find::find_collect, mongodb::{ bson::{doc, to_bson, to_document}, options::FindOneOptions, }, }; use formatting::format_serror; use futures::future::join_all; use interpolate::Interpolator; use komodo_client::{ api::execute::{ BatchExecutionResponse, BatchRunBuild, CancelBuild, Deploy, RunBuild, }, entities::{ alert::{Alert, AlertData, SeverityLevel}, all_logs_success, build::{Build, BuildConfig}, builder::{Builder, BuilderConfig}, deployment::DeploymentState, komodo_timestamp, optional_string, permission::PermissionLevel, repo::Repo, update::{Log, Update}, user::auto_redeploy_user, }, }; use periphery_client::api; use resolver_api::Resolve; use tokio_util::sync::CancellationToken; use crate::{ alert::send_alerts, helpers::{ build_git_token, builder::{cleanup_builder_instance, get_builder_periphery}, channel::build_cancel_channel, query::{ VariablesAndSecrets, get_deployment_state, get_variables_and_secrets, }, registry_token, update::{init_execution_update, update_update}, }, permission::get_check_permissions, resource::{self, refresh_build_state_cache}, state::{action_states, db_client}, }; use super::{ExecuteArgs, ExecuteRequest}; impl super::BatchExecute for BatchRunBuild { type Resource = Build; fn single_request(build: String) -> ExecuteRequest { ExecuteRequest::RunBuild(RunBuild { build }) } } impl Resolve for BatchRunBuild { #[instrument(name = "BatchRunBuild", skip(user), fields(user_id = user.id))] async fn resolve( self, ExecuteArgs { user, .. }: &ExecuteArgs, ) -> serror::Result { Ok( super::batch_execute::(&self.pattern, user) .await?, ) } } impl Resolve for RunBuild { #[instrument(name = "RunBuild", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let mut build = get_check_permissions::( &self.build, user, PermissionLevel::Execute.into(), ) .await?; let mut repo = if !build.config.files_on_host && !build.config.linked_repo.is_empty() { crate::resource::get::(&build.config.linked_repo) .await? .into() } else { None }; let VariablesAndSecrets { mut variables, secrets, } = get_variables_and_secrets().await?; // Add the $VERSION to variables. Use with [[$VERSION]] variables.insert( String::from("$VERSION"), build.config.version.to_string(), ); if build.config.builder_id.is_empty() { return Err(anyhow!("Must attach builder to RunBuild").into()); } // get the action state for the build (or insert default). let action_state = action_states().build.get_or_insert_default(&build.id).await; // This will set action state back to default when dropped. // Will also check to ensure build not already busy before updating. let _action_guard = action_state.update(|state| state.building = true)?; if build.config.auto_increment_version { build.config.version.increment(); } let mut update = update.clone(); update.version = build.config.version; update_update(update.clone()).await?; let git_token = build_git_token(&mut build, repo.as_mut()).await?; let registry_tokens = validate_account_extract_registry_tokens(&build).await?; let cancel = CancellationToken::new(); let cancel_clone = cancel.clone(); let mut cancel_recv = build_cancel_channel().receiver.resubscribe(); let build_id = build.id.clone(); let builder = resource::get::(&build.config.builder_id).await?; let is_server_builder = matches!(&builder.config, BuilderConfig::Server(_)); tokio::spawn(async move { let poll = async { loop { let (incoming_build_id, mut update) = tokio::select! { _ = cancel_clone.cancelled() => return Ok(()), id = cancel_recv.recv() => id? }; if incoming_build_id == build_id { if is_server_builder { update.push_error_log("Cancel acknowledged", "Build cancellation is not possible on server builders at this time. Use an AWS builder to enable this feature."); } else { update.push_simple_log("Cancel acknowledged", "The build cancellation has been queued, it may still take some time."); } update.finalize(); let id = update.id.clone(); if let Err(e) = update_update(update).await { warn!("failed to modify Update {id} on db | {e:#}"); } if !is_server_builder { cancel_clone.cancel(); } return Ok(()); } } #[allow(unreachable_code)] anyhow::Ok(()) }; tokio::select! { _ = cancel_clone.cancelled() => {} _ = poll => {} } }); // GET BUILDER PERIPHERY let (periphery, cleanup_data) = match get_builder_periphery( build.name.clone(), Some(build.config.version), builder, &mut update, ) .await { Ok(builder) => builder, Err(e) => { warn!( "failed to get builder for build {} | {e:#}", build.name ); update.logs.push(Log::error( "get builder", format_serror(&e.context("failed to get builder").into()), )); return handle_early_return( update, build.id, build.name, false, ) .await; } }; // INTERPOLATE VARIABLES let secret_replacers = if !build.config.skip_secret_interp { let mut interpolator = Interpolator::new(Some(&variables), &secrets); interpolator.interpolate_build(&mut build)?; if let Some(repo) = repo.as_mut() { interpolator.interpolate_repo(repo)?; } interpolator.push_logs(&mut update.logs); interpolator.secret_replacers } else { Default::default() }; let commit_message = if !build.config.files_on_host && (!build.config.repo.is_empty() || !build.config.linked_repo.is_empty()) { // PULL OR CLONE REPO let res = tokio::select! { res = periphery .request(api::git::PullOrCloneRepo { args: repo.as_ref().map(Into::into).unwrap_or((&build).into()), git_token, environment: Default::default(), env_file_path: Default::default(), on_clone: None, on_pull: None, skip_secret_interp: Default::default(), replacers: Default::default(), }) => res, _ = cancel.cancelled() => { debug!("build cancelled during clone, cleaning up builder"); update.push_error_log("build cancelled", String::from("user cancelled build during repo clone")); cleanup_builder_instance(cleanup_data, &mut update) .await; info!("builder cleaned up"); return handle_early_return(update, build.id, build.name, true).await }, }; let commit_message = match res { Ok(res) => { debug!("finished repo clone"); update.logs.extend(res.res.logs); update.commit_hash = res.res.commit_hash.unwrap_or_default().to_string(); res.res.commit_message.unwrap_or_default() } Err(e) => { warn!("Failed build at clone repo | {e:#}"); update.push_error_log( "Clone Repo", format_serror(&e.context("Failed to clone repo").into()), ); Default::default() } }; update_update(update.clone()).await?; Some(commit_message) } else { None }; if all_logs_success(&update.logs) { // RUN BUILD let res = tokio::select! { res = periphery .request(api::build::Build { build: build.clone(), repo, registry_tokens, replacers: secret_replacers.into_iter().collect(), // To push a commit hash tagged image commit_hash: optional_string(&update.commit_hash), // Unused for now additional_tags: Default::default(), }) => res.context("failed at call to periphery to build"), _ = cancel.cancelled() => { info!("build cancelled during build, cleaning up builder"); update.push_error_log("build cancelled", String::from("user cancelled build during docker build")); cleanup_builder_instance(cleanup_data, &mut update) .await; return handle_early_return(update, build.id, build.name, true).await }, }; match res { Ok(logs) => { debug!("finished build"); update.logs.extend(logs); } Err(e) => { warn!("error in build | {e:#}"); update.push_error_log( "build", format_serror(&e.context("failed to build").into()), ) } }; } update.finalize(); let db = db_client(); if update.success { let _ = db .builds .update_one( doc! { "name": &build.name }, doc! { "$set": { "config.version": to_bson(&build.config.version) .context("failed at converting version to bson")?, "info.last_built_at": komodo_timestamp(), "info.built_hash": &update.commit_hash, "info.built_message": commit_message }}, ) .await; } // stop the cancel listening task from going forever cancel.cancel(); // If building on temporary cloud server (AWS), // this will terminate the server. cleanup_builder_instance(cleanup_data, &mut update).await; // Need to manually update the update before cache refresh, // and before broadcast with add_update. // The Err case of to_document should be unreachable, // but will fail to update cache in that case. if let Ok(update_doc) = to_document(&update) { let _ = update_one_by_id( &db.updates, &update.id, database::mungos::update::Update::Set(update_doc), None, ) .await; refresh_build_state_cache().await; } update_update(update.clone()).await?; if update.success { // don't hold response up for user tokio::spawn(async move { handle_post_build_redeploy(&build.id).await; }); } else { warn!("build unsuccessful, alerting..."); let target = update.target.clone(); let version = update.version; tokio::spawn(async move { let alert = Alert { id: Default::default(), target, ts: komodo_timestamp(), resolved_ts: Some(komodo_timestamp()), resolved: true, level: SeverityLevel::Warning, data: AlertData::BuildFailed { id: build.id, name: build.name, version, }, }; send_alerts(&[alert]).await }); } Ok(update.clone()) } } #[instrument(skip(update))] async fn handle_early_return( mut update: Update, build_id: String, build_name: String, is_cancel: bool, ) -> serror::Result { update.finalize(); // Need to manually update the update before cache refresh, // and before broadcast with add_update. // The Err case of to_document should be unreachable, // but will fail to update cache in that case. if let Ok(update_doc) = to_document(&update) { let _ = update_one_by_id( &db_client().updates, &update.id, database::mungos::update::Update::Set(update_doc), None, ) .await; refresh_build_state_cache().await; } update_update(update.clone()).await?; if !update.success && !is_cancel { warn!("build unsuccessful, alerting..."); let target = update.target.clone(); let version = update.version; tokio::spawn(async move { let alert = Alert { id: Default::default(), target, ts: komodo_timestamp(), resolved_ts: Some(komodo_timestamp()), resolved: true, level: SeverityLevel::Warning, data: AlertData::BuildFailed { id: build_id, name: build_name, version, }, }; send_alerts(&[alert]).await }); } Ok(update.clone()) } pub async fn validate_cancel_build( request: &ExecuteRequest, ) -> anyhow::Result<()> { if let ExecuteRequest::CancelBuild(req) = request { let build = resource::get::(&req.build).await?; let db = db_client(); let (latest_build, latest_cancel) = tokio::try_join!( db.updates .find_one(doc! { "operation": "RunBuild", "target.id": &build.id, },) .with_options( FindOneOptions::builder() .sort(doc! { "start_ts": -1 }) .build() ) .into_future(), db.updates .find_one(doc! { "operation": "CancelBuild", "target.id": &build.id, },) .with_options( FindOneOptions::builder() .sort(doc! { "start_ts": -1 }) .build() ) .into_future() )?; match (latest_build, latest_cancel) { (Some(build), Some(cancel)) => { if cancel.start_ts > build.start_ts { return Err(anyhow!("Build has already been cancelled")); } } (None, _) => return Err(anyhow!("No build in progress")), _ => {} }; } Ok(()) } impl Resolve for CancelBuild { #[instrument(name = "CancelBuild", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let build = get_check_permissions::( &self.build, user, PermissionLevel::Execute.into(), ) .await?; // make sure the build is building if !action_states() .build .get(&build.id) .await .and_then(|s| s.get().ok().map(|s| s.building)) .unwrap_or_default() { return Err(anyhow!("Build is not building.").into()); } let mut update = update.clone(); update.push_simple_log( "cancel triggered", "the build cancel has been triggered", ); update_update(update.clone()).await?; build_cancel_channel() .sender .lock() .await .send((build.id, update.clone()))?; // Make sure cancel is set to complete after some time in case // no reciever is there to do it. Prevents update stuck in InProgress. let update_id = update.id.clone(); tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(60)).await; if let Err(e) = update_one_by_id( &db_client().updates, &update_id, doc! { "$set": { "status": "Complete" } }, None, ) .await { warn!( "failed to set CancelBuild Update status Complete after timeout | {e:#}" ) } }); Ok(update) } } #[instrument] async fn handle_post_build_redeploy(build_id: &str) { let Ok(redeploy_deployments) = find_collect( &db_client().deployments, doc! { "config.image.params.build_id": build_id, "config.redeploy_on_build": true }, None, ) .await else { return; }; let futures = redeploy_deployments .into_iter() .map(|deployment| async move { let state = get_deployment_state(&deployment.id) .await .unwrap_or_default(); if state == DeploymentState::Running { let req = super::ExecuteRequest::Deploy(Deploy { deployment: deployment.id.clone(), stop_signal: None, stop_time: None, }); let user = auto_redeploy_user().to_owned(); let res = async { let update = init_execution_update(&req, &user).await?; Deploy { deployment: deployment.id.clone(), stop_signal: None, stop_time: None, } .resolve(&ExecuteArgs { user, update }) .await } .await; Some((deployment.id.clone(), res)) } else { None } }); for res in join_all(futures).await { let Some((id, res)) = res else { continue; }; if let Err(e) = res { warn!( "failed post build redeploy for deployment {id}: {:#}", e.error ); } } } /// This will make sure that a build with non-none image registry has an account attached, /// and will check the core config for a token matching requirements. /// Otherwise it is left to periphery. async fn validate_account_extract_registry_tokens( Build { config: BuildConfig { image_registry, .. }, .. }: &Build, // Maps (domain, account) -> token ) -> serror::Result> { let mut res = HashMap::with_capacity(image_registry.capacity()); for (domain, account) in image_registry .iter() .map(|r| (r.domain.as_str(), r.account.as_str())) // This ensures uniqueness / prevents redundant logins .collect::>() { if domain.is_empty() { continue; } if account.is_empty() { return Err( anyhow!( "Must attach account to use registry provider {domain}" ) .into(), ); } let Some(registry_token) = registry_token(domain, account).await.with_context( || format!("Failed to get registry token in call to db. Stopping run. | {domain} | {account}"), )? else { continue; }; res.insert( (domain.to_string(), account.to_string()), registry_token, ); } Ok( res .into_iter() .map(|((domain, account), token)| (domain, account, token)) .collect(), ) } ================================================ FILE: bin/core/src/api/execute/deployment.rs ================================================ use std::sync::OnceLock; use anyhow::{Context, anyhow}; use cache::TimeoutCache; use formatting::format_serror; use interpolate::Interpolator; use komodo_client::{ api::execute::*, entities::{ Version, build::{Build, ImageRegistryConfig}, deployment::{ Deployment, DeploymentImage, extract_registry_domain, }, komodo_timestamp, optional_string, permission::PermissionLevel, server::Server, update::{Log, Update}, user::User, }, }; use periphery_client::api; use resolver_api::Resolve; use crate::{ helpers::{ periphery_client, query::{VariablesAndSecrets, get_variables_and_secrets}, registry_token, update::update_update, }, monitor::update_cache_for_server, permission::get_check_permissions, resource, state::action_states, }; use super::{ExecuteArgs, ExecuteRequest}; impl super::BatchExecute for BatchDeploy { type Resource = Deployment; fn single_request(deployment: String) -> ExecuteRequest { ExecuteRequest::Deploy(Deploy { deployment, stop_signal: None, stop_time: None, }) } } impl Resolve for BatchDeploy { #[instrument(name = "BatchDeploy", skip(user), fields(user_id = user.id))] async fn resolve( self, ExecuteArgs { user, .. }: &ExecuteArgs, ) -> serror::Result { Ok( super::batch_execute::(&self.pattern, user) .await?, ) } } async fn setup_deployment_execution( deployment: &str, user: &User, ) -> anyhow::Result<(Deployment, Server)> { let deployment = get_check_permissions::( deployment, user, PermissionLevel::Execute.into(), ) .await?; if deployment.config.server_id.is_empty() { return Err(anyhow!("Deployment has no Server configured")); } let server = resource::get::(&deployment.config.server_id).await?; if !server.config.enabled { return Err(anyhow!("Attached Server is not enabled")); } Ok((deployment, server)) } impl Resolve for Deploy { #[instrument(name = "Deploy", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let (mut deployment, server) = setup_deployment_execution(&self.deployment, user).await?; // get the action state for the deployment (or insert default). let action_state = action_states() .deployment .get_or_insert_default(&deployment.id) .await; // Will check to ensure deployment not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.deploying = true)?; let mut update = update.clone(); // Send update after setting action state, this way frontend gets correct state. update_update(update.clone()).await?; // This block resolves the attached Build to an actual versioned image let (version, registry_token) = match &deployment.config.image { DeploymentImage::Build { build_id, version } => { let build = resource::get::(build_id).await?; let image_names = build.get_image_names(); let image_name = image_names .first() .context("No image name could be created") .context("Failed to create image name")?; let version = if version.is_none() { build.config.version } else { *version }; let version_str = version.to_string(); // Potentially add the build image_tag postfix let version_str = if build.config.image_tag.is_empty() { version_str } else { format!("{version_str}-{}", build.config.image_tag) }; // replace image with corresponding build image. deployment.config.image = DeploymentImage::Image { image: format!("{image_name}:{version_str}"), }; let first_registry = build .config .image_registry .first() .unwrap_or(ImageRegistryConfig::static_default()); if first_registry.domain.is_empty() { (version, None) } else { let ImageRegistryConfig { domain, account, .. } = first_registry; if deployment.config.image_registry_account.is_empty() { deployment.config.image_registry_account = account.to_string(); } let token = if !deployment .config .image_registry_account .is_empty() { registry_token(domain, &deployment.config.image_registry_account).await.with_context( || format!("Failed to get git token in call to db. Stopping run. | {domain} | {}", deployment.config.image_registry_account), )? } else { None }; (version, token) } } DeploymentImage::Image { image } => { let domain = extract_registry_domain(image)?; let token = if !deployment .config .image_registry_account .is_empty() { registry_token(&domain, &deployment.config.image_registry_account).await.with_context( || format!("Failed to get git token in call to db. Stopping run. | {domain} | {}", deployment.config.image_registry_account), )? } else { None }; (Version::default(), token) } }; // interpolate variables / secrets, returning the sanitizing replacers to send to // periphery so it may sanitize the final command for safe logging (avoids exposing secret values) let secret_replacers = if !deployment.config.skip_secret_interp { let VariablesAndSecrets { variables, secrets } = get_variables_and_secrets().await?; let mut interpolator = Interpolator::new(Some(&variables), &secrets); interpolator .interpolate_deployment(&mut deployment)? .push_logs(&mut update.logs); interpolator.secret_replacers } else { Default::default() }; update.version = version; update_update(update.clone()).await?; match periphery_client(&server)? .request(api::container::Deploy { deployment, stop_signal: self.stop_signal, stop_time: self.stop_time, registry_token, replacers: secret_replacers.into_iter().collect(), }) .await { Ok(log) => update.logs.push(log), Err(e) => { update.push_error_log( "Deploy Container", format_serror(&e.into()), ); } }; update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } /// Wait this long after a pull to allow another pull through const PULL_TIMEOUT: i64 = 5_000; type ServerId = String; type Image = String; type PullCache = TimeoutCache<(ServerId, Image), Log>; fn pull_cache() -> &'static PullCache { static PULL_CACHE: OnceLock = OnceLock::new(); PULL_CACHE.get_or_init(Default::default) } pub async fn pull_deployment_inner( deployment: Deployment, server: &Server, ) -> anyhow::Result { let (image, account, token) = match deployment.config.image { DeploymentImage::Build { build_id, version } => { let build = resource::get::(&build_id).await?; let image_names = build.get_image_names(); let image_name = image_names .first() .context("No image name could be created") .context("Failed to create image name")?; let version = if version.is_none() { build.config.version.to_string() } else { version.to_string() }; // Potentially add the build image_tag postfix let version = if build.config.image_tag.is_empty() { version } else { format!("{version}-{}", build.config.image_tag) }; // replace image with corresponding build image. let image = format!("{image_name}:{version}"); let first_registry = build .config .image_registry .first() .unwrap_or(ImageRegistryConfig::static_default()); if first_registry.domain.is_empty() { (image, None, None) } else { let ImageRegistryConfig { domain, account, .. } = first_registry; let account = if deployment.config.image_registry_account.is_empty() { account } else { &deployment.config.image_registry_account }; let token = if !account.is_empty() { registry_token(domain, account).await.with_context( || format!("Failed to get git token in call to db. Stopping run. | {domain} | {account}"), )? } else { None }; (image, optional_string(account), token) } } DeploymentImage::Image { image } => { let domain = extract_registry_domain(&image)?; let token = if !deployment .config .image_registry_account .is_empty() { registry_token(&domain, &deployment.config.image_registry_account).await.with_context( || format!("Failed to get git token in call to db. Stopping run. | {domain} | {}", deployment.config.image_registry_account), )? } else { None }; ( image, optional_string(&deployment.config.image_registry_account), token, ) } }; // Acquire the pull lock for this image on the server let lock = pull_cache() .get_lock((server.id.clone(), image.clone())) .await; // Lock the path lock, prevents simultaneous pulls by // ensuring simultaneous pulls will wait for first to finish // and checking cached results. let mut locked = lock.lock().await; // Early return from cache if lasted pulled with PULL_TIMEOUT if locked.last_ts + PULL_TIMEOUT > komodo_timestamp() { return locked.clone_res(); } let res = async { let log = match periphery_client(server)? .request(api::image::PullImage { name: image, account, token, }) .await { Ok(log) => log, Err(e) => Log::error("Pull image", format_serror(&e.into())), }; update_cache_for_server(server, true).await; anyhow::Ok(log) } .await; // Set the cache with results. Any other calls waiting on the lock will // then immediately also use this same result. locked.set(&res, komodo_timestamp()); res } impl Resolve for PullDeployment { #[instrument(name = "PullDeployment", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let (deployment, server) = setup_deployment_execution(&self.deployment, user).await?; // get the action state for the deployment (or insert default). let action_state = action_states() .deployment .get_or_insert_default(&deployment.id) .await; // Will check to ensure deployment not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.pulling = true)?; let mut update = update.clone(); // Send update after setting action state, this way frontend gets correct state. update_update(update.clone()).await?; let log = pull_deployment_inner(deployment, &server).await?; update.logs.push(log); update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for StartDeployment { #[instrument(name = "StartDeployment", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let (deployment, server) = setup_deployment_execution(&self.deployment, user).await?; // get the action state for the deployment (or insert default). let action_state = action_states() .deployment .get_or_insert_default(&deployment.id) .await; // Will check to ensure deployment not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.starting = true)?; let mut update = update.clone(); // Send update after setting action state, this way frontend gets correct state. update_update(update.clone()).await?; let log = match periphery_client(&server)? .request(api::container::StartContainer { name: deployment.name, }) .await { Ok(log) => log, Err(e) => Log::error( "start container", format_serror(&e.context("failed to start container").into()), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for RestartDeployment { #[instrument(name = "RestartDeployment", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let (deployment, server) = setup_deployment_execution(&self.deployment, user).await?; // get the action state for the deployment (or insert default). let action_state = action_states() .deployment .get_or_insert_default(&deployment.id) .await; // Will check to ensure deployment not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.restarting = true)?; let mut update = update.clone(); // Send update after setting action state, this way frontend gets correct state. update_update(update.clone()).await?; let log = match periphery_client(&server)? .request(api::container::RestartContainer { name: deployment.name, }) .await { Ok(log) => log, Err(e) => Log::error( "restart container", format_serror( &e.context("failed to restart container").into(), ), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for PauseDeployment { #[instrument(name = "PauseDeployment", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let (deployment, server) = setup_deployment_execution(&self.deployment, user).await?; // get the action state for the deployment (or insert default). let action_state = action_states() .deployment .get_or_insert_default(&deployment.id) .await; // Will check to ensure deployment not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.pausing = true)?; let mut update = update.clone(); // Send update after setting action state, this way frontend gets correct state. update_update(update.clone()).await?; let log = match periphery_client(&server)? .request(api::container::PauseContainer { name: deployment.name, }) .await { Ok(log) => log, Err(e) => Log::error( "pause container", format_serror(&e.context("failed to pause container").into()), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for UnpauseDeployment { #[instrument(name = "UnpauseDeployment", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let (deployment, server) = setup_deployment_execution(&self.deployment, user).await?; // get the action state for the deployment (or insert default). let action_state = action_states() .deployment .get_or_insert_default(&deployment.id) .await; // Will check to ensure deployment not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.unpausing = true)?; let mut update = update.clone(); // Send update after setting action state, this way frontend gets correct state. update_update(update.clone()).await?; let log = match periphery_client(&server)? .request(api::container::UnpauseContainer { name: deployment.name, }) .await { Ok(log) => log, Err(e) => Log::error( "unpause container", format_serror( &e.context("failed to unpause container").into(), ), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for StopDeployment { #[instrument(name = "StopDeployment", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let (deployment, server) = setup_deployment_execution(&self.deployment, user).await?; // get the action state for the deployment (or insert default). let action_state = action_states() .deployment .get_or_insert_default(&deployment.id) .await; // Will check to ensure deployment not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.stopping = true)?; let mut update = update.clone(); // Send update after setting action state, this way frontend gets correct state. update_update(update.clone()).await?; let log = match periphery_client(&server)? .request(api::container::StopContainer { name: deployment.name, signal: self .signal .unwrap_or(deployment.config.termination_signal) .into(), time: self .time .unwrap_or(deployment.config.termination_timeout) .into(), }) .await { Ok(log) => log, Err(e) => Log::error( "stop container", format_serror(&e.context("failed to stop container").into()), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl super::BatchExecute for BatchDestroyDeployment { type Resource = Deployment; fn single_request(deployment: String) -> ExecuteRequest { ExecuteRequest::DestroyDeployment(DestroyDeployment { deployment, signal: None, time: None, }) } } impl Resolve for BatchDestroyDeployment { #[instrument(name = "BatchDestroyDeployment", skip(user), fields(user_id = user.id))] async fn resolve( self, ExecuteArgs { user, .. }: &ExecuteArgs, ) -> serror::Result { Ok( super::batch_execute::( &self.pattern, user, ) .await?, ) } } impl Resolve for DestroyDeployment { #[instrument(name = "DestroyDeployment", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let (deployment, server) = setup_deployment_execution(&self.deployment, user).await?; // get the action state for the deployment (or insert default). let action_state = action_states() .deployment .get_or_insert_default(&deployment.id) .await; // Will check to ensure deployment not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.destroying = true)?; let mut update = update.clone(); // Send update after setting action state, this way frontend gets correct state. update_update(update.clone()).await?; let log = match periphery_client(&server)? .request(api::container::RemoveContainer { name: deployment.name, signal: self .signal .unwrap_or(deployment.config.termination_signal) .into(), time: self .time .unwrap_or(deployment.config.termination_timeout) .into(), }) .await { Ok(log) => log, Err(e) => Log::error( "stop container", format_serror(&e.context("failed to stop container").into()), ), }; update.logs.push(log); update.finalize(); update_cache_for_server(&server, true).await; update_update(update.clone()).await?; Ok(update) } } ================================================ FILE: bin/core/src/api/execute/maintenance.rs ================================================ use std::sync::OnceLock; use anyhow::{Context, anyhow}; use command::run_komodo_command; use database::mungos::{find::find_collect, mongodb::bson::doc}; use formatting::{bold, format_serror}; use komodo_client::{ api::execute::{ BackupCoreDatabase, ClearRepoCache, GlobalAutoUpdate, }, entities::{ deployment::DeploymentState, server::ServerState, stack::StackState, }, }; use reqwest::StatusCode; use resolver_api::Resolve; use serror::AddStatusCodeError; use tokio::sync::Mutex; use crate::{ api::execute::{ ExecuteArgs, pull_deployment_inner, pull_stack_inner, }, config::core_config, helpers::update::update_update, state::{ db_client, deployment_status_cache, server_status_cache, stack_status_cache, }, }; /// Makes sure the method can only be called once at a time fn clear_repo_cache_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(Default::default) } impl Resolve for ClearRepoCache { #[instrument( name = "ClearRepoCache", skip(user, update), fields(user_id = user.id, update_id = update.id) )] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> Result { if !user.admin { return Err( anyhow!("This method is admin only.") .status_code(StatusCode::FORBIDDEN), ); } let _lock = clear_repo_cache_lock() .try_lock() .context("Clear already in progress...")?; let mut update = update.clone(); let mut contents = tokio::fs::read_dir(&core_config().repo_directory) .await .context("Failed to read repo cache directory")?; loop { let path = match contents .next_entry() .await .context("Failed to read contents at path") { Ok(Some(contents)) => contents.path(), Ok(None) => break, Err(e) => { update.push_error_log( "Read Directory", format_serror(&e.into()), ); continue; } }; if path.is_dir() { match tokio::fs::remove_dir_all(&path) .await .context("Failed to clear contents at path") { Ok(_) => {} Err(e) => { update.push_error_log( "Clear Directory", format_serror(&e.into()), ); } }; } } update.finalize(); update_update(update.clone()).await?; Ok(update) } } // /// Makes sure the method can only be called once at a time fn backup_database_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(Default::default) } impl Resolve for BackupCoreDatabase { #[instrument( name = "BackupCoreDatabase", skip(user, update), fields(user_id = user.id, update_id = update.id) )] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> Result { if !user.admin { return Err( anyhow!("This method is admin only.") .status_code(StatusCode::FORBIDDEN), ); } let _lock = backup_database_lock() .try_lock() .context("Backup already in progress...")?; let mut update = update.clone(); update_update(update.clone()).await?; let res = run_komodo_command( "Backup Core Database", None, "km database backup --yes", ) .await; update.logs.push(res); update.finalize(); update_update(update.clone()).await?; Ok(update) } } // /// Makes sure the method can only be called once at a time fn global_update_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(Default::default) } impl Resolve for GlobalAutoUpdate { #[instrument( name = "GlobalAutoUpdate", skip(user, update), fields(user_id = user.id, update_id = update.id) )] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> Result { if !user.admin { return Err( anyhow!("This method is admin only.") .status_code(StatusCode::FORBIDDEN), ); } let _lock = global_update_lock() .try_lock() .context("Global update already in progress...")?; let mut update = update.clone(); update_update(update.clone()).await?; // This is all done in sequence because there is no rush, // the pulls / deploys happen spaced out to ease the load on system. let servers = find_collect(&db_client().servers, None, None) .await .context("Failed to query for servers from database")?; let query = doc! { "$or": [ { "config.poll_for_updates": true }, { "config.auto_update": true } ] }; let (stacks, repos) = tokio::try_join!( find_collect(&db_client().stacks, query.clone(), None), find_collect(&db_client().repos, None, None) ) .context("Failed to query for resources from database")?; let server_status_cache = server_status_cache(); let stack_status_cache = stack_status_cache(); // Will be edited later at update.logs[0] update.push_simple_log("Auto Pull", String::new()); for stack in stacks { let Some(status) = stack_status_cache.get(&stack.id).await else { continue; }; // Only pull running stacks. if !matches!(status.curr.state, StackState::Running) { continue; } if let Some(server) = servers.iter().find(|s| s.id == stack.config.server_id) // This check is probably redundant along with running check // but shouldn't hurt && server_status_cache .get(&server.id) .await .map(|s| matches!(s.state, ServerState::Ok)) .unwrap_or_default() { let name = stack.name.clone(); let repo = if stack.config.linked_repo.is_empty() { None } else { let Some(repo) = repos.iter().find(|r| r.id == stack.config.linked_repo) else { update.push_error_log( &format!("Pull Stack {name}"), format!( "Did not find any Repo matching {}", stack.config.linked_repo ), ); continue; }; Some(repo.clone()) }; if let Err(e) = pull_stack_inner(stack, Vec::new(), server, repo, None) .await { update.push_error_log( &format!("Pull Stack {name}"), format_serror(&e.into()), ); } else { if !update.logs[0].stdout.is_empty() { update.logs[0].stdout.push('\n'); } update.logs[0] .stdout .push_str(&format!("Pulled Stack {} ✅", bold(name))); } } } let deployment_status_cache = deployment_status_cache(); let deployments = find_collect(&db_client().deployments, query, None) .await .context("Failed to query for deployments from database")?; for deployment in deployments { let Some(status) = deployment_status_cache.get(&deployment.id).await else { continue; }; // Only pull running deployments. if !matches!(status.curr.state, DeploymentState::Running) { continue; } if let Some(server) = servers.iter().find(|s| s.id == deployment.config.server_id) // This check is probably redundant along with running check // but shouldn't hurt && server_status_cache .get(&server.id) .await .map(|s| matches!(s.state, ServerState::Ok)) .unwrap_or_default() { let name = deployment.name.clone(); if let Err(e) = pull_deployment_inner(deployment, server).await { update.push_error_log( &format!("Pull Deployment {name}"), format_serror(&e.into()), ); } else { if !update.logs[0].stdout.is_empty() { update.logs[0].stdout.push('\n'); } update.logs[0].stdout.push_str(&format!( "Pulled Deployment {} ✅", bold(name) )); } } } update.finalize(); update_update(update.clone()).await?; Ok(update) } } ================================================ FILE: bin/core/src/api/execute/mod.rs ================================================ use std::{pin::Pin, time::Instant}; use anyhow::Context; use axum::{ Extension, Router, extract::Path, middleware, routing::post, }; use axum_extra::{TypedHeader, headers::ContentType}; use database::mungos::by_id::find_one_by_id; use derive_variants::{EnumVariants, ExtractVariant}; use formatting::format_serror; use futures::future::join_all; use komodo_client::{ api::execute::*, entities::{ Operation, permission::PermissionLevel, update::{Log, Update}, user::User, }, }; use resolver_api::Resolve; use response::JsonString; use serde::{Deserialize, Serialize}; use serde_json::json; use serror::Json; use typeshare::typeshare; use uuid::Uuid; use crate::{ auth::auth_request, helpers::update::{init_execution_update, update_update}, resource::{KomodoResource, list_full_for_user_using_pattern}, state::db_client, }; mod action; mod alerter; mod build; mod deployment; mod maintenance; mod procedure; mod repo; mod server; mod stack; mod sync; use super::Variant; pub use { deployment::pull_deployment_inner, stack::pull_stack_inner, }; pub struct ExecuteArgs { pub user: User, pub update: Update, } #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EnumVariants, )] #[variant_derive(Debug)] #[args(ExecuteArgs)] #[response(JsonString)] #[error(serror::Error)] #[serde(tag = "type", content = "params")] pub enum ExecuteRequest { // ==== SERVER ==== StartContainer(StartContainer), RestartContainer(RestartContainer), PauseContainer(PauseContainer), UnpauseContainer(UnpauseContainer), StopContainer(StopContainer), DestroyContainer(DestroyContainer), StartAllContainers(StartAllContainers), RestartAllContainers(RestartAllContainers), PauseAllContainers(PauseAllContainers), UnpauseAllContainers(UnpauseAllContainers), StopAllContainers(StopAllContainers), PruneContainers(PruneContainers), DeleteNetwork(DeleteNetwork), PruneNetworks(PruneNetworks), DeleteImage(DeleteImage), PruneImages(PruneImages), DeleteVolume(DeleteVolume), PruneVolumes(PruneVolumes), PruneDockerBuilders(PruneDockerBuilders), PruneBuildx(PruneBuildx), PruneSystem(PruneSystem), // ==== STACK ==== DeployStack(DeployStack), BatchDeployStack(BatchDeployStack), DeployStackIfChanged(DeployStackIfChanged), BatchDeployStackIfChanged(BatchDeployStackIfChanged), PullStack(PullStack), BatchPullStack(BatchPullStack), StartStack(StartStack), RestartStack(RestartStack), StopStack(StopStack), PauseStack(PauseStack), UnpauseStack(UnpauseStack), DestroyStack(DestroyStack), BatchDestroyStack(BatchDestroyStack), RunStackService(RunStackService), // ==== DEPLOYMENT ==== Deploy(Deploy), BatchDeploy(BatchDeploy), PullDeployment(PullDeployment), StartDeployment(StartDeployment), RestartDeployment(RestartDeployment), PauseDeployment(PauseDeployment), UnpauseDeployment(UnpauseDeployment), StopDeployment(StopDeployment), DestroyDeployment(DestroyDeployment), BatchDestroyDeployment(BatchDestroyDeployment), // ==== BUILD ==== RunBuild(RunBuild), BatchRunBuild(BatchRunBuild), CancelBuild(CancelBuild), // ==== REPO ==== CloneRepo(CloneRepo), BatchCloneRepo(BatchCloneRepo), PullRepo(PullRepo), BatchPullRepo(BatchPullRepo), BuildRepo(BuildRepo), BatchBuildRepo(BatchBuildRepo), CancelRepoBuild(CancelRepoBuild), // ==== PROCEDURE ==== RunProcedure(RunProcedure), BatchRunProcedure(BatchRunProcedure), // ==== ACTION ==== RunAction(RunAction), BatchRunAction(BatchRunAction), // ==== ALERTER ==== TestAlerter(TestAlerter), SendAlert(SendAlert), // ==== SYNC ==== RunSync(RunSync), // ==== MAINTENANCE ==== ClearRepoCache(ClearRepoCache), BackupCoreDatabase(BackupCoreDatabase), GlobalAutoUpdate(GlobalAutoUpdate), } pub fn router() -> Router { Router::new() .route("/", post(handler)) .route("/{variant}", post(variant_handler)) .layer(middleware::from_fn(auth_request)) } async fn variant_handler( user: Extension, Path(Variant { variant }): Path, Json(params): Json, ) -> serror::Result<(TypedHeader, String)> { let req: ExecuteRequest = serde_json::from_value(json!({ "type": variant, "params": params, }))?; handler(user, Json(req)).await } async fn handler( Extension(user): Extension, Json(request): Json, ) -> serror::Result<(TypedHeader, String)> { let res = match inner_handler(request, user).await? { ExecutionResult::Single(update) => serde_json::to_string(&update) .context("Failed to serialize Update")?, ExecutionResult::Batch(res) => res, }; Ok((TypedHeader(ContentType::json()), res)) } #[typeshare(serialized_as = "Update")] type BoxUpdate = Box; pub enum ExecutionResult { Single(BoxUpdate), /// The batch contents will be pre serialized here Batch(String), } pub fn inner_handler( request: ExecuteRequest, user: User, ) -> Pin< Box< dyn std::future::Future> + Send, >, > { Box::pin(async move { let req_id = Uuid::new_v4(); // Need to validate no cancel is active before any update is created. // This ensures no double update created if Cancel is called more than once for the same request. build::validate_cancel_build(&request).await?; repo::validate_cancel_repo_build(&request).await?; let update = init_execution_update(&request, &user).await?; // This will be the case for the Batch exections, // they don't have their own updates. // The batch calls also call "inner_handler" themselves, // and in their case will spawn tasks, so that isn't necessary // here either. if update.operation == Operation::None { return Ok(ExecutionResult::Batch( task(req_id, request, user, update).await?, )); } // Spawn a task for the execution which continues // running after this method returns. let handle = tokio::spawn(task(req_id, request, user, update.clone())); // Spawns another task to monitor the first for failures, // and add the log to Update about it (which primary task can't do because it errored out) tokio::spawn({ let update_id = update.id.clone(); async move { let log = match handle.await { Ok(Err(e)) => { warn!("/execute request {req_id} task error: {e:#}",); Log::error("Task Error", format_serror(&e.into())) } Err(e) => { warn!("/execute request {req_id} spawn error: {e:?}",); Log::error("Spawn Error", format!("{e:#?}")) } _ => return, }; let res = async { // Nothing to do if update was never actually created, // which is the case when the id is empty. if update_id.is_empty() { return Ok(()); } let mut update = find_one_by_id(&db_client().updates, &update_id) .await .context("failed to query to db")? .context("no update exists with given id")?; update.logs.push(log); update.finalize(); update_update(update).await } .await; if let Err(e) = res { warn!( "failed to update update with task error log | {e:#}" ); } } }); Ok(ExecutionResult::Single(update.into())) }) } #[instrument( name = "ExecuteRequest", skip(user, update), fields( user_id = user.id, update_id = update.id, request = format!("{:?}", request.extract_variant())) ) ] async fn task( req_id: Uuid, request: ExecuteRequest, user: User, update: Update, ) -> anyhow::Result { info!("/execute request {req_id} | user: {}", user.username); let timer = Instant::now(); let res = match request.resolve(&ExecuteArgs { user, update }).await { Err(e) => Err(e.error), Ok(JsonString::Err(e)) => Err( anyhow::Error::from(e).context("failed to serialize response"), ), Ok(JsonString::Ok(res)) => Ok(res), }; if let Err(e) = &res { warn!("/execute request {req_id} error: {e:#}"); } let elapsed = timer.elapsed(); debug!("/execute request {req_id} | resolve time: {elapsed:?}"); res } trait BatchExecute { type Resource: KomodoResource; fn single_request(name: String) -> ExecuteRequest; } async fn batch_execute( pattern: &str, user: &User, ) -> anyhow::Result { let resources = list_full_for_user_using_pattern::( pattern, Default::default(), user, PermissionLevel::Execute.into(), &[], ) .await?; let futures = resources.into_iter().map(|resource| { let user = user.clone(); async move { inner_handler(E::single_request(resource.name.clone()), user) .await .map(|r| { let ExecutionResult::Single(update) = r else { unreachable!() }; update }) .map_err(|e| BatchExecutionResponseItemErr { name: resource.name, error: e.into(), }) .into() } }); Ok(join_all(futures).await) } ================================================ FILE: bin/core/src/api/execute/procedure.rs ================================================ use std::pin::Pin; use database::mungos::{ by_id::update_one_by_id, mongodb::bson::to_document, }; use formatting::{Color, bold, colored, format_serror, muted}; use komodo_client::{ api::execute::{ BatchExecutionResponse, BatchRunProcedure, RunProcedure, }, entities::{ alert::{Alert, AlertData, SeverityLevel}, komodo_timestamp, permission::PermissionLevel, procedure::Procedure, update::Update, user::User, }, }; use resolver_api::Resolve; use tokio::sync::Mutex; use crate::{ alert::send_alerts, helpers::{procedure::execute_procedure, update::update_update}, permission::get_check_permissions, resource::refresh_procedure_state_cache, state::{action_states, db_client}, }; use super::{ExecuteArgs, ExecuteRequest}; impl super::BatchExecute for BatchRunProcedure { type Resource = Procedure; fn single_request(procedure: String) -> ExecuteRequest { ExecuteRequest::RunProcedure(RunProcedure { procedure }) } } impl Resolve for BatchRunProcedure { #[instrument(name = "BatchRunProcedure", skip(user), fields(user_id = user.id))] async fn resolve( self, ExecuteArgs { user, .. }: &ExecuteArgs, ) -> serror::Result { Ok( super::batch_execute::(&self.pattern, user) .await?, ) } } impl Resolve for RunProcedure { #[instrument(name = "RunProcedure", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { Ok( resolve_inner(self.procedure, user.clone(), update.clone()) .await?, ) } } fn resolve_inner( procedure: String, user: User, mut update: Update, ) -> Pin< Box< dyn std::future::Future> + Send, >, > { Box::pin(async move { let procedure = get_check_permissions::( &procedure, &user, PermissionLevel::Execute.into(), ) .await?; // Need to push the initial log, as execute_procedure // assumes first log is already created // and will panic otherwise. update.push_simple_log( "Execute procedure", format!( "{}: executing procedure '{}'", muted("INFO"), bold(&procedure.name) ), ); // get the action state for the procedure (or insert default). let action_state = action_states() .procedure .get_or_insert_default(&procedure.id) .await; // This will set action state back to default when dropped. // Will also check to ensure procedure not already busy before updating. let _action_guard = action_state.update(|state| state.running = true)?; update_update(update.clone()).await?; let update = Mutex::new(update); let res = execute_procedure(&procedure, &update).await; let mut update = update.into_inner(); match res { Ok(_) => { update.push_simple_log( "Execution ok", format!( "{}: The procedure has {} with no errors", muted("INFO"), colored("completed", Color::Green) ), ); } Err(e) => update .push_error_log("execution error", format_serror(&e.into())), } update.finalize(); // Need to manually update the update before cache refresh, // and before broadcast with add_update. // The Err case of to_document should be unreachable, // but will fail to update cache in that case. if let Ok(update_doc) = to_document(&update) { let _ = update_one_by_id( &db_client().updates, &update.id, database::mungos::update::Update::Set(update_doc), None, ) .await; refresh_procedure_state_cache().await; } update_update(update.clone()).await?; if !update.success && procedure.config.failure_alert { warn!("procedure unsuccessful, alerting..."); let target = update.target.clone(); tokio::spawn(async move { let alert = Alert { id: Default::default(), target, ts: komodo_timestamp(), resolved_ts: Some(komodo_timestamp()), resolved: true, level: SeverityLevel::Warning, data: AlertData::ProcedureFailed { id: procedure.id, name: procedure.name, }, }; send_alerts(&[alert]).await }); } Ok(update) }) } ================================================ FILE: bin/core/src/api/execute/repo.rs ================================================ use std::{collections::HashSet, future::IntoFuture, time::Duration}; use anyhow::{Context, anyhow}; use database::mungos::{ by_id::update_one_by_id, mongodb::{ bson::{doc, to_document}, options::FindOneOptions, }, }; use formatting::format_serror; use interpolate::Interpolator; use komodo_client::{ api::{execute::*, write::RefreshRepoCache}, entities::{ alert::{Alert, AlertData, SeverityLevel}, builder::{Builder, BuilderConfig}, komodo_timestamp, permission::PermissionLevel, repo::Repo, server::Server, update::{Log, Update}, }, }; use periphery_client::api; use resolver_api::Resolve; use tokio_util::sync::CancellationToken; use crate::{ alert::send_alerts, api::write::WriteArgs, helpers::{ builder::{cleanup_builder_instance, get_builder_periphery}, channel::repo_cancel_channel, git_token, periphery_client, query::{VariablesAndSecrets, get_variables_and_secrets}, update::update_update, }, permission::get_check_permissions, resource::{self, refresh_repo_state_cache}, state::{action_states, db_client}, }; use super::{ExecuteArgs, ExecuteRequest}; impl super::BatchExecute for BatchCloneRepo { type Resource = Repo; fn single_request(repo: String) -> ExecuteRequest { ExecuteRequest::CloneRepo(CloneRepo { repo }) } } impl Resolve for BatchCloneRepo { #[instrument(name = "BatchCloneRepo", skip( user), fields(user_id = user.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { Ok( super::batch_execute::(&self.pattern, user) .await?, ) } } impl Resolve for CloneRepo { #[instrument(name = "CloneRepo", skip( user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let mut repo = get_check_permissions::( &self.repo, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the repo (or insert default). let action_state = action_states().repo.get_or_insert_default(&repo.id).await; // This will set action state back to default when dropped. // Will also check to ensure repo not already busy before updating. let _action_guard = action_state.update(|state| state.cloning = true)?; let mut update = update.clone(); update_update(update.clone()).await?; if repo.config.server_id.is_empty() { return Err(anyhow!("repo has no server attached").into()); } let git_token = git_token( &repo.config.git_provider, &repo.config.git_account, |https| repo.config.git_https = https, ) .await .with_context( || format!("Failed to get git token in call to db. This is a database error, not a token exisitence error. Stopping run. | {} | {}", repo.config.git_provider, repo.config.git_account), )?; let server = resource::get::(&repo.config.server_id).await?; let periphery = periphery_client(&server)?; // interpolate variables / secrets, returning the sanitizing replacers to send to // periphery so it may sanitize the final command for safe logging (avoids exposing secret values) let secret_replacers = interpolate(&mut repo, &mut update).await?; let logs = match periphery .request(api::git::CloneRepo { args: (&repo).into(), git_token, environment: repo.config.env_vars()?, env_file_path: repo.config.env_file_path, on_clone: repo.config.on_clone.into(), on_pull: repo.config.on_pull.into(), skip_secret_interp: repo.config.skip_secret_interp, replacers: secret_replacers.into_iter().collect(), }) .await { Ok(res) => res.res.logs, Err(e) => { vec![Log::error( "Clone Repo", format_serror(&e.context("Failed to clone repo").into()), )] } }; update.logs.extend(logs); update.finalize(); if update.success { update_last_pulled_time(&repo.name).await; } if let Err(e) = (RefreshRepoCache { repo: repo.id }) .resolve(&WriteArgs { user: user.clone() }) .await .map_err(|e| e.error) .context("Failed to refresh repo cache") { update.push_error_log( "Refresh Repo cache", format_serror(&e.into()), ); }; handle_repo_update_return(update).await } } impl super::BatchExecute for BatchPullRepo { type Resource = Repo; fn single_request(repo: String) -> ExecuteRequest { ExecuteRequest::PullRepo(PullRepo { repo }) } } impl Resolve for BatchPullRepo { #[instrument(name = "BatchPullRepo", skip(user), fields(user_id = user.id))] async fn resolve( self, ExecuteArgs { user, .. }: &ExecuteArgs, ) -> serror::Result { Ok( super::batch_execute::(&self.pattern, user) .await?, ) } } impl Resolve for PullRepo { #[instrument(name = "PullRepo", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let mut repo = get_check_permissions::( &self.repo, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the repo (or insert default). let action_state = action_states().repo.get_or_insert_default(&repo.id).await; // This will set action state back to default when dropped. // Will also check to ensure repo not already busy before updating. let _action_guard = action_state.update(|state| state.pulling = true)?; let mut update = update.clone(); update_update(update.clone()).await?; if repo.config.server_id.is_empty() { return Err(anyhow!("repo has no server attached").into()); } let git_token = git_token( &repo.config.git_provider, &repo.config.git_account, |https| repo.config.git_https = https, ) .await .with_context( || format!("Failed to get git token in call to db. This is a database error, not a token exisitence error. Stopping run. | {} | {}", repo.config.git_provider, repo.config.git_account), )?; let server = resource::get::(&repo.config.server_id).await?; let periphery = periphery_client(&server)?; // interpolate variables / secrets, returning the sanitizing replacers to send to // periphery so it may sanitize the final command for safe logging (avoids exposing secret values) let secret_replacers = interpolate(&mut repo, &mut update).await?; let logs = match periphery .request(api::git::PullRepo { args: (&repo).into(), git_token, environment: repo.config.env_vars()?, env_file_path: repo.config.env_file_path, on_pull: repo.config.on_pull.into(), skip_secret_interp: repo.config.skip_secret_interp, replacers: secret_replacers.into_iter().collect(), }) .await { Ok(res) => { update.commit_hash = res.res.commit_hash.unwrap_or_default(); res.res.logs } Err(e) => { vec![Log::error( "pull repo", format_serror(&e.context("failed to pull repo").into()), )] } }; update.logs.extend(logs); update.finalize(); if update.success { update_last_pulled_time(&repo.name).await; } if let Err(e) = (RefreshRepoCache { repo: repo.id }) .resolve(&WriteArgs { user: user.clone() }) .await .map_err(|e| e.error) .context("Failed to refresh repo cache") { update.push_error_log( "Refresh Repo cache", format_serror(&e.into()), ); }; handle_repo_update_return(update).await } } #[instrument(skip_all, fields(update_id = update.id))] async fn handle_repo_update_return( update: Update, ) -> serror::Result { // Need to manually update the update before cache refresh, // and before broadcast with add_update. // The Err case of to_document should be unreachable, // but will fail to update cache in that case. if let Ok(update_doc) = to_document(&update) { let _ = update_one_by_id( &db_client().updates, &update.id, database::mungos::update::Update::Set(update_doc), None, ) .await; refresh_repo_state_cache().await; } update_update(update.clone()).await?; Ok(update) } #[instrument] async fn update_last_pulled_time(repo_name: &str) { let res = db_client() .repos .update_one( doc! { "name": repo_name }, doc! { "$set": { "info.last_pulled_at": komodo_timestamp() } }, ) .await; if let Err(e) = res { warn!( "failed to update repo last_pulled_at | repo: {repo_name} | {e:#}", ); } } impl super::BatchExecute for BatchBuildRepo { type Resource = Repo; fn single_request(repo: String) -> ExecuteRequest { ExecuteRequest::CloneRepo(CloneRepo { repo }) } } impl Resolve for BatchBuildRepo { #[instrument(name = "BatchBuildRepo", skip(user), fields(user_id = user.id))] async fn resolve( self, ExecuteArgs { user, .. }: &ExecuteArgs, ) -> serror::Result { Ok( super::batch_execute::(&self.pattern, user) .await?, ) } } impl Resolve for BuildRepo { #[instrument(name = "BuildRepo", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let mut repo = get_check_permissions::( &self.repo, user, PermissionLevel::Execute.into(), ) .await?; if repo.config.builder_id.is_empty() { return Err(anyhow!("Must attach builder to BuildRepo").into()); } // get the action state for the repo (or insert default). let action_state = action_states().repo.get_or_insert_default(&repo.id).await; // This will set action state back to default when dropped. // Will also check to ensure repo not already busy before updating. let _action_guard = action_state.update(|state| state.building = true)?; let mut update = update.clone(); update_update(update.clone()).await?; let git_token = git_token( &repo.config.git_provider, &repo.config.git_account, |https| repo.config.git_https = https, ) .await .with_context( || format!("Failed to get git token in call to db. This is a database error, not a token exisitence error. Stopping run. | {} | {}", repo.config.git_provider, repo.config.git_account), )?; let cancel = CancellationToken::new(); let cancel_clone = cancel.clone(); let mut cancel_recv = repo_cancel_channel().receiver.resubscribe(); let repo_id = repo.id.clone(); let builder = resource::get::(&repo.config.builder_id).await?; let is_server_builder = matches!(&builder.config, BuilderConfig::Server(_)); tokio::spawn(async move { let poll = async { loop { let (incoming_repo_id, mut update) = tokio::select! { _ = cancel_clone.cancelled() => return Ok(()), id = cancel_recv.recv() => id? }; if incoming_repo_id == repo_id { if is_server_builder { update.push_error_log("Cancel acknowledged", "Repo Build cancellation is not possible on server builders at this time. Use an AWS builder to enable this feature."); } else { update.push_simple_log("Cancel acknowledged", "The repo build cancellation has been queued, it may still take some time."); } update.finalize(); let id = update.id.clone(); if let Err(e) = update_update(update).await { warn!("failed to modify Update {id} on db | {e:#}"); } if !is_server_builder { cancel_clone.cancel(); } return Ok(()); } } #[allow(unreachable_code)] anyhow::Ok(()) }; tokio::select! { _ = cancel_clone.cancelled() => {} _ = poll => {} } }); // GET BUILDER PERIPHERY let (periphery, cleanup_data) = match get_builder_periphery( repo.name.clone(), None, builder, &mut update, ) .await { Ok(builder) => builder, Err(e) => { warn!("failed to get builder for repo {} | {e:#}", repo.name); update.logs.push(Log::error( "get builder", format_serror(&e.context("failed to get builder").into()), )); return handle_builder_early_return( update, repo.id, repo.name, false, ) .await; } }; // CLONE REPO // interpolate variables / secrets, returning the sanitizing replacers to send to // periphery so it may sanitize the final command for safe logging (avoids exposing secret values) let secret_replacers = interpolate(&mut repo, &mut update).await?; let res = tokio::select! { res = periphery .request(api::git::CloneRepo { args: (&repo).into(), git_token, environment: repo.config.env_vars()?, env_file_path: repo.config.env_file_path, on_clone: repo.config.on_clone.into(), on_pull: repo.config.on_pull.into(), skip_secret_interp: repo.config.skip_secret_interp, replacers: secret_replacers.into_iter().collect() }) => res, _ = cancel.cancelled() => { debug!("build cancelled during clone, cleaning up builder"); update.push_error_log("build cancelled", String::from("user cancelled build during repo clone")); cleanup_builder_instance(cleanup_data, &mut update) .await; info!("builder cleaned up"); return handle_builder_early_return(update, repo.id, repo.name, true).await }, }; let commit_message = match res { Ok(res) => { debug!("finished repo clone"); update.logs.extend(res.res.logs); update.commit_hash = res.res.commit_hash.unwrap_or_default(); res.res.commit_message.unwrap_or_default() } Err(e) => { update.push_error_log( "Clone Repo", format_serror(&e.context("Failed to clone repo").into()), ); Default::default() } }; update.finalize(); let db = db_client(); if update.success { let _ = db .repos .update_one( doc! { "name": &repo.name }, doc! { "$set": { "info.last_built_at": komodo_timestamp(), "info.built_hash": &update.commit_hash, "info.built_message": commit_message }}, ) .await; } // stop the cancel listening task from going forever cancel.cancel(); // If building on temporary cloud server (AWS), // this will terminate the server. cleanup_builder_instance(cleanup_data, &mut update).await; // Need to manually update the update before cache refresh, // and before broadcast with add_update. // The Err case of to_document should be unreachable, // but will fail to update cache in that case. if let Ok(update_doc) = to_document(&update) { let _ = update_one_by_id( &db.updates, &update.id, database::mungos::update::Update::Set(update_doc), None, ) .await; refresh_repo_state_cache().await; } update_update(update.clone()).await?; if !update.success { warn!("repo build unsuccessful, alerting..."); let target = update.target.clone(); tokio::spawn(async move { let alert = Alert { id: Default::default(), target, ts: komodo_timestamp(), resolved_ts: Some(komodo_timestamp()), resolved: true, level: SeverityLevel::Warning, data: AlertData::RepoBuildFailed { id: repo.id, name: repo.name, }, }; send_alerts(&[alert]).await }); } Ok(update) } } #[instrument(skip(update))] async fn handle_builder_early_return( mut update: Update, repo_id: String, repo_name: String, is_cancel: bool, ) -> serror::Result { update.finalize(); // Need to manually update the update before cache refresh, // and before broadcast with add_update. // The Err case of to_document should be unreachable, // but will fail to update cache in that case. if let Ok(update_doc) = to_document(&update) { let _ = update_one_by_id( &db_client().updates, &update.id, database::mungos::update::Update::Set(update_doc), None, ) .await; refresh_repo_state_cache().await; } update_update(update.clone()).await?; if !update.success && !is_cancel { warn!("repo build unsuccessful, alerting..."); let target = update.target.clone(); tokio::spawn(async move { let alert = Alert { id: Default::default(), target, ts: komodo_timestamp(), resolved_ts: Some(komodo_timestamp()), resolved: true, level: SeverityLevel::Warning, data: AlertData::RepoBuildFailed { id: repo_id, name: repo_name, }, }; send_alerts(&[alert]).await }); } Ok(update) } #[instrument(skip_all)] pub async fn validate_cancel_repo_build( request: &ExecuteRequest, ) -> anyhow::Result<()> { if let ExecuteRequest::CancelRepoBuild(req) = request { let repo = resource::get::(&req.repo).await?; let db = db_client(); let (latest_build, latest_cancel) = tokio::try_join!( db.updates .find_one(doc! { "operation": "BuildRepo", "target.id": &repo.id, },) .with_options( FindOneOptions::builder() .sort(doc! { "start_ts": -1 }) .build() ) .into_future(), db.updates .find_one(doc! { "operation": "CancelRepoBuild", "target.id": &repo.id, },) .with_options( FindOneOptions::builder() .sort(doc! { "start_ts": -1 }) .build() ) .into_future() )?; match (latest_build, latest_cancel) { (Some(build), Some(cancel)) => { if cancel.start_ts > build.start_ts { return Err(anyhow!( "Repo build has already been cancelled" )); } } (None, _) => return Err(anyhow!("No repo build in progress")), _ => {} }; } Ok(()) } impl Resolve for CancelRepoBuild { #[instrument(name = "CancelRepoBuild", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let repo = get_check_permissions::( &self.repo, user, PermissionLevel::Execute.into(), ) .await?; // make sure the build is building if !action_states() .repo .get(&repo.id) .await .and_then(|s| s.get().ok().map(|s| s.building)) .unwrap_or_default() { return Err(anyhow!("Repo is not building.").into()); } let mut update = update.clone(); update.push_simple_log( "cancel triggered", "the repo build cancel has been triggered", ); update_update(update.clone()).await?; repo_cancel_channel() .sender .lock() .await .send((repo.id, update.clone()))?; // Make sure cancel is set to complete after some time in case // no reciever is there to do it. Prevents update stuck in InProgress. let update_id = update.id.clone(); tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(60)).await; if let Err(e) = update_one_by_id( &db_client().updates, &update_id, doc! { "$set": { "status": "Complete" } }, None, ) .await { warn!( "failed to set CancelRepoBuild Update status Complete after timeout | {e:#}" ) } }); Ok(update) } } async fn interpolate( repo: &mut Repo, update: &mut Update, ) -> anyhow::Result> { if !repo.config.skip_secret_interp { let VariablesAndSecrets { variables, secrets } = get_variables_and_secrets().await?; let mut interpolator = Interpolator::new(Some(&variables), &secrets); interpolator .interpolate_repo(repo)? .push_logs(&mut update.logs); Ok(interpolator.secret_replacers) } else { Ok(Default::default()) } } ================================================ FILE: bin/core/src/api/execute/server.rs ================================================ use anyhow::Context; use formatting::format_serror; use komodo_client::{ api::execute::*, entities::{ all_logs_success, permission::PermissionLevel, server::Server, update::{Log, Update}, }, }; use periphery_client::api; use resolver_api::Resolve; use crate::{ helpers::{periphery_client, update::update_update}, monitor::update_cache_for_server, permission::get_check_permissions, state::action_states, }; use super::ExecuteArgs; impl Resolve for StartContainer { #[instrument(name = "StartContainer", skip(self, user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the server (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure deployment not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state .update(|state| state.starting_containers = true)?; let mut update = update.clone(); // Send update after setting action state, this way frontend gets correct state. update_update(update.clone()).await?; let periphery = periphery_client(&server)?; let log = match periphery .request(api::container::StartContainer { name: self.container, }) .await { Ok(log) => log, Err(e) => Log::error( "start container", format_serror(&e.context("failed to start container").into()), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for RestartContainer { #[instrument(name = "RestartContainer", skip(self, user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the deployment (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure server not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state .update(|state| state.restarting_containers = true)?; let mut update = update.clone(); // Send update after setting action state, this way frontend gets correct state. update_update(update.clone()).await?; let periphery = periphery_client(&server)?; let log = match periphery .request(api::container::RestartContainer { name: self.container, }) .await { Ok(log) => log, Err(e) => Log::error( "restart container", format_serror( &e.context("failed to restart container").into(), ), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for PauseContainer { #[instrument(name = "PauseContainer", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the server (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure server not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.pausing_containers = true)?; let mut update = update.clone(); // Send update after setting action state, this way frontend gets correct state. update_update(update.clone()).await?; let periphery = periphery_client(&server)?; let log = match periphery .request(api::container::PauseContainer { name: self.container, }) .await { Ok(log) => log, Err(e) => Log::error( "pause container", format_serror(&e.context("failed to pause container").into()), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for UnpauseContainer { #[instrument(name = "UnpauseContainer", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the server (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure server not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state .update(|state| state.unpausing_containers = true)?; let mut update = update.clone(); // Send update after setting action state, this way frontend gets correct state. update_update(update.clone()).await?; let periphery = periphery_client(&server)?; let log = match periphery .request(api::container::UnpauseContainer { name: self.container, }) .await { Ok(log) => log, Err(e) => Log::error( "unpause container", format_serror( &e.context("failed to unpause container").into(), ), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for StopContainer { #[instrument(name = "StopContainer", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the server (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure server not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state .update(|state| state.stopping_containers = true)?; let mut update = update.clone(); // Send update after setting action state, this way frontend gets correct state. update_update(update.clone()).await?; let periphery = periphery_client(&server)?; let log = match periphery .request(api::container::StopContainer { name: self.container, signal: self.signal, time: self.time, }) .await { Ok(log) => log, Err(e) => Log::error( "stop container", format_serror(&e.context("failed to stop container").into()), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for DestroyContainer { #[instrument(name = "DestroyContainer", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let DestroyContainer { server, container, signal, time, } = self; let server = get_check_permissions::( &server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the server (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure server not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.pruning_containers = true)?; let mut update = update.clone(); // Send update after setting action state, this way frontend gets correct state. update_update(update.clone()).await?; let periphery = periphery_client(&server)?; let log = match periphery .request(api::container::RemoveContainer { name: container, signal, time, }) .await { Ok(log) => log, Err(e) => Log::error( "stop container", format_serror(&e.context("failed to stop container").into()), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for StartAllContainers { #[instrument(name = "StartAllContainers", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the server (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure server not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state .update(|state| state.starting_containers = true)?; let mut update = update.clone(); update_update(update.clone()).await?; let logs = periphery_client(&server)? .request(api::container::StartAllContainers {}) .await .context("failed to start all containers on host")?; update.logs.extend(logs); if all_logs_success(&update.logs) { update.push_simple_log( "start all containers", String::from("All containers have been started on the host."), ); } update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for RestartAllContainers { #[instrument(name = "RestartAllContainers", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the server (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure server not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state .update(|state| state.restarting_containers = true)?; let mut update = update.clone(); update_update(update.clone()).await?; let logs = periphery_client(&server)? .request(api::container::RestartAllContainers {}) .await .context("failed to restart all containers on host")?; update.logs.extend(logs); if all_logs_success(&update.logs) { update.push_simple_log( "restart all containers", String::from( "All containers have been restarted on the host.", ), ); } update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for PauseAllContainers { #[instrument(name = "PauseAllContainers", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the server (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure server not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.pausing_containers = true)?; let mut update = update.clone(); update_update(update.clone()).await?; let logs = periphery_client(&server)? .request(api::container::PauseAllContainers {}) .await .context("failed to pause all containers on host")?; update.logs.extend(logs); if all_logs_success(&update.logs) { update.push_simple_log( "pause all containers", String::from("All containers have been paused on the host."), ); } update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for UnpauseAllContainers { #[instrument(name = "UnpauseAllContainers", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the server (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure server not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state .update(|state| state.unpausing_containers = true)?; let mut update = update.clone(); update_update(update.clone()).await?; let logs = periphery_client(&server)? .request(api::container::UnpauseAllContainers {}) .await .context("failed to unpause all containers on host")?; update.logs.extend(logs); if all_logs_success(&update.logs) { update.push_simple_log( "unpause all containers", String::from( "All containers have been unpaused on the host.", ), ); } update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for StopAllContainers { #[instrument(name = "StopAllContainers", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the server (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure server not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state .update(|state| state.stopping_containers = true)?; let mut update = update.clone(); update_update(update.clone()).await?; let logs = periphery_client(&server)? .request(api::container::StopAllContainers {}) .await .context("failed to stop all containers on host")?; update.logs.extend(logs); if all_logs_success(&update.logs) { update.push_simple_log( "stop all containers", String::from("All containers have been stopped on the host."), ); } update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for PruneContainers { #[instrument(name = "PruneContainers", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the server (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure server not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.pruning_containers = true)?; let mut update = update.clone(); update_update(update.clone()).await?; let periphery = periphery_client(&server)?; let log = match periphery .request(api::container::PruneContainers {}) .await .context(format!( "failed to prune containers on server {}", server.name )) { Ok(log) => log, Err(e) => Log::error( "prune containers", format_serror( &e.context("failed to prune containers").into(), ), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for DeleteNetwork { #[instrument(name = "DeleteNetwork", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; let mut update = update.clone(); update_update(update.clone()).await?; let periphery = periphery_client(&server)?; let log = match periphery .request(api::network::DeleteNetwork { name: self.name.clone(), }) .await .context(format!( "failed to delete network {} on server {}", self.name, server.name )) { Ok(log) => log, Err(e) => Log::error( "delete network", format_serror( &e.context(format!( "failed to delete network {}", self.name )) .into(), ), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for PruneNetworks { #[instrument(name = "PruneNetworks", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the server (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure server not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.pruning_networks = true)?; let mut update = update.clone(); update_update(update.clone()).await?; let periphery = periphery_client(&server)?; let log = match periphery .request(api::network::PruneNetworks {}) .await .context(format!( "failed to prune networks on server {}", server.name )) { Ok(log) => log, Err(e) => Log::error( "prune networks", format_serror(&e.context("failed to prune networks").into()), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for DeleteImage { #[instrument(name = "DeleteImage", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; let mut update = update.clone(); update_update(update.clone()).await?; let periphery = periphery_client(&server)?; let log = match periphery .request(api::image::DeleteImage { name: self.name.clone(), }) .await .context(format!( "failed to delete image {} on server {}", self.name, server.name )) { Ok(log) => log, Err(e) => Log::error( "delete image", format_serror( &e.context(format!("failed to delete image {}", self.name)) .into(), ), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for PruneImages { #[instrument(name = "PruneImages", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the server (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure server not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.pruning_images = true)?; let mut update = update.clone(); update_update(update.clone()).await?; let periphery = periphery_client(&server)?; let log = match periphery.request(api::image::PruneImages {}).await { Ok(log) => log, Err(e) => Log::error( "prune images", format!( "failed to prune images on server {} | {e:#?}", server.name ), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for DeleteVolume { #[instrument(name = "DeleteVolume", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; let mut update = update.clone(); update_update(update.clone()).await?; let periphery = periphery_client(&server)?; let log = match periphery .request(api::volume::DeleteVolume { name: self.name.clone(), }) .await .context(format!( "failed to delete volume {} on server {}", self.name, server.name )) { Ok(log) => log, Err(e) => Log::error( "delete volume", format_serror( &e.context(format!( "failed to delete volume {}", self.name )) .into(), ), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for PruneVolumes { #[instrument(name = "PruneVolumes", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the server (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure server not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.pruning_volumes = true)?; let mut update = update.clone(); update_update(update.clone()).await?; let periphery = periphery_client(&server)?; let log = match periphery.request(api::volume::PruneVolumes {}).await { Ok(log) => log, Err(e) => Log::error( "prune volumes", format!( "failed to prune volumes on server {} | {e:#?}", server.name ), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for PruneDockerBuilders { #[instrument(name = "PruneDockerBuilders", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the server (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure server not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.pruning_builders = true)?; let mut update = update.clone(); update_update(update.clone()).await?; let periphery = periphery_client(&server)?; let log = match periphery.request(api::build::PruneBuilders {}).await { Ok(log) => log, Err(e) => Log::error( "prune builders", format!( "failed to docker builder prune on server {} | {e:#?}", server.name ), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for PruneBuildx { #[instrument(name = "PruneBuildx", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the server (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure server not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.pruning_buildx = true)?; let mut update = update.clone(); update_update(update.clone()).await?; let periphery = periphery_client(&server)?; let log = match periphery.request(api::build::PruneBuildx {}).await { Ok(log) => log, Err(e) => Log::error( "prune buildx", format!( "failed to docker buildx prune on server {} | {e:#?}", server.name ), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for PruneSystem { #[instrument(name = "PruneSystem", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Execute.into(), ) .await?; // get the action state for the server (or insert default). let action_state = action_states() .server .get_or_insert_default(&server.id) .await; // Will check to ensure server not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.pruning_system = true)?; let mut update = update.clone(); update_update(update.clone()).await?; let periphery = periphery_client(&server)?; let log = match periphery.request(api::PruneSystem {}).await { Ok(log) => log, Err(e) => Log::error( "prune system", format!( "failed to docker system prune on server {} | {e:#?}", server.name ), ), }; update.logs.push(log); update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } ================================================ FILE: bin/core/src/api/execute/stack.rs ================================================ use std::{collections::HashSet, str::FromStr}; use anyhow::Context; use database::mungos::mongodb::bson::{ doc, oid::ObjectId, to_bson, to_document, }; use formatting::format_serror; use interpolate::Interpolator; use komodo_client::{ api::{execute::*, write::RefreshStackCache}, entities::{ FileContents, permission::PermissionLevel, repo::Repo, server::Server, stack::{ Stack, StackFileRequires, StackInfo, StackRemoteFileContents, }, update::{Log, Update}, user::User, }, }; use periphery_client::api::compose::*; use resolver_api::Resolve; use crate::{ api::write::WriteArgs, helpers::{ periphery_client, query::{VariablesAndSecrets, get_variables_and_secrets}, stack_git_token, update::{ add_update_without_send, init_execution_update, update_update, }, }, monitor::update_cache_for_server, permission::get_check_permissions, resource, stack::{execute::execute_compose, get_stack_and_server}, state::{action_states, db_client}, }; use super::{ExecuteArgs, ExecuteRequest}; impl super::BatchExecute for BatchDeployStack { type Resource = Stack; fn single_request(stack: String) -> ExecuteRequest { ExecuteRequest::DeployStack(DeployStack { stack, services: Vec::new(), stop_time: None, }) } } impl Resolve for BatchDeployStack { #[instrument(name = "BatchDeployStack", skip(user), fields(user_id = user.id))] async fn resolve( self, ExecuteArgs { user, .. }: &ExecuteArgs, ) -> serror::Result { Ok( super::batch_execute::(&self.pattern, user) .await?, ) } } impl Resolve for DeployStack { #[instrument(name = "DeployStack", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let (mut stack, server) = get_stack_and_server( &self.stack, user, PermissionLevel::Execute.into(), true, ) .await?; let mut repo = if !stack.config.files_on_host && !stack.config.linked_repo.is_empty() { crate::resource::get::(&stack.config.linked_repo) .await? .into() } else { None }; // get the action state for the stack (or insert default). let action_state = action_states().stack.get_or_insert_default(&stack.id).await; // Will check to ensure stack not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.deploying = true)?; let mut update = update.clone(); update_update(update.clone()).await?; if !self.services.is_empty() { update.logs.push(Log::simple( "Service/s", format!( "Execution requested for Stack service/s {}", self.services.join(", ") ), )) } let git_token = stack_git_token(&mut stack, repo.as_mut()).await?; let registry_token = crate::helpers::registry_token( &stack.config.registry_provider, &stack.config.registry_account, ).await.with_context( || format!("Failed to get registry token in call to db. Stopping run. | {} | {}", stack.config.registry_provider, stack.config.registry_account), )?; // interpolate variables / secrets, returning the sanitizing replacers to send to // periphery so it may sanitize the final command for safe logging (avoids exposing secret values) let secret_replacers = if !stack.config.skip_secret_interp { let VariablesAndSecrets { variables, secrets } = get_variables_and_secrets().await?; let mut interpolator = Interpolator::new(Some(&variables), &secrets); interpolator.interpolate_stack(&mut stack)?; if let Some(repo) = repo.as_mut() && !repo.config.skip_secret_interp { interpolator.interpolate_repo(repo)?; } interpolator.push_logs(&mut update.logs); interpolator.secret_replacers } else { Default::default() }; let ComposeUpResponse { logs, deployed, services, file_contents, missing_files, remote_errors, compose_config, commit_hash, commit_message, } = periphery_client(&server)? .request(ComposeUp { stack: stack.clone(), services: self.services, repo, git_token, registry_token, replacers: secret_replacers.into_iter().collect(), }) .await?; update.logs.extend(logs); let update_info = async { let latest_services = if services.is_empty() { // maybe better to do something else here for services. stack.info.latest_services.clone() } else { services }; // This ensures to get the latest project name, // as it may have changed since the last deploy. let project_name = stack.project_name(true); let ( deployed_services, deployed_contents, deployed_config, deployed_hash, deployed_message, ) = if deployed { ( Some(latest_services.clone()), Some( file_contents .iter() .map(|f| FileContents { path: f.path.clone(), contents: f.contents.clone(), }) .collect(), ), compose_config, commit_hash.clone(), commit_message.clone(), ) } else { ( stack.info.deployed_services, stack.info.deployed_contents, stack.info.deployed_config, stack.info.deployed_hash, stack.info.deployed_message, ) }; let info = StackInfo { missing_files, deployed_project_name: project_name.into(), deployed_services, deployed_contents, deployed_config, deployed_hash, deployed_message, latest_services, remote_contents: stack .config .file_contents .is_empty() .then_some(file_contents), remote_errors: stack .config .file_contents .is_empty() .then_some(remote_errors), latest_hash: commit_hash, latest_message: commit_message, }; let info = to_document(&info) .context("failed to serialize stack info to bson")?; db_client() .stacks .update_one( doc! { "name": &stack.name }, doc! { "$set": { "info": info } }, ) .await .context("failed to update stack info on db")?; anyhow::Ok(()) }; // This will be weird with single service deploys. Come back to it. if let Err(e) = update_info.await { update.push_error_log( "refresh stack info", format_serror( &e.context("failed to refresh stack info on db").into(), ), ) } // Ensure cached stack state up to date by updating server cache update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl super::BatchExecute for BatchDeployStackIfChanged { type Resource = Stack; fn single_request(stack: String) -> ExecuteRequest { ExecuteRequest::DeployStackIfChanged(DeployStackIfChanged { stack, stop_time: None, }) } } impl Resolve for BatchDeployStackIfChanged { #[instrument(name = "BatchDeployStackIfChanged", skip(user), fields(user_id = user.id))] async fn resolve( self, ExecuteArgs { user, .. }: &ExecuteArgs, ) -> serror::Result { Ok( super::batch_execute::( &self.pattern, user, ) .await?, ) } } impl Resolve for DeployStackIfChanged { #[instrument(name = "DeployStackIfChanged", skip(user, update), fields(user_id = user.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let stack = get_check_permissions::( &self.stack, user, PermissionLevel::Execute.into(), ) .await?; RefreshStackCache { stack: stack.id.clone(), } .resolve(&WriteArgs { user: user.clone() }) .await?; let stack = resource::get::(&stack.id).await?; let action = match ( &stack.info.deployed_contents, &stack.info.remote_contents, ) { (Some(deployed_contents), Some(latest_contents)) => { let services = stack .info .latest_services .iter() .map(|s| s.service_name.clone()) .collect::>(); resolve_deploy_if_changed_action( deployed_contents, latest_contents, &services, ) } (None, _) => DeployIfChangedAction::FullDeploy, _ => DeployIfChangedAction::Services { deploy: Vec::new(), restart: Vec::new(), }, }; let mut update = update.clone(); match action { // Existing path pre 1.19.1 DeployIfChangedAction::FullDeploy => { // Don't actually send it here, let the handler send it after it can set action state. // This is usually done in crate::helpers::update::init_execution_update. update.id = add_update_without_send(&update).await?; DeployStack { stack: stack.name, services: Vec::new(), stop_time: self.stop_time, } .resolve(&ExecuteArgs { user: user.clone(), update, }) .await } DeployIfChangedAction::FullRestart => { // For git repo based stacks, need to do a // PullStack in order to ensure latest repo contents on the // host before restart. maybe_pull_stack(&stack, Some(&mut update)).await?; let mut update = restart_services(stack.name, Vec::new(), user).await?; if update.success { // Need to update 'info.deployed_contents' with the // latest contents so next check doesn't read the same diff. update_deployed_contents_with_latest( &stack.id, stack.info.remote_contents, &mut update, ) .await; } Ok(update) } DeployIfChangedAction::Services { deploy, restart } => { match (deploy.is_empty(), restart.is_empty()) { // Both empty, nothing to do (true, true) => { update.push_simple_log( "Diff compose files", String::from( "Deploy cancelled after no changes detected.", ), ); update.finalize(); Ok(update) } // Only restart (true, false) => { // For git repo based stacks, need to do a // PullStack in order to ensure latest repo contents on the // host before restart. Only necessary if no "deploys" (deploy already pulls stack). maybe_pull_stack(&stack, Some(&mut update)).await?; let mut update = restart_services(stack.name, restart, user).await?; if update.success { // Need to update 'info.deployed_contents' with the // latest contents so next check doesn't read the same diff. update_deployed_contents_with_latest( &stack.id, stack.info.remote_contents, &mut update, ) .await; } Ok(update) } // Only deploy (false, true) => { deploy_services(stack.name, deploy, user).await } // Deploy then restart, returning non-db update with executed services. (false, false) => { update.push_simple_log( "Execute Deploys", format!("Deploying: {}", deploy.join(", "),), ); // This already updates 'stack.info.deployed_services', // restart doesn't require this again. let deploy_update = deploy_services(stack.name.clone(), deploy, user) .await?; if !deploy_update.success { update.push_error_log( "Execute Deploys", String::from("There was a failure in service deploy"), ); update.finalize(); return Ok(update); } update.push_simple_log( "Execute Restarts", format!("Restarting: {}", restart.join(", "),), ); let restart_update = restart_services(stack.name, restart, user).await?; if !restart_update.success { update.push_error_log( "Execute Restarts", String::from( "There was a failure in a service restart", ), ); } update.finalize(); Ok(update) } } } } } } async fn deploy_services( stack: String, services: Vec, user: &User, ) -> serror::Result { // The existing update is initialized to DeployStack, // but also has not been created on database. // Setup a new update here. let req = ExecuteRequest::DeployStack(DeployStack { stack, services, stop_time: None, }); let update = init_execution_update(&req, user).await?; let ExecuteRequest::DeployStack(req) = req else { unreachable!() }; req .resolve(&ExecuteArgs { user: user.clone(), update, }) .await } async fn restart_services( stack: String, services: Vec, user: &User, ) -> serror::Result { // The existing update is initialized to DeployStack, // but also has not been created on database. // Setup a new update here. let req = ExecuteRequest::RestartStack(RestartStack { stack, services }); let update = init_execution_update(&req, user).await?; let ExecuteRequest::RestartStack(req) = req else { unreachable!() }; req .resolve(&ExecuteArgs { user: user.clone(), update, }) .await } /// This can safely be called in [DeployStackIfChanged] /// when there are ONLY changes to config files requiring restart, /// AFTER the restart has been successfully completed. /// /// In the case the if changed action is not FullDeploy, /// the only file diff possible is to config files. /// Also note either full or service deploy will already update 'deployed_contents' /// making this method unnecessary in those cases. /// /// Changes to config files after restart is applied should /// be taken as the deployed contents, otherwise next changed check /// will restart service again for no reason. async fn update_deployed_contents_with_latest( id: &str, contents: Option>, update: &mut Update, ) { let Some(contents) = contents else { return; }; let contents = contents .into_iter() .map(|f| FileContents { path: f.path, contents: f.contents, }) .collect::>(); if let Err(e) = (async { let contents = to_bson(&contents) .context("Failed to serialize contents to bson")?; let id = ObjectId::from_str(id).context("Id is not valid ObjectId")?; db_client() .stacks .update_one( doc! { "_id": id }, doc! { "$set": { "info.deployed_contents": contents } }, ) .await .context("Failed to update stack 'deployed_contents'")?; anyhow::Ok(()) }) .await { update.push_error_log( "Update content cache", format_serror(&e.into()), ); update.finalize(); let _ = update_update(update.clone()).await; } } enum DeployIfChangedAction { /// Changes to any compose or env files /// always lead to this. FullDeploy, /// If the above is not met, then changes to /// any changed additional file with `requires = "Restart"` /// and empty services array will lead to this. FullRestart, /// If all changed additional files have specific services /// they depend on, collect the final necessary /// services to deploy / restart. /// If eg `deploy` is empty, no services will be redeployed, same for `restart`. /// If both are empty, nothing is to be done. Services { deploy: Vec, restart: Vec, }, } fn resolve_deploy_if_changed_action( deployed_contents: &[FileContents], latest_contents: &[StackRemoteFileContents], all_services: &[String], ) -> DeployIfChangedAction { let mut full_restart = false; let mut deploy = HashSet::::new(); let mut restart = HashSet::::new(); for latest in latest_contents { let Some(deployed) = deployed_contents.iter().find(|c| c.path == latest.path) else { // If file doesn't exist in deployed contents, do full // deploy to align this. return DeployIfChangedAction::FullDeploy; }; // Ignore unchanged files if latest.contents == deployed.contents { continue; } match (latest.requires, latest.services.is_empty()) { (StackFileRequires::Redeploy, true) => { // File has requires = "Redeploy" at global level. // Can do early return here. return DeployIfChangedAction::FullDeploy; } (StackFileRequires::Redeploy, false) => { // Requires redeploy on specific services deploy.extend(latest.services.clone()); } (StackFileRequires::Restart, true) => { // Services empty -> Full restart full_restart = true; } (StackFileRequires::Restart, false) => { restart.extend(latest.services.clone()); } (StackFileRequires::None, _) => { // File can be ignored even with changes. continue; } } } match (full_restart, deploy.is_empty()) { // Full restart required with NO deploys needed -> Full Restart (true, true) => DeployIfChangedAction::FullRestart, // Full restart required WITH deploys needed -> Deploy those, restart all others (true, false) => DeployIfChangedAction::Services { restart: all_services .iter() // Only keep ones that don't need deploy .filter(|&s| !deploy.contains(s)) .cloned() .collect(), deploy: deploy.into_iter().collect(), }, // No full restart needed -> Deploy / restart as. pickedup. (false, _) => DeployIfChangedAction::Services { deploy: deploy.into_iter().collect(), restart: restart.into_iter().collect(), }, } } impl super::BatchExecute for BatchPullStack { type Resource = Stack; fn single_request(stack: String) -> ExecuteRequest { ExecuteRequest::PullStack(PullStack { stack, services: Vec::new(), }) } } impl Resolve for BatchPullStack { #[instrument(name = "BatchPullStack", skip(user), fields(user_id = user.id))] async fn resolve( self, ExecuteArgs { user, .. }: &ExecuteArgs, ) -> serror::Result { Ok( super::batch_execute::(&self.pattern, user) .await?, ) } } async fn maybe_pull_stack( stack: &Stack, update: Option<&mut Update>, ) -> anyhow::Result<()> { if stack.config.files_on_host || (stack.config.repo.is_empty() && stack.config.linked_repo.is_empty()) { // Not repo based, no pull necessary return Ok(()); } let server = resource::get::(&stack.config.server_id).await?; let repo = if stack.config.repo.is_empty() && !stack.config.linked_repo.is_empty() { Some(resource::get::(&stack.config.linked_repo).await?) } else { None }; pull_stack_inner(stack.clone(), Vec::new(), &server, repo, update) .await?; Ok(()) } pub async fn pull_stack_inner( mut stack: Stack, services: Vec, server: &Server, mut repo: Option, mut update: Option<&mut Update>, ) -> anyhow::Result { if let Some(update) = update.as_mut() && !services.is_empty() { update.logs.push(Log::simple( "Service/s", format!( "Execution requested for Stack service/s {}", services.join(", ") ), )) } let git_token = stack_git_token(&mut stack, repo.as_mut()).await?; let registry_token = crate::helpers::registry_token( &stack.config.registry_provider, &stack.config.registry_account, ).await.with_context( || format!("Failed to get registry token in call to db. Stopping run. | {} | {}", stack.config.registry_provider, stack.config.registry_account), )?; // interpolate variables / secrets let secret_replacers = if !stack.config.skip_secret_interp { let VariablesAndSecrets { variables, secrets } = get_variables_and_secrets().await?; let mut interpolator = Interpolator::new(Some(&variables), &secrets); interpolator.interpolate_stack(&mut stack)?; if let Some(repo) = repo.as_mut() && !repo.config.skip_secret_interp { interpolator.interpolate_repo(repo)?; } if let Some(update) = update { interpolator.push_logs(&mut update.logs); } interpolator.secret_replacers } else { Default::default() }; let res = periphery_client(server)? .request(ComposePull { stack, services, repo, git_token, registry_token, replacers: secret_replacers.into_iter().collect(), }) .await?; // Ensure cached stack state up to date by updating server cache update_cache_for_server(server, true).await; Ok(res) } impl Resolve for PullStack { #[instrument(name = "PullStack", skip(user, update), fields(user_id = user.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let (stack, server) = get_stack_and_server( &self.stack, user, PermissionLevel::Execute.into(), true, ) .await?; let repo = if !stack.config.files_on_host && !stack.config.linked_repo.is_empty() { crate::resource::get::(&stack.config.linked_repo) .await? .into() } else { None }; // get the action state for the stack (or insert default). let action_state = action_states().stack.get_or_insert_default(&stack.id).await; // Will check to ensure stack not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.pulling = true)?; let mut update = update.clone(); update_update(update.clone()).await?; let res = pull_stack_inner( stack, self.services, &server, repo, Some(&mut update), ) .await?; update.logs.extend(res.logs); update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for StartStack { #[instrument(name = "StartStack", skip(user, update), fields(user_id = user.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { execute_compose::( &self.stack, self.services, user, |state| state.starting = true, update.clone(), (), ) .await .map_err(Into::into) } } impl Resolve for RestartStack { #[instrument(name = "RestartStack", skip(user, update), fields(user_id = user.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { execute_compose::( &self.stack, self.services, user, |state| { state.restarting = true; }, update.clone(), (), ) .await .map_err(Into::into) } } impl Resolve for PauseStack { #[instrument(name = "PauseStack", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { execute_compose::( &self.stack, self.services, user, |state| state.pausing = true, update.clone(), (), ) .await .map_err(Into::into) } } impl Resolve for UnpauseStack { #[instrument(name = "UnpauseStack", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { execute_compose::( &self.stack, self.services, user, |state| state.unpausing = true, update.clone(), (), ) .await .map_err(Into::into) } } impl Resolve for StopStack { #[instrument(name = "StopStack", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { execute_compose::( &self.stack, self.services, user, |state| state.stopping = true, update.clone(), self.stop_time, ) .await .map_err(Into::into) } } impl super::BatchExecute for BatchDestroyStack { type Resource = Stack; fn single_request(stack: String) -> ExecuteRequest { ExecuteRequest::DestroyStack(DestroyStack { stack, services: Vec::new(), remove_orphans: false, stop_time: None, }) } } impl Resolve for BatchDestroyStack { #[instrument(name = "BatchDestroyStack", skip(user), fields(user_id = user.id))] async fn resolve( self, ExecuteArgs { user, .. }: &ExecuteArgs, ) -> serror::Result { super::batch_execute::(&self.pattern, user) .await .map_err(Into::into) } } impl Resolve for DestroyStack { #[instrument(name = "DestroyStack", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { execute_compose::( &self.stack, self.services, user, |state| state.destroying = true, update.clone(), (self.stop_time, self.remove_orphans), ) .await .map_err(Into::into) } } impl Resolve for RunStackService { #[instrument(name = "RunStackService", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let (mut stack, server) = get_stack_and_server( &self.stack, user, PermissionLevel::Execute.into(), true, ) .await?; let mut repo = if !stack.config.files_on_host && !stack.config.linked_repo.is_empty() { crate::resource::get::(&stack.config.linked_repo) .await? .into() } else { None }; let action_state = action_states().stack.get_or_insert_default(&stack.id).await; let _action_guard = action_state.update(|state| state.deploying = true)?; let mut update = update.clone(); update_update(update.clone()).await?; let git_token = stack_git_token(&mut stack, repo.as_mut()).await?; let registry_token = crate::helpers::registry_token( &stack.config.registry_provider, &stack.config.registry_account, ).await.with_context( || format!("Failed to get registry token in call to db. Stopping run. | {} | {}", stack.config.registry_provider, stack.config.registry_account), )?; let secret_replacers = if !stack.config.skip_secret_interp { let VariablesAndSecrets { variables, secrets } = get_variables_and_secrets().await?; let mut interpolator = Interpolator::new(Some(&variables), &secrets); interpolator.interpolate_stack(&mut stack)?; if let Some(repo) = repo.as_mut() && !repo.config.skip_secret_interp { interpolator.interpolate_repo(repo)?; } interpolator.push_logs(&mut update.logs); interpolator.secret_replacers } else { Default::default() }; let log = periphery_client(&server)? .request(ComposeRun { stack, repo, git_token, registry_token, replacers: secret_replacers.into_iter().collect(), service: self.service, command: self.command, no_tty: self.no_tty, no_deps: self.no_deps, detach: self.detach, service_ports: self.service_ports, env: self.env, workdir: self.workdir, user: self.user, entrypoint: self.entrypoint, pull: self.pull, }) .await?; update.logs.push(log); update.finalize(); update_update(update.clone()).await?; Ok(update) } } ================================================ FILE: bin/core/src/api/execute/sync.rs ================================================ use std::{collections::HashMap, str::FromStr}; use anyhow::{Context, anyhow}; use database::mungos::{ by_id::update_one_by_id, mongodb::bson::{doc, oid::ObjectId}, }; use formatting::{Color, colored, format_serror}; use komodo_client::{ api::{execute::RunSync, write::RefreshResourceSyncPending}, entities::{ self, ResourceTargetVariant, action::Action, alerter::Alerter, build::Build, builder::Builder, deployment::Deployment, komodo_timestamp, permission::PermissionLevel, procedure::Procedure, repo::Repo, server::Server, stack::Stack, sync::ResourceSync, update::{Log, Update}, user::sync_user, }, }; use resolver_api::Resolve; use crate::{ api::write::WriteArgs, helpers::{ all_resources::AllResourcesById, query::get_id_to_tags, update::update_update, }, permission::get_check_permissions, state::{action_states, db_client}, sync::{ ResourceSyncTrait, deploy::{ SyncDeployParams, build_deploy_cache, deploy_from_cache, }, execute::{ExecuteResourceSync, get_updates_for_execution}, remote::RemoteResources, }, }; use super::ExecuteArgs; impl Resolve for RunSync { #[instrument(name = "RunSync", skip(user, update), fields(user_id = user.id, update_id = update.id))] async fn resolve( self, ExecuteArgs { user, update }: &ExecuteArgs, ) -> serror::Result { let RunSync { sync, resource_type: match_resource_type, resources: match_resources, } = self; let sync = get_check_permissions::( &sync, user, PermissionLevel::Execute.into(), ) .await?; let repo = if !sync.config.files_on_host && !sync.config.linked_repo.is_empty() { crate::resource::get::(&sync.config.linked_repo) .await? .into() } else { None }; // get the action state for the sync (or insert default). let action_state = action_states().sync.get_or_insert_default(&sync.id).await; // This will set action state back to default when dropped. // Will also check to ensure sync not already busy before updating. let _action_guard = action_state.update(|state| state.syncing = true)?; let mut update = update.clone(); // Send update here for FE to recheck action state update_update(update.clone()).await?; let RemoteResources { resources, logs, hash, message, file_errors, .. } = crate::sync::remote::get_remote_resources(&sync, repo.as_ref()) .await .context("failed to get remote resources")?; update.logs.extend(logs); update_update(update.clone()).await?; if !file_errors.is_empty() { return Err( anyhow!("Found file errors. Cannot execute sync.").into(), ); } let resources = resources?; let id_to_tags = get_id_to_tags(None).await?; let all_resources = AllResourcesById::load().await?; // Convert all match_resources to names let match_resources = match_resources.map(|resources| { resources .into_iter() .filter_map(|name_or_id| { let Some(resource_type) = match_resource_type else { return Some(name_or_id); }; match ObjectId::from_str(&name_or_id) { Ok(_) => match resource_type { ResourceTargetVariant::Alerter => all_resources .alerters .get(&name_or_id) .map(|a| a.name.clone()), ResourceTargetVariant::Build => all_resources .builds .get(&name_or_id) .map(|b| b.name.clone()), ResourceTargetVariant::Builder => all_resources .builders .get(&name_or_id) .map(|b| b.name.clone()), ResourceTargetVariant::Deployment => all_resources .deployments .get(&name_or_id) .map(|d| d.name.clone()), ResourceTargetVariant::Procedure => all_resources .procedures .get(&name_or_id) .map(|p| p.name.clone()), ResourceTargetVariant::Action => all_resources .actions .get(&name_or_id) .map(|p| p.name.clone()), ResourceTargetVariant::Repo => all_resources .repos .get(&name_or_id) .map(|r| r.name.clone()), ResourceTargetVariant::Server => all_resources .servers .get(&name_or_id) .map(|s| s.name.clone()), ResourceTargetVariant::Stack => all_resources .stacks .get(&name_or_id) .map(|s| s.name.clone()), ResourceTargetVariant::ResourceSync => all_resources .syncs .get(&name_or_id) .map(|s| s.name.clone()), ResourceTargetVariant::System => None, }, Err(_) => Some(name_or_id), } }) .collect::>() }); let deployments_by_name = all_resources .deployments .values() .filter(|deployment| { Deployment::include_resource( &deployment.name, &deployment.config, match_resource_type, match_resources.as_deref(), &deployment.tags, &id_to_tags, &sync.config.match_tags, ) }) .map(|deployment| (deployment.name.clone(), deployment.clone())) .collect::>(); let stacks_by_name = all_resources .stacks .values() .filter(|stack| { Stack::include_resource( &stack.name, &stack.config, match_resource_type, match_resources.as_deref(), &stack.tags, &id_to_tags, &sync.config.match_tags, ) }) .map(|stack| (stack.name.clone(), stack.clone())) .collect::>(); let deploy_cache = build_deploy_cache(SyncDeployParams { deployments: &resources.deployments, deployment_map: &deployments_by_name, stacks: &resources.stacks, stack_map: &stacks_by_name, }) .await?; let delete = sync.config.managed || sync.config.delete; let server_deltas = if sync.config.include_resources { get_updates_for_execution::( resources.servers, delete, match_resource_type, match_resources.as_deref(), &id_to_tags, &sync.config.match_tags, ) .await? } else { Default::default() }; let stack_deltas = if sync.config.include_resources { get_updates_for_execution::( resources.stacks, delete, match_resource_type, match_resources.as_deref(), &id_to_tags, &sync.config.match_tags, ) .await? } else { Default::default() }; let deployment_deltas = if sync.config.include_resources { get_updates_for_execution::( resources.deployments, delete, match_resource_type, match_resources.as_deref(), &id_to_tags, &sync.config.match_tags, ) .await? } else { Default::default() }; let build_deltas = if sync.config.include_resources { get_updates_for_execution::( resources.builds, delete, match_resource_type, match_resources.as_deref(), &id_to_tags, &sync.config.match_tags, ) .await? } else { Default::default() }; let repo_deltas = if sync.config.include_resources { get_updates_for_execution::( resources.repos, delete, match_resource_type, match_resources.as_deref(), &id_to_tags, &sync.config.match_tags, ) .await? } else { Default::default() }; let procedure_deltas = if sync.config.include_resources { get_updates_for_execution::( resources.procedures, delete, match_resource_type, match_resources.as_deref(), &id_to_tags, &sync.config.match_tags, ) .await? } else { Default::default() }; let action_deltas = if sync.config.include_resources { get_updates_for_execution::( resources.actions, delete, match_resource_type, match_resources.as_deref(), &id_to_tags, &sync.config.match_tags, ) .await? } else { Default::default() }; let builder_deltas = if sync.config.include_resources { get_updates_for_execution::( resources.builders, delete, match_resource_type, match_resources.as_deref(), &id_to_tags, &sync.config.match_tags, ) .await? } else { Default::default() }; let alerter_deltas = if sync.config.include_resources { get_updates_for_execution::( resources.alerters, delete, match_resource_type, match_resources.as_deref(), &id_to_tags, &sync.config.match_tags, ) .await? } else { Default::default() }; let resource_sync_deltas = if sync.config.include_resources { get_updates_for_execution::( resources.resource_syncs, delete, match_resource_type, match_resources.as_deref(), &id_to_tags, &sync.config.match_tags, ) .await? } else { Default::default() }; let ( variables_to_create, variables_to_update, variables_to_delete, ) = if match_resource_type.is_none() && match_resources.is_none() && sync.config.include_variables { crate::sync::variables::get_updates_for_execution( resources.variables, delete, ) .await? } else { Default::default() }; let ( user_groups_to_create, user_groups_to_update, user_groups_to_delete, ) = if match_resource_type.is_none() && match_resources.is_none() && sync.config.include_user_groups { crate::sync::user_groups::get_updates_for_execution( resources.user_groups, delete, ) .await? } else { Default::default() }; if deploy_cache.is_empty() && resource_sync_deltas.no_changes() && server_deltas.no_changes() && deployment_deltas.no_changes() && stack_deltas.no_changes() && build_deltas.no_changes() && builder_deltas.no_changes() && alerter_deltas.no_changes() && repo_deltas.no_changes() && procedure_deltas.no_changes() && action_deltas.no_changes() && user_groups_to_create.is_empty() && user_groups_to_update.is_empty() && user_groups_to_delete.is_empty() && variables_to_create.is_empty() && variables_to_update.is_empty() && variables_to_delete.is_empty() { update.push_simple_log( "No Changes", format!( "{}. exiting.", colored("nothing to do", Color::Green) ), ); update.finalize(); update_update(update.clone()).await?; return Ok(update); } // ================= // No deps maybe_extend( &mut update.logs, crate::sync::variables::run_updates( variables_to_create, variables_to_update, variables_to_delete, ) .await, ); maybe_extend( &mut update.logs, crate::sync::user_groups::run_updates( user_groups_to_create, user_groups_to_update, user_groups_to_delete, ) .await, ); maybe_extend( &mut update.logs, ResourceSync::execute_sync_updates(resource_sync_deltas).await, ); maybe_extend( &mut update.logs, Server::execute_sync_updates(server_deltas).await, ); maybe_extend( &mut update.logs, Alerter::execute_sync_updates(alerter_deltas).await, ); maybe_extend( &mut update.logs, Action::execute_sync_updates(action_deltas).await, ); // Dependent on server maybe_extend( &mut update.logs, Builder::execute_sync_updates(builder_deltas).await, ); maybe_extend( &mut update.logs, Repo::execute_sync_updates(repo_deltas).await, ); // Dependant on builder maybe_extend( &mut update.logs, Build::execute_sync_updates(build_deltas).await, ); // Dependant on server / build maybe_extend( &mut update.logs, Deployment::execute_sync_updates(deployment_deltas).await, ); // stack only depends on server, but maybe will depend on build later. maybe_extend( &mut update.logs, Stack::execute_sync_updates(stack_deltas).await, ); // Dependant on everything maybe_extend( &mut update.logs, Procedure::execute_sync_updates(procedure_deltas).await, ); // Execute the deploy cache deploy_from_cache(deploy_cache, &mut update.logs).await; let db = db_client(); if let Err(e) = update_one_by_id( &db.resource_syncs, &sync.id, doc! { "$set": { "info.last_sync_ts": komodo_timestamp(), "info.last_sync_hash": hash, "info.last_sync_message": message, } }, None, ) .await { warn!( "failed to update resource sync {} info after sync | {e:#}", sync.name ) } if let Err(e) = (RefreshResourceSyncPending { sync: sync.id }) .resolve(&WriteArgs { user: sync_user().to_owned(), }) .await { warn!( "failed to refresh sync {} after run | {:#}", sync.name, e.error ); update.push_error_log( "refresh sync", format_serror( &e.error .context("failed to refresh sync pending after run") .into(), ), ); } update.finalize(); update_update(update.clone()).await?; Ok(update) } } fn maybe_extend(logs: &mut Vec, log: Option) { if let Some(log) = log { logs.push(log); } } ================================================ FILE: bin/core/src/api/mod.rs ================================================ pub mod auth; pub mod execute; pub mod read; pub mod terminal; pub mod user; pub mod write; #[derive(serde::Deserialize)] struct Variant { variant: String, } ================================================ FILE: bin/core/src/api/read/action.rs ================================================ use anyhow::Context; use komodo_client::{ api::read::*, entities::{ action::{ Action, ActionActionState, ActionListItem, ActionState, }, permission::PermissionLevel, }, }; use resolver_api::Resolve; use crate::{ helpers::query::get_all_tags, permission::get_check_permissions, resource, state::{action_state_cache, action_states}, }; use super::ReadArgs; impl Resolve for GetAction { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { Ok( get_check_permissions::( &self.action, user, PermissionLevel::Read.into(), ) .await?, ) } } impl Resolve for ListActions { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result> { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for ListFullActions { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_full_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for GetActionActionState { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let action = get_check_permissions::( &self.action, user, PermissionLevel::Read.into(), ) .await?; let action_state = action_states() .action .get(&action.id) .await .unwrap_or_default() .get()?; Ok(action_state) } } impl Resolve for GetActionsSummary { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let actions = resource::list_full_for_user::( Default::default(), user, PermissionLevel::Read.into(), &[], ) .await .context("failed to get actions from db")?; let mut res = GetActionsSummaryResponse::default(); let cache = action_state_cache(); let action_states = action_states(); for action in actions { res.total += 1; match ( cache.get(&action.id).await.unwrap_or_default(), action_states .action .get(&action.id) .await .unwrap_or_default() .get()?, ) { (_, action_states) if action_states.running > 0 => { res.running += action_states.running; } (ActionState::Ok, _) => res.ok += 1, (ActionState::Failed, _) => res.failed += 1, (ActionState::Unknown, _) => res.unknown += 1, // will never come off the cache in the running state, since that comes from action states (ActionState::Running, _) => unreachable!(), } } Ok(res) } } ================================================ FILE: bin/core/src/api/read/alert.rs ================================================ use anyhow::Context; use database::mungos::{ by_id::find_one_by_id, find::find_collect, mongodb::{bson::doc, options::FindOptions}, }; use komodo_client::{ api::read::{ GetAlert, GetAlertResponse, ListAlerts, ListAlertsResponse, }, entities::{ deployment::Deployment, server::Server, stack::Stack, sync::ResourceSync, }, }; use resolver_api::Resolve; use crate::{ config::core_config, permission::get_resource_ids_for_user, state::db_client, }; use super::ReadArgs; const NUM_ALERTS_PER_PAGE: u64 = 100; impl Resolve for ListAlerts { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let mut query = self.query.unwrap_or_default(); if !user.admin && !core_config().transparent_mode { let server_ids = get_resource_ids_for_user::(user).await?; let stack_ids = get_resource_ids_for_user::(user).await?; let deployment_ids = get_resource_ids_for_user::(user).await?; let sync_ids = get_resource_ids_for_user::(user).await?; query.extend(doc! { "$or": [ { "target.type": "Server", "target.id": { "$in": &server_ids } }, { "target.type": "Stack", "target.id": { "$in": &stack_ids } }, { "target.type": "Deployment", "target.id": { "$in": &deployment_ids } }, { "target.type": "ResourceSync", "target.id": { "$in": &sync_ids } }, ] }); } let alerts = find_collect( &db_client().alerts, query, FindOptions::builder() .sort(doc! { "ts": -1 }) .limit(NUM_ALERTS_PER_PAGE as i64) .skip(self.page * NUM_ALERTS_PER_PAGE) .build(), ) .await .context("failed to get alerts from db")?; let next_page = if alerts.len() < NUM_ALERTS_PER_PAGE as usize { None } else { Some((self.page + 1) as i64) }; let res = ListAlertsResponse { next_page, alerts }; Ok(res) } } impl Resolve for GetAlert { async fn resolve( self, _: &ReadArgs, ) -> serror::Result { Ok( find_one_by_id(&db_client().alerts, &self.id) .await .context("failed to query db for alert")? .context("no alert found with given id")?, ) } } ================================================ FILE: bin/core/src/api/read/alerter.rs ================================================ use anyhow::Context; use database::mongo_indexed::Document; use database::mungos::mongodb::bson::doc; use komodo_client::{ api::read::*, entities::{ alerter::{Alerter, AlerterListItem}, permission::PermissionLevel, }, }; use resolver_api::Resolve; use crate::{ helpers::query::get_all_tags, permission::get_check_permissions, resource, state::db_client, }; use super::ReadArgs; impl Resolve for GetAlerter { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { Ok( get_check_permissions::( &self.alerter, user, PermissionLevel::Read.into(), ) .await?, ) } } impl Resolve for ListAlerters { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result> { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for ListFullAlerters { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_full_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for GetAlertersSummary { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let query = match resource::get_resource_object_ids_for_user::< Alerter, >(user) .await? { Some(ids) => doc! { "_id": { "$in": ids } }, None => Document::new(), }; let total = db_client() .alerters .count_documents(query) .await .context("failed to count all alerter documents")?; let res = GetAlertersSummaryResponse { total: total as u32, }; Ok(res) } } ================================================ FILE: bin/core/src/api/read/build.rs ================================================ use std::collections::{HashMap, HashSet}; use anyhow::Context; use async_timing_util::unix_timestamp_ms; use database::mungos::{ find::find_collect, mongodb::{bson::doc, options::FindOptions}, }; use futures::TryStreamExt; use komodo_client::{ api::read::*, entities::{ Operation, build::{Build, BuildActionState, BuildListItem, BuildState}, config::core::CoreConfig, permission::PermissionLevel, update::UpdateStatus, }, }; use resolver_api::Resolve; use crate::{ config::core_config, helpers::query::get_all_tags, permission::get_check_permissions, resource, state::{ action_states, build_state_cache, db_client, github_client, }, }; use super::ReadArgs; impl Resolve for GetBuild { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { Ok( get_check_permissions::( &self.build, user, PermissionLevel::Read.into(), ) .await?, ) } } impl Resolve for ListBuilds { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result> { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for ListFullBuilds { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_full_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for GetBuildActionState { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let build = get_check_permissions::( &self.build, user, PermissionLevel::Read.into(), ) .await?; let action_state = action_states() .build .get(&build.id) .await .unwrap_or_default() .get()?; Ok(action_state) } } impl Resolve for GetBuildsSummary { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let builds = resource::list_full_for_user::( Default::default(), user, PermissionLevel::Read.into(), &[], ) .await .context("failed to get all builds")?; let mut res = GetBuildsSummaryResponse::default(); let cache = build_state_cache(); let action_states = action_states(); for build in builds { res.total += 1; match ( cache.get(&build.id).await.unwrap_or_default(), action_states .build .get(&build.id) .await .unwrap_or_default() .get()?, ) { (_, action_states) if action_states.building => { res.building += 1; } (BuildState::Ok, _) => res.ok += 1, (BuildState::Failed, _) => res.failed += 1, (BuildState::Unknown, _) => res.unknown += 1, // will never come off the cache in the building state, since that comes from action states (BuildState::Building, _) => unreachable!(), } } Ok(res) } } const ONE_DAY_MS: i64 = 86400000; impl Resolve for GetBuildMonthlyStats { async fn resolve( self, _: &ReadArgs, ) -> serror::Result { let curr_ts = unix_timestamp_ms() as i64; let next_day = curr_ts - curr_ts % ONE_DAY_MS + ONE_DAY_MS; let close_ts = next_day - self.page as i64 * 30 * ONE_DAY_MS; let open_ts = close_ts - 30 * ONE_DAY_MS; let mut build_updates = db_client() .updates .find(doc! { "start_ts": { "$gte": open_ts, "$lt": close_ts }, "operation": Operation::RunBuild.to_string(), }) .await .context("failed to get updates cursor")?; let mut days = HashMap::::with_capacity(32); let mut curr = open_ts; while curr < close_ts { let stats = BuildStatsDay { ts: curr as f64, ..Default::default() }; days.insert(curr, stats); curr += ONE_DAY_MS; } while let Some(update) = build_updates.try_next().await? { if let Some(end_ts) = update.end_ts { let day = update.start_ts - update.start_ts % ONE_DAY_MS; let entry = days.entry(day).or_default(); entry.count += 1.0; entry.time += ms_to_hour(end_ts - update.start_ts); } } Ok(GetBuildMonthlyStatsResponse::new( days.into_values().collect(), )) } } const MS_TO_HOUR_DIVISOR: f64 = 1000.0 * 60.0 * 60.0; fn ms_to_hour(duration: i64) -> f64 { duration as f64 / MS_TO_HOUR_DIVISOR } impl Resolve for ListBuildVersions { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result> { let ListBuildVersions { build, major, minor, patch, limit, } = self; let build = get_check_permissions::( &build, user, PermissionLevel::Read.into(), ) .await?; let mut filter = doc! { "target": { "type": "Build", "id": build.id }, "operation": Operation::RunBuild.to_string(), "status": UpdateStatus::Complete.to_string(), "success": true }; if let Some(major) = major { filter.insert("version.major", major); } if let Some(minor) = minor { filter.insert("version.minor", minor); } if let Some(patch) = patch { filter.insert("version.patch", patch); } let versions = find_collect( &db_client().updates, filter, FindOptions::builder() .sort(doc! { "_id": -1 }) .limit(limit) .build(), ) .await .context("failed to pull versions from mongo")? .into_iter() .map(|u| (u.version, u.start_ts)) .filter(|(v, _)| !v.is_none()) .map(|(version, ts)| BuildVersionResponseItem { version, ts }) .collect(); Ok(versions) } } impl Resolve for ListCommonBuildExtraArgs { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; let builds = resource::list_full_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await .context("failed to get resources matching query")?; // first collect with guaranteed uniqueness let mut res = HashSet::::new(); for build in builds { for extra_arg in build.config.extra_args { res.insert(extra_arg); } } let mut res = res.into_iter().collect::>(); res.sort(); Ok(res) } } impl Resolve for GetBuildWebhookEnabled { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let Some(github) = github_client() else { return Ok(GetBuildWebhookEnabledResponse { managed: false, enabled: false, }); }; let build = get_check_permissions::( &self.build, user, PermissionLevel::Read.into(), ) .await?; if build.config.git_provider != "github.com" || build.config.repo.is_empty() { return Ok(GetBuildWebhookEnabledResponse { managed: false, enabled: false, }); } let mut split = build.config.repo.split('/'); let owner = split.next().context("Build repo has no owner")?; let Some(github) = github.get(owner) else { return Ok(GetBuildWebhookEnabledResponse { managed: false, enabled: false, }); }; let repo = split.next().context("Build repo has no repo after the /")?; let github_repos = github.repos(); let webhooks = github_repos .list_all_webhooks(owner, repo) .await .context("failed to list all webhooks on repo")? .body; let CoreConfig { host, webhook_base_url, .. } = core_config(); let host = if webhook_base_url.is_empty() { host } else { webhook_base_url }; let url = format!("{host}/listener/github/build/{}", build.id); for webhook in webhooks { if webhook.active && webhook.config.url == url { return Ok(GetBuildWebhookEnabledResponse { managed: true, enabled: true, }); } } Ok(GetBuildWebhookEnabledResponse { managed: true, enabled: false, }) } } ================================================ FILE: bin/core/src/api/read/builder.rs ================================================ use anyhow::Context; use database::mongo_indexed::Document; use database::mungos::mongodb::bson::doc; use komodo_client::{ api::read::*, entities::{ builder::{Builder, BuilderListItem}, permission::PermissionLevel, }, }; use resolver_api::Resolve; use crate::{ helpers::query::get_all_tags, permission::get_check_permissions, resource, state::db_client, }; use super::ReadArgs; impl Resolve for GetBuilder { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { Ok( get_check_permissions::( &self.builder, user, PermissionLevel::Read.into(), ) .await?, ) } } impl Resolve for ListBuilders { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result> { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for ListFullBuilders { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_full_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for GetBuildersSummary { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let query = match resource::get_resource_object_ids_for_user::< Builder, >(user) .await? { Some(ids) => doc! { "_id": { "$in": ids } }, None => Document::new(), }; let total = db_client() .builders .count_documents(query) .await .context("failed to count all builder documents")?; let res = GetBuildersSummaryResponse { total: total as u32, }; Ok(res) } } ================================================ FILE: bin/core/src/api/read/deployment.rs ================================================ use std::{cmp, collections::HashSet}; use anyhow::{Context, anyhow}; use komodo_client::{ api::read::*, entities::{ deployment::{ Deployment, DeploymentActionState, DeploymentConfig, DeploymentListItem, DeploymentState, }, docker::container::{Container, ContainerStats}, permission::PermissionLevel, server::{Server, ServerState}, update::Log, }, }; use periphery_client::api::{self, container::InspectContainer}; use resolver_api::Resolve; use crate::{ helpers::{periphery_client, query::get_all_tags}, permission::get_check_permissions, resource, state::{ action_states, deployment_status_cache, server_status_cache, }, }; use super::ReadArgs; impl Resolve for GetDeployment { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { Ok( get_check_permissions::( &self.deployment, user, PermissionLevel::Read.into(), ) .await?, ) } } impl Resolve for ListDeployments { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result> { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; let only_update_available = self.query.specific.update_available; let deployments = resource::list_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?; let deployments = if only_update_available { deployments .into_iter() .filter(|deployment| deployment.info.update_available) .collect() } else { deployments }; Ok(deployments) } } impl Resolve for ListFullDeployments { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_full_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for GetDeploymentContainer { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let deployment = get_check_permissions::( &self.deployment, user, PermissionLevel::Read.into(), ) .await?; let status = deployment_status_cache() .get(&deployment.id) .await .unwrap_or_default(); let response = GetDeploymentContainerResponse { state: status.curr.state, container: status.curr.container.clone(), }; Ok(response) } } const MAX_LOG_LENGTH: u64 = 5000; impl Resolve for GetDeploymentLog { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let GetDeploymentLog { deployment, tail, timestamps, } = self; let Deployment { name, config: DeploymentConfig { server_id, .. }, .. } = get_check_permissions::( &deployment, user, PermissionLevel::Read.logs(), ) .await?; if server_id.is_empty() { return Ok(Log::default()); } let server = resource::get::(&server_id).await?; let res = periphery_client(&server)? .request(api::container::GetContainerLog { name, tail: cmp::min(tail, MAX_LOG_LENGTH), timestamps, }) .await .context("failed at call to periphery")?; Ok(res) } } impl Resolve for SearchDeploymentLog { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let SearchDeploymentLog { deployment, terms, combinator, invert, timestamps, } = self; let Deployment { name, config: DeploymentConfig { server_id, .. }, .. } = get_check_permissions::( &deployment, user, PermissionLevel::Read.logs(), ) .await?; if server_id.is_empty() { return Ok(Log::default()); } let server = resource::get::(&server_id).await?; let res = periphery_client(&server)? .request(api::container::GetContainerLogSearch { name, terms, combinator, invert, timestamps, }) .await .context("failed at call to periphery")?; Ok(res) } } impl Resolve for InspectDeploymentContainer { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let InspectDeploymentContainer { deployment } = self; let Deployment { name, config: DeploymentConfig { server_id, .. }, .. } = get_check_permissions::( &deployment, user, PermissionLevel::Read.inspect(), ) .await?; if server_id.is_empty() { return Err( anyhow!( "Cannot inspect deployment, not attached to any server" ) .into(), ); } let server = resource::get::(&server_id).await?; let cache = server_status_cache() .get_or_insert_default(&server.id) .await; if cache.state != ServerState::Ok { return Err( anyhow!( "Cannot inspect container: server is {:?}", cache.state ) .into(), ); } let res = periphery_client(&server)? .request(InspectContainer { name }) .await?; Ok(res) } } impl Resolve for GetDeploymentStats { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let Deployment { name, config: DeploymentConfig { server_id, .. }, .. } = get_check_permissions::( &self.deployment, user, PermissionLevel::Read.into(), ) .await?; if server_id.is_empty() { return Err( anyhow!("deployment has no server attached").into(), ); } let server = resource::get::(&server_id).await?; let res = periphery_client(&server)? .request(api::container::GetContainerStats { name }) .await .context("failed to get stats from periphery")?; Ok(res) } } impl Resolve for GetDeploymentActionState { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let deployment = get_check_permissions::( &self.deployment, user, PermissionLevel::Read.into(), ) .await?; let action_state = action_states() .deployment .get(&deployment.id) .await .unwrap_or_default() .get()?; Ok(action_state) } } impl Resolve for GetDeploymentsSummary { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let deployments = resource::list_full_for_user::( Default::default(), user, PermissionLevel::Read.into(), &[], ) .await .context("failed to get deployments from db")?; let mut res = GetDeploymentsSummaryResponse::default(); let status_cache = deployment_status_cache(); for deployment in deployments { res.total += 1; let status = status_cache.get(&deployment.id).await.unwrap_or_default(); match status.curr.state { DeploymentState::Running => { res.running += 1; } DeploymentState::Exited | DeploymentState::Paused => { res.stopped += 1; } DeploymentState::NotDeployed => { res.not_deployed += 1; } DeploymentState::Unknown => { res.unknown += 1; } _ => { res.unhealthy += 1; } } } Ok(res) } } impl Resolve for ListCommonDeploymentExtraArgs { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; let deployments = resource::list_full_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await .context("failed to get resources matching query")?; // first collect with guaranteed uniqueness let mut res = HashSet::::new(); for deployment in deployments { for extra_arg in deployment.config.extra_args { res.insert(extra_arg); } } let mut res = res.into_iter().collect::>(); res.sort(); Ok(res) } } ================================================ FILE: bin/core/src/api/read/mod.rs ================================================ use std::{collections::HashSet, sync::OnceLock, time::Instant}; use anyhow::{Context, anyhow}; use axum::{ Extension, Router, extract::Path, middleware, routing::post, }; use komodo_client::{ api::read::*, entities::{ ResourceTarget, build::Build, builder::{Builder, BuilderConfig}, config::{DockerRegistry, GitProvider}, permission::PermissionLevel, repo::Repo, server::Server, sync::ResourceSync, user::User, }, }; use resolver_api::Resolve; use response::Response; use serde::{Deserialize, Serialize}; use serde_json::json; use serror::Json; use typeshare::typeshare; use uuid::Uuid; use crate::{ auth::auth_request, config::core_config, helpers::periphery_client, resource, }; use super::Variant; mod action; mod alert; mod alerter; mod build; mod builder; mod deployment; mod permission; mod procedure; mod provider; mod repo; mod schedule; mod server; mod stack; mod sync; mod tag; mod toml; mod update; mod user; mod user_group; mod variable; pub struct ReadArgs { pub user: User, } #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[args(ReadArgs)] #[response(Response)] #[error(serror::Error)] #[serde(tag = "type", content = "params")] enum ReadRequest { GetVersion(GetVersion), GetCoreInfo(GetCoreInfo), ListSecrets(ListSecrets), ListGitProvidersFromConfig(ListGitProvidersFromConfig), ListDockerRegistriesFromConfig(ListDockerRegistriesFromConfig), // ==== USER ==== GetUsername(GetUsername), GetPermission(GetPermission), FindUser(FindUser), ListUsers(ListUsers), ListApiKeys(ListApiKeys), ListApiKeysForServiceUser(ListApiKeysForServiceUser), ListPermissions(ListPermissions), ListUserTargetPermissions(ListUserTargetPermissions), // ==== USER GROUP ==== GetUserGroup(GetUserGroup), ListUserGroups(ListUserGroups), // ==== PROCEDURE ==== GetProceduresSummary(GetProceduresSummary), GetProcedure(GetProcedure), GetProcedureActionState(GetProcedureActionState), ListProcedures(ListProcedures), ListFullProcedures(ListFullProcedures), // ==== ACTION ==== GetActionsSummary(GetActionsSummary), GetAction(GetAction), GetActionActionState(GetActionActionState), ListActions(ListActions), ListFullActions(ListFullActions), // ==== SCHEDULE ==== ListSchedules(ListSchedules), // ==== SERVER ==== GetServersSummary(GetServersSummary), GetServer(GetServer), GetServerState(GetServerState), GetPeripheryVersion(GetPeripheryVersion), GetServerActionState(GetServerActionState), GetHistoricalServerStats(GetHistoricalServerStats), ListServers(ListServers), ListFullServers(ListFullServers), InspectDockerContainer(InspectDockerContainer), GetResourceMatchingContainer(GetResourceMatchingContainer), GetContainerLog(GetContainerLog), SearchContainerLog(SearchContainerLog), InspectDockerNetwork(InspectDockerNetwork), InspectDockerImage(InspectDockerImage), ListDockerImageHistory(ListDockerImageHistory), InspectDockerVolume(InspectDockerVolume), GetDockerContainersSummary(GetDockerContainersSummary), ListAllDockerContainers(ListAllDockerContainers), ListDockerContainers(ListDockerContainers), ListDockerNetworks(ListDockerNetworks), ListDockerImages(ListDockerImages), ListDockerVolumes(ListDockerVolumes), ListComposeProjects(ListComposeProjects), ListTerminals(ListTerminals), // ==== SERVER STATS ==== GetSystemInformation(GetSystemInformation), GetSystemStats(GetSystemStats), ListSystemProcesses(ListSystemProcesses), // ==== STACK ==== GetStacksSummary(GetStacksSummary), GetStack(GetStack), GetStackActionState(GetStackActionState), GetStackWebhooksEnabled(GetStackWebhooksEnabled), GetStackLog(GetStackLog), SearchStackLog(SearchStackLog), InspectStackContainer(InspectStackContainer), ListStacks(ListStacks), ListFullStacks(ListFullStacks), ListStackServices(ListStackServices), ListCommonStackExtraArgs(ListCommonStackExtraArgs), ListCommonStackBuildExtraArgs(ListCommonStackBuildExtraArgs), // ==== DEPLOYMENT ==== GetDeploymentsSummary(GetDeploymentsSummary), GetDeployment(GetDeployment), GetDeploymentContainer(GetDeploymentContainer), GetDeploymentActionState(GetDeploymentActionState), GetDeploymentStats(GetDeploymentStats), GetDeploymentLog(GetDeploymentLog), SearchDeploymentLog(SearchDeploymentLog), InspectDeploymentContainer(InspectDeploymentContainer), ListDeployments(ListDeployments), ListFullDeployments(ListFullDeployments), ListCommonDeploymentExtraArgs(ListCommonDeploymentExtraArgs), // ==== BUILD ==== GetBuildsSummary(GetBuildsSummary), GetBuild(GetBuild), GetBuildActionState(GetBuildActionState), GetBuildMonthlyStats(GetBuildMonthlyStats), ListBuildVersions(ListBuildVersions), GetBuildWebhookEnabled(GetBuildWebhookEnabled), ListBuilds(ListBuilds), ListFullBuilds(ListFullBuilds), ListCommonBuildExtraArgs(ListCommonBuildExtraArgs), // ==== REPO ==== GetReposSummary(GetReposSummary), GetRepo(GetRepo), GetRepoActionState(GetRepoActionState), GetRepoWebhooksEnabled(GetRepoWebhooksEnabled), ListRepos(ListRepos), ListFullRepos(ListFullRepos), // ==== SYNC ==== GetResourceSyncsSummary(GetResourceSyncsSummary), GetResourceSync(GetResourceSync), GetResourceSyncActionState(GetResourceSyncActionState), GetSyncWebhooksEnabled(GetSyncWebhooksEnabled), ListResourceSyncs(ListResourceSyncs), ListFullResourceSyncs(ListFullResourceSyncs), // ==== BUILDER ==== GetBuildersSummary(GetBuildersSummary), GetBuilder(GetBuilder), ListBuilders(ListBuilders), ListFullBuilders(ListFullBuilders), // ==== ALERTER ==== GetAlertersSummary(GetAlertersSummary), GetAlerter(GetAlerter), ListAlerters(ListAlerters), ListFullAlerters(ListFullAlerters), // ==== TOML ==== ExportAllResourcesToToml(ExportAllResourcesToToml), ExportResourcesToToml(ExportResourcesToToml), // ==== TAG ==== GetTag(GetTag), ListTags(ListTags), // ==== UPDATE ==== GetUpdate(GetUpdate), ListUpdates(ListUpdates), // ==== ALERT ==== ListAlerts(ListAlerts), GetAlert(GetAlert), // ==== VARIABLE ==== GetVariable(GetVariable), ListVariables(ListVariables), // ==== PROVIDER ==== GetGitProviderAccount(GetGitProviderAccount), ListGitProviderAccounts(ListGitProviderAccounts), GetDockerRegistryAccount(GetDockerRegistryAccount), ListDockerRegistryAccounts(ListDockerRegistryAccounts), } pub fn router() -> Router { Router::new() .route("/", post(handler)) .route("/{variant}", post(variant_handler)) .layer(middleware::from_fn(auth_request)) } async fn variant_handler( user: Extension, Path(Variant { variant }): Path, Json(params): Json, ) -> serror::Result { let req: ReadRequest = serde_json::from_value(json!({ "type": variant, "params": params, }))?; handler(user, Json(req)).await } #[instrument(name = "ReadHandler", level = "debug", skip(user), fields(user_id = user.id))] async fn handler( Extension(user): Extension, Json(request): Json, ) -> serror::Result { let timer = Instant::now(); let req_id = Uuid::new_v4(); debug!("/read request | user: {}", user.username); let res = request.resolve(&ReadArgs { user }).await; if let Err(e) = &res { debug!("/read request {req_id} error: {:#}", e.error); } let elapsed = timer.elapsed(); debug!("/read request {req_id} | resolve time: {elapsed:?}"); res.map(|res| res.0) } impl Resolve for GetVersion { async fn resolve( self, _: &ReadArgs, ) -> serror::Result { Ok(GetVersionResponse { version: env!("CARGO_PKG_VERSION").to_string(), }) } } fn core_info() -> &'static GetCoreInfoResponse { static CORE_INFO: OnceLock = OnceLock::new(); CORE_INFO.get_or_init(|| { let config = core_config(); GetCoreInfoResponse { title: config.title.clone(), monitoring_interval: config.monitoring_interval, webhook_base_url: if config.webhook_base_url.is_empty() { config.host.clone() } else { config.webhook_base_url.clone() }, transparent_mode: config.transparent_mode, ui_write_disabled: config.ui_write_disabled, disable_confirm_dialog: config.disable_confirm_dialog, disable_non_admin_create: config.disable_non_admin_create, disable_websocket_reconnect: config.disable_websocket_reconnect, enable_fancy_toml: config.enable_fancy_toml, github_webhook_owners: config .github_webhook_app .installations .iter() .map(|i| i.namespace.to_string()) .collect(), timezone: config.timezone.clone(), } }) } impl Resolve for GetCoreInfo { async fn resolve( self, _: &ReadArgs, ) -> serror::Result { Ok(core_info().clone()) } } impl Resolve for ListSecrets { async fn resolve( self, _: &ReadArgs, ) -> serror::Result { let mut secrets = core_config() .secrets .keys() .cloned() .collect::>(); if let Some(target) = self.target { let server_id = match target { ResourceTarget::Server(id) => Some(id), ResourceTarget::Builder(id) => { match resource::get::(&id).await?.config { BuilderConfig::Url(_) => None, BuilderConfig::Server(config) => Some(config.server_id), BuilderConfig::Aws(config) => { secrets.extend(config.secrets); None } } } _ => { return Err( anyhow!("target must be `Server` or `Builder`").into(), ); } }; if let Some(id) = server_id { let server = resource::get::(&id).await?; let more = periphery_client(&server)? .request(periphery_client::api::ListSecrets {}) .await .with_context(|| { format!( "failed to get secrets from server {}", server.name ) })?; secrets.extend(more); } } let mut secrets = secrets.into_iter().collect::>(); secrets.sort(); Ok(secrets) } } impl Resolve for ListGitProvidersFromConfig { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let mut providers = core_config().git_providers.clone(); if let Some(target) = self.target { match target { ResourceTarget::Server(id) => { merge_git_providers_for_server(&mut providers, &id).await?; } ResourceTarget::Builder(id) => { match resource::get::(&id).await?.config { BuilderConfig::Url(_) => {} BuilderConfig::Server(config) => { merge_git_providers_for_server( &mut providers, &config.server_id, ) .await?; } BuilderConfig::Aws(config) => { merge_git_providers( &mut providers, config.git_providers, ); } } } _ => { return Err( anyhow!("target must be `Server` or `Builder`").into(), ); } } } let (builds, repos, syncs) = tokio::try_join!( resource::list_full_for_user::( Default::default(), user, PermissionLevel::Read.into(), &[] ), resource::list_full_for_user::( Default::default(), user, PermissionLevel::Read.into(), &[] ), resource::list_full_for_user::( Default::default(), user, PermissionLevel::Read.into(), &[] ), )?; for build in builds { if !providers .iter() .any(|provider| provider.domain == build.config.git_provider) { providers.push(GitProvider { domain: build.config.git_provider, https: build.config.git_https, accounts: Default::default(), }); } } for repo in repos { if !providers .iter() .any(|provider| provider.domain == repo.config.git_provider) { providers.push(GitProvider { domain: repo.config.git_provider, https: repo.config.git_https, accounts: Default::default(), }); } } for sync in syncs { if !providers .iter() .any(|provider| provider.domain == sync.config.git_provider) { providers.push(GitProvider { domain: sync.config.git_provider, https: sync.config.git_https, accounts: Default::default(), }); } } providers.sort(); Ok(providers) } } impl Resolve for ListDockerRegistriesFromConfig { async fn resolve( self, _: &ReadArgs, ) -> serror::Result { let mut registries = core_config().docker_registries.clone(); if let Some(target) = self.target { match target { ResourceTarget::Server(id) => { merge_docker_registries_for_server(&mut registries, &id) .await?; } ResourceTarget::Builder(id) => { match resource::get::(&id).await?.config { BuilderConfig::Url(_) => {} BuilderConfig::Server(config) => { merge_docker_registries_for_server( &mut registries, &config.server_id, ) .await?; } BuilderConfig::Aws(config) => { merge_docker_registries( &mut registries, config.docker_registries, ); } } } _ => { return Err( anyhow!("target must be `Server` or `Builder`").into(), ); } } } registries.sort(); Ok(registries) } } async fn merge_git_providers_for_server( providers: &mut Vec, server_id: &str, ) -> serror::Result<()> { let server = resource::get::(server_id).await?; let more = periphery_client(&server)? .request(periphery_client::api::ListGitProviders {}) .await .with_context(|| { format!( "failed to get git providers from server {}", server.name ) })?; merge_git_providers(providers, more); Ok(()) } fn merge_git_providers( providers: &mut Vec, more: Vec, ) { for incoming_provider in more { if let Some(provider) = providers .iter_mut() .find(|provider| provider.domain == incoming_provider.domain) { for account in incoming_provider.accounts { if !provider.accounts.contains(&account) { provider.accounts.push(account); } } } else { providers.push(incoming_provider); } } } async fn merge_docker_registries_for_server( registries: &mut Vec, server_id: &str, ) -> serror::Result<()> { let server = resource::get::(server_id).await?; let more = periphery_client(&server)? .request(periphery_client::api::ListDockerRegistries {}) .await .with_context(|| { format!( "failed to get docker registries from server {}", server.name ) })?; merge_docker_registries(registries, more); Ok(()) } fn merge_docker_registries( registries: &mut Vec, more: Vec, ) { for incoming_registry in more { if let Some(registry) = registries .iter_mut() .find(|registry| registry.domain == incoming_registry.domain) { for account in incoming_registry.accounts { if !registry.accounts.contains(&account) { registry.accounts.push(account); } } } else { registries.push(incoming_registry); } } } ================================================ FILE: bin/core/src/api/read/permission.rs ================================================ use anyhow::{Context, anyhow}; use database::mungos::{find::find_collect, mongodb::bson::doc}; use komodo_client::{ api::read::{ GetPermission, GetPermissionResponse, ListPermissions, ListPermissionsResponse, ListUserTargetPermissions, ListUserTargetPermissionsResponse, }, entities::permission::PermissionLevel, }; use resolver_api::Resolve; use crate::{ helpers::query::get_user_permission_on_target, state::db_client, }; use super::ReadArgs; impl Resolve for ListPermissions { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let res = find_collect( &db_client().permissions, doc! { "user_target.type": "User", "user_target.id": &user.id }, None, ) .await .context("failed to query db for permissions")?; Ok(res) } } impl Resolve for GetPermission { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { if user.admin { return Ok(PermissionLevel::Write.all()); } Ok(get_user_permission_on_target(user, &self.target).await?) } } impl Resolve for ListUserTargetPermissions { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { if !user.admin { return Err(anyhow!("this method is admin only").into()); } let (variant, id) = self.user_target.extract_variant_id(); let res = find_collect( &db_client().permissions, doc! { "user_target.type": variant.as_ref(), "user_target.id": id }, None, ) .await .context("failed to query db for permissions")?; Ok(res) } } ================================================ FILE: bin/core/src/api/read/procedure.rs ================================================ use anyhow::Context; use komodo_client::{ api::read::*, entities::{ permission::PermissionLevel, procedure::{Procedure, ProcedureState}, }, }; use resolver_api::Resolve; use crate::{ helpers::query::get_all_tags, permission::get_check_permissions, resource, state::{action_states, procedure_state_cache}, }; use super::ReadArgs; impl Resolve for GetProcedure { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { Ok( get_check_permissions::( &self.procedure, user, PermissionLevel::Read.into(), ) .await?, ) } } impl Resolve for ListProcedures { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for ListFullProcedures { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_full_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for GetProceduresSummary { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let procedures = resource::list_full_for_user::( Default::default(), user, PermissionLevel::Read.into(), &[], ) .await .context("failed to get procedures from db")?; let mut res = GetProceduresSummaryResponse::default(); let cache = procedure_state_cache(); let action_states = action_states(); for procedure in procedures { res.total += 1; match ( cache.get(&procedure.id).await.unwrap_or_default(), action_states .procedure .get(&procedure.id) .await .unwrap_or_default() .get()?, ) { (_, action_states) if action_states.running => { res.running += 1; } (ProcedureState::Ok, _) => res.ok += 1, (ProcedureState::Failed, _) => res.failed += 1, (ProcedureState::Unknown, _) => res.unknown += 1, // will never come off the cache in the running state, since that comes from action states (ProcedureState::Running, _) => unreachable!(), } } Ok(res) } } impl Resolve for GetProcedureActionState { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let procedure = get_check_permissions::( &self.procedure, user, PermissionLevel::Read.into(), ) .await?; let action_state = action_states() .procedure .get(&procedure.id) .await .unwrap_or_default() .get()?; Ok(action_state) } } ================================================ FILE: bin/core/src/api/read/provider.rs ================================================ use anyhow::{Context, anyhow}; use database::mongo_indexed::{Document, doc}; use database::mungos::{ by_id::find_one_by_id, find::find_collect, mongodb::options::FindOptions, }; use komodo_client::api::read::*; use resolver_api::Resolve; use crate::state::db_client; use super::ReadArgs; impl Resolve for GetGitProviderAccount { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { if !user.admin { return Err( anyhow!("Only admins can read git provider accounts").into(), ); } let res = find_one_by_id(&db_client().git_accounts, &self.id) .await .context("failed to query db for git provider accounts")? .context( "did not find git provider account with the given id", )?; Ok(res) } } impl Resolve for ListGitProviderAccounts { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { if !user.admin { return Err( anyhow!("Only admins can read git provider accounts").into(), ); } let mut filter = Document::new(); if let Some(domain) = self.domain { filter.insert("domain", domain); } if let Some(username) = self.username { filter.insert("username", username); } let res = find_collect( &db_client().git_accounts, filter, FindOptions::builder() .sort(doc! { "domain": 1, "username": 1 }) .build(), ) .await .context("failed to query db for git provider accounts")?; Ok(res) } } impl Resolve for GetDockerRegistryAccount { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { if !user.admin { return Err( anyhow!("Only admins can read docker registry accounts") .into(), ); } let res = find_one_by_id(&db_client().registry_accounts, &self.id) .await .context("failed to query db for docker registry accounts")? .context( "did not find docker registry account with the given id", )?; Ok(res) } } impl Resolve for ListDockerRegistryAccounts { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { if !user.admin { return Err( anyhow!("Only admins can read docker registry accounts") .into(), ); } let mut filter = Document::new(); if let Some(domain) = self.domain { filter.insert("domain", domain); } if let Some(username) = self.username { filter.insert("username", username); } let res = find_collect( &db_client().registry_accounts, filter, FindOptions::builder() .sort(doc! { "domain": 1, "username": 1 }) .build(), ) .await .context("failed to query db for docker registry accounts")?; Ok(res) } } ================================================ FILE: bin/core/src/api/read/repo.rs ================================================ use anyhow::Context; use komodo_client::{ api::read::*, entities::{ config::core::CoreConfig, permission::PermissionLevel, repo::{Repo, RepoActionState, RepoListItem, RepoState}, }, }; use resolver_api::Resolve; use crate::{ config::core_config, helpers::query::get_all_tags, permission::get_check_permissions, resource, state::{action_states, github_client, repo_state_cache}, }; use super::ReadArgs; impl Resolve for GetRepo { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { Ok( get_check_permissions::( &self.repo, user, PermissionLevel::Read.into(), ) .await?, ) } } impl Resolve for ListRepos { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result> { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for ListFullRepos { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_full_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for GetRepoActionState { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let repo = get_check_permissions::( &self.repo, user, PermissionLevel::Read.into(), ) .await?; let action_state = action_states() .repo .get(&repo.id) .await .unwrap_or_default() .get()?; Ok(action_state) } } impl Resolve for GetReposSummary { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let repos = resource::list_full_for_user::( Default::default(), user, PermissionLevel::Read.into(), &[], ) .await .context("failed to get repos from db")?; let mut res = GetReposSummaryResponse::default(); let cache = repo_state_cache(); let action_states = action_states(); for repo in repos { res.total += 1; match ( cache.get(&repo.id).await.unwrap_or_default(), action_states .repo .get(&repo.id) .await .unwrap_or_default() .get()?, ) { (_, action_states) if action_states.cloning => { res.cloning += 1; } (_, action_states) if action_states.pulling => { res.pulling += 1; } (_, action_states) if action_states.building => { res.building += 1; } (RepoState::Ok, _) => res.ok += 1, (RepoState::Failed, _) => res.failed += 1, (RepoState::Unknown, _) => res.unknown += 1, // will never come off the cache in the building state, since that comes from action states (RepoState::Cloning, _) | (RepoState::Pulling, _) | (RepoState::Building, _) => { unreachable!() } } } Ok(res) } } impl Resolve for GetRepoWebhooksEnabled { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let Some(github) = github_client() else { return Ok(GetRepoWebhooksEnabledResponse { managed: false, clone_enabled: false, pull_enabled: false, build_enabled: false, }); }; let repo = get_check_permissions::( &self.repo, user, PermissionLevel::Read.into(), ) .await?; if repo.config.git_provider != "github.com" || repo.config.repo.is_empty() { return Ok(GetRepoWebhooksEnabledResponse { managed: false, clone_enabled: false, pull_enabled: false, build_enabled: false, }); } let mut split = repo.config.repo.split('/'); let owner = split.next().context("Repo repo has no owner")?; let Some(github) = github.get(owner) else { return Ok(GetRepoWebhooksEnabledResponse { managed: false, clone_enabled: false, pull_enabled: false, build_enabled: false, }); }; let repo_name = split.next().context("Repo repo has no repo after the /")?; let github_repos = github.repos(); let webhooks = github_repos .list_all_webhooks(owner, repo_name) .await .context("failed to list all webhooks on repo")? .body; let CoreConfig { host, webhook_base_url, .. } = core_config(); let host = if webhook_base_url.is_empty() { host } else { webhook_base_url }; let clone_url = format!("{host}/listener/github/repo/{}/clone", repo.id); let pull_url = format!("{host}/listener/github/repo/{}/pull", repo.id); let build_url = format!("{host}/listener/github/repo/{}/build", repo.id); let mut clone_enabled = false; let mut pull_enabled = false; let mut build_enabled = false; for webhook in webhooks { if !webhook.active { continue; } if webhook.config.url == clone_url { clone_enabled = true } if webhook.config.url == pull_url { pull_enabled = true } if webhook.config.url == build_url { build_enabled = true } } Ok(GetRepoWebhooksEnabledResponse { managed: true, clone_enabled, pull_enabled, build_enabled, }) } } ================================================ FILE: bin/core/src/api/read/schedule.rs ================================================ use futures::future::join_all; use komodo_client::{ api::read::*, entities::{ ResourceTarget, action::Action, permission::PermissionLevel, procedure::Procedure, resource::{ResourceQuery, TemplatesQueryBehavior}, schedule::Schedule, }, }; use resolver_api::Resolve; use crate::{ helpers::query::{get_all_tags, get_last_run_at}, resource::list_full_for_user, schedule::get_schedule_item_info, }; use super::ReadArgs; impl Resolve for ListSchedules { async fn resolve( self, args: &ReadArgs, ) -> serror::Result> { let all_tags = get_all_tags(None).await?; let (actions, procedures) = tokio::try_join!( list_full_for_user::( ResourceQuery { names: Default::default(), templates: TemplatesQueryBehavior::Include, tag_behavior: self.tag_behavior, tags: self.tags.clone(), specific: Default::default(), }, &args.user, PermissionLevel::Read.into(), &all_tags, ), list_full_for_user::( ResourceQuery { names: Default::default(), templates: TemplatesQueryBehavior::Include, tag_behavior: self.tag_behavior, tags: self.tags.clone(), specific: Default::default(), }, &args.user, PermissionLevel::Read.into(), &all_tags, ) )?; let actions = actions.into_iter().map(async |action| { let (next_scheduled_run, schedule_error) = get_schedule_item_info(&ResourceTarget::Action( action.id.clone(), )); let last_run_at = get_last_run_at::(&action.id).await.unwrap_or(None); Schedule { target: ResourceTarget::Action(action.id), name: action.name, enabled: action.config.schedule_enabled, schedule_format: action.config.schedule_format, schedule: action.config.schedule, schedule_timezone: action.config.schedule_timezone, tags: action.tags, last_run_at, next_scheduled_run, schedule_error, } }); let procedures = procedures.into_iter().map(async |procedure| { let (next_scheduled_run, schedule_error) = get_schedule_item_info(&ResourceTarget::Procedure( procedure.id.clone(), )); let last_run_at = get_last_run_at::(&procedure.id) .await .unwrap_or(None); Schedule { target: ResourceTarget::Procedure(procedure.id), name: procedure.name, enabled: procedure.config.schedule_enabled, schedule_format: procedure.config.schedule_format, schedule: procedure.config.schedule, schedule_timezone: procedure.config.schedule_timezone, tags: procedure.tags, last_run_at, next_scheduled_run, schedule_error, } }); let (actions, procedures) = tokio::join!(join_all(actions), join_all(procedures)); Ok( actions .into_iter() .chain(procedures) .filter(|s| !s.schedule.is_empty()) .collect(), ) } } ================================================ FILE: bin/core/src/api/read/server.rs ================================================ use std::{ cmp, collections::HashMap, sync::{Arc, OnceLock}, }; use anyhow::{Context, anyhow}; use async_timing_util::{ FIFTEEN_SECONDS_MS, get_timelength_in_ms, unix_timestamp_ms, }; use database::mungos::{ find::find_collect, mongodb::{bson::doc, options::FindOptions}, }; use komodo_client::{ api::read::*, entities::{ ResourceTarget, deployment::Deployment, docker::{ container::{ Container, ContainerListItem, ContainerStateStatusEnum, }, image::{Image, ImageHistoryResponseItem}, network::Network, volume::Volume, }, komodo_timestamp, permission::PermissionLevel, server::{ Server, ServerActionState, ServerListItem, ServerState, TerminalInfo, }, stack::{Stack, StackServiceNames}, stats::{SystemInformation, SystemProcess}, update::Log, }, }; use periphery_client::api::{ self as periphery, container::InspectContainer, image::{ImageHistory, InspectImage}, network::InspectNetwork, volume::InspectVolume, }; use resolver_api::Resolve; use tokio::sync::Mutex; use crate::{ helpers::{ periphery_client, query::{get_all_tags, get_system_info}, }, permission::get_check_permissions, resource, stack::compose_container_match_regex, state::{action_states, db_client, server_status_cache}, }; use super::ReadArgs; impl Resolve for GetServersSummary { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let servers = resource::list_for_user::( Default::default(), user, PermissionLevel::Read.into(), &[], ) .await?; let core_version = env!("CARGO_PKG_VERSION"); let mut res = GetServersSummaryResponse::default(); for server in servers { res.total += 1; match server.info.state { ServerState::Ok => { // Check for version mismatch let has_version_mismatch = !server.info.version.is_empty() && server.info.version != "Unknown" && server.info.version != core_version; if has_version_mismatch { res.warning += 1; } else { res.healthy += 1; } } ServerState::NotOk => { res.unhealthy += 1; } ServerState::Disabled => { res.disabled += 1; } } } Ok(res) } } impl Resolve for GetPeripheryVersion { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.into(), ) .await?; let version = server_status_cache() .get(&server.id) .await .map(|s| s.version.clone()) .unwrap_or(String::from("unknown")); Ok(GetPeripheryVersionResponse { version }) } } impl Resolve for GetServer { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { Ok( get_check_permissions::( &self.server, user, PermissionLevel::Read.into(), ) .await?, ) } } impl Resolve for ListServers { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result> { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for ListFullServers { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_full_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for GetServerState { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.into(), ) .await?; let status = server_status_cache() .get(&server.id) .await .ok_or(anyhow!("did not find cached status for server"))?; let response = GetServerStateResponse { status: status.state, }; Ok(response) } } impl Resolve for GetServerActionState { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.into(), ) .await?; let action_state = action_states() .server .get(&server.id) .await .unwrap_or_default() .get()?; Ok(action_state) } } impl Resolve for GetSystemInformation { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.into(), ) .await?; get_system_info(&server).await.map_err(Into::into) } } impl Resolve for GetSystemStats { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.into(), ) .await?; let status = server_status_cache().get(&server.id).await.with_context( || format!("did not find status for server at {}", server.id), )?; let stats = status .stats .as_ref() .context("server stats not available")?; Ok(stats.clone()) } } // This protects the peripheries from spam requests const PROCESSES_EXPIRY: u128 = FIFTEEN_SECONDS_MS; type ProcessesCache = Mutex, u128)>>>; fn processes_cache() -> &'static ProcessesCache { static PROCESSES_CACHE: OnceLock = OnceLock::new(); PROCESSES_CACHE.get_or_init(Default::default) } impl Resolve for ListSystemProcesses { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.processes(), ) .await?; let mut lock = processes_cache().lock().await; let res = match lock.get(&server.id) { Some(cached) if cached.1 > unix_timestamp_ms() => { cached.0.clone() } _ => { let stats = periphery_client(&server)? .request(periphery::stats::GetSystemProcesses {}) .await?; lock.insert( server.id, (stats.clone(), unix_timestamp_ms() + PROCESSES_EXPIRY) .into(), ); stats } }; Ok(res) } } const STATS_PER_PAGE: i64 = 200; impl Resolve for GetHistoricalServerStats { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let GetHistoricalServerStats { server, granularity, page, } = self; let server = get_check_permissions::( &server, user, PermissionLevel::Read.into(), ) .await?; let granularity = get_timelength_in_ms(granularity.to_string().parse().unwrap()) as i64; let mut ts_vec = Vec::::new(); let curr_ts = unix_timestamp_ms() as i64; let mut curr_ts = curr_ts - curr_ts % granularity - granularity * STATS_PER_PAGE * page as i64; for _ in 0..STATS_PER_PAGE { ts_vec.push(curr_ts); curr_ts -= granularity; } let stats = find_collect( &db_client().stats, doc! { "sid": server.id, "ts": { "$in": ts_vec }, }, FindOptions::builder() .sort(doc! { "ts": -1 }) .skip(page as u64 * STATS_PER_PAGE as u64) .limit(STATS_PER_PAGE) .build(), ) .await .context("failed to pull stats from db")?; let next_page = if stats.len() == STATS_PER_PAGE as usize { Some(page + 1) } else { None }; let res = GetHistoricalServerStatsResponse { stats, next_page }; Ok(res) } } impl Resolve for ListDockerContainers { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.into(), ) .await?; let cache = server_status_cache() .get_or_insert_default(&server.id) .await; if let Some(containers) = &cache.containers { Ok(containers.clone()) } else { Ok(Vec::new()) } } } impl Resolve for ListAllDockerContainers { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let servers = resource::list_for_user::( Default::default(), user, PermissionLevel::Read.into(), &[], ) .await? .into_iter() .filter(|server| { self.servers.is_empty() || self.servers.contains(&server.id) || self.servers.contains(&server.name) }); let mut containers = Vec::::new(); for server in servers { let cache = server_status_cache() .get_or_insert_default(&server.id) .await; if let Some(more_containers) = &cache.containers { containers.extend(more_containers.clone()); } } Ok(containers) } } impl Resolve for GetDockerContainersSummary { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let servers = resource::list_full_for_user::( Default::default(), user, PermissionLevel::Read.into(), &[], ) .await .context("failed to get servers from db")?; let mut res = GetDockerContainersSummaryResponse::default(); for server in servers { let cache = server_status_cache() .get_or_insert_default(&server.id) .await; if let Some(containers) = &cache.containers { for container in containers { res.total += 1; match container.state { ContainerStateStatusEnum::Created | ContainerStateStatusEnum::Paused | ContainerStateStatusEnum::Exited => res.stopped += 1, ContainerStateStatusEnum::Running => res.running += 1, ContainerStateStatusEnum::Empty => res.unknown += 1, _ => res.unhealthy += 1, } } } } Ok(res) } } impl Resolve for InspectDockerContainer { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.inspect(), ) .await?; let cache = server_status_cache() .get_or_insert_default(&server.id) .await; if cache.state != ServerState::Ok { return Err( anyhow!( "Cannot inspect container: server is {:?}", cache.state ) .into(), ); } let res = periphery_client(&server)? .request(InspectContainer { name: self.container, }) .await?; Ok(res) } } const MAX_LOG_LENGTH: u64 = 5000; impl Resolve for GetContainerLog { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let GetContainerLog { server, container, tail, timestamps, } = self; let server = get_check_permissions::( &server, user, PermissionLevel::Read.logs(), ) .await?; let res = periphery_client(&server)? .request(periphery::container::GetContainerLog { name: container, tail: cmp::min(tail, MAX_LOG_LENGTH), timestamps, }) .await .context("failed at call to periphery")?; Ok(res) } } impl Resolve for SearchContainerLog { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let SearchContainerLog { server, container, terms, combinator, invert, timestamps, } = self; let server = get_check_permissions::( &server, user, PermissionLevel::Read.logs(), ) .await?; let res = periphery_client(&server)? .request(periphery::container::GetContainerLogSearch { name: container, terms, combinator, invert, timestamps, }) .await .context("failed at call to periphery")?; Ok(res) } } impl Resolve for GetResourceMatchingContainer { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.into(), ) .await?; // first check deployments if let Ok(deployment) = resource::get::(&self.container).await { return Ok(GetResourceMatchingContainerResponse { resource: ResourceTarget::Deployment(deployment.id).into(), }); } // then check stacks let stacks = resource::list_full_for_user_using_document::( doc! { "config.server_id": &server.id }, user, ) .await?; // check matching stack for stack in stacks { for StackServiceNames { service_name, container_name, .. } in stack .info .deployed_services .unwrap_or(stack.info.latest_services) { let is_match = match compose_container_match_regex(&container_name) .with_context(|| format!("failed to construct container name matching regex for service {service_name}")) { Ok(regex) => regex, Err(e) => { warn!("{e:#}"); continue; } }.is_match(&self.container); if is_match { return Ok(GetResourceMatchingContainerResponse { resource: ResourceTarget::Stack(stack.id).into(), }); } } } Ok(GetResourceMatchingContainerResponse { resource: None }) } } impl Resolve for ListDockerNetworks { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.into(), ) .await?; let cache = server_status_cache() .get_or_insert_default(&server.id) .await; if let Some(networks) = &cache.networks { Ok(networks.clone()) } else { Ok(Vec::new()) } } } impl Resolve for InspectDockerNetwork { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.into(), ) .await?; let cache = server_status_cache() .get_or_insert_default(&server.id) .await; if cache.state != ServerState::Ok { return Err( anyhow!( "Cannot inspect network: server is {:?}", cache.state ) .into(), ); } let res = periphery_client(&server)? .request(InspectNetwork { name: self.network }) .await?; Ok(res) } } impl Resolve for ListDockerImages { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.into(), ) .await?; let cache = server_status_cache() .get_or_insert_default(&server.id) .await; if let Some(images) = &cache.images { Ok(images.clone()) } else { Ok(Vec::new()) } } } impl Resolve for InspectDockerImage { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.into(), ) .await?; let cache = server_status_cache() .get_or_insert_default(&server.id) .await; if cache.state != ServerState::Ok { return Err( anyhow!("Cannot inspect image: server is {:?}", cache.state) .into(), ); } let res = periphery_client(&server)? .request(InspectImage { name: self.image }) .await?; Ok(res) } } impl Resolve for ListDockerImageHistory { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result> { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.into(), ) .await?; let cache = server_status_cache() .get_or_insert_default(&server.id) .await; if cache.state != ServerState::Ok { return Err( anyhow!( "Cannot get image history: server is {:?}", cache.state ) .into(), ); } let res = periphery_client(&server)? .request(ImageHistory { name: self.image }) .await?; Ok(res) } } impl Resolve for ListDockerVolumes { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.into(), ) .await?; let cache = server_status_cache() .get_or_insert_default(&server.id) .await; if let Some(volumes) = &cache.volumes { Ok(volumes.clone()) } else { Ok(Vec::new()) } } } impl Resolve for InspectDockerVolume { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.into(), ) .await?; let cache = server_status_cache() .get_or_insert_default(&server.id) .await; if cache.state != ServerState::Ok { return Err( anyhow!("Cannot inspect volume: server is {:?}", cache.state) .into(), ); } let res = periphery_client(&server)? .request(InspectVolume { name: self.volume }) .await?; Ok(res) } } impl Resolve for ListComposeProjects { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.into(), ) .await?; let cache = server_status_cache() .get_or_insert_default(&server.id) .await; if let Some(projects) = &cache.projects { Ok(projects.clone()) } else { Ok(Vec::new()) } } } #[derive(Default)] struct TerminalCacheItem { list: Vec, ttl: i64, } const TERMINAL_CACHE_TIMEOUT: i64 = 30_000; #[derive(Default)] struct TerminalCache( std::sync::Mutex< HashMap>>, >, ); impl TerminalCache { fn get_or_insert( &self, server_id: String, ) -> Arc> { if let Some(cached) = self.0.lock().unwrap().get(&server_id).cloned() { return cached; } let to_cache = Arc::new(tokio::sync::Mutex::new(TerminalCacheItem::default())); self.0.lock().unwrap().insert(server_id, to_cache.clone()); to_cache } } fn terminals_cache() -> &'static TerminalCache { static TERMINALS: OnceLock = OnceLock::new(); TERMINALS.get_or_init(Default::default) } impl Resolve for ListTerminals { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.terminal(), ) .await?; let cache = terminals_cache().get_or_insert(server.id.clone()); let mut cache = cache.lock().await; if self.fresh || komodo_timestamp() > cache.ttl { cache.list = periphery_client(&server)? .request(periphery_client::api::terminal::ListTerminals {}) .await .context("Failed to get fresh terminal list")?; cache.ttl = komodo_timestamp() + TERMINAL_CACHE_TIMEOUT; Ok(cache.list.clone()) } else { Ok(cache.list.clone()) } } } ================================================ FILE: bin/core/src/api/read/stack.rs ================================================ use std::collections::HashSet; use anyhow::{Context, anyhow}; use komodo_client::{ api::read::*, entities::{ config::core::CoreConfig, docker::container::Container, permission::PermissionLevel, server::{Server, ServerState}, stack::{Stack, StackActionState, StackListItem, StackState}, }, }; use periphery_client::api::{ compose::{GetComposeLog, GetComposeLogSearch}, container::InspectContainer, }; use resolver_api::Resolve; use crate::{ config::core_config, helpers::{periphery_client, query::get_all_tags}, permission::get_check_permissions, resource, stack::get_stack_and_server, state::{ action_states, github_client, server_status_cache, stack_status_cache, }, }; use super::ReadArgs; impl Resolve for GetStack { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { Ok( get_check_permissions::( &self.stack, user, PermissionLevel::Read.into(), ) .await?, ) } } impl Resolve for ListStackServices { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let stack = get_check_permissions::( &self.stack, user, PermissionLevel::Read.into(), ) .await?; let services = stack_status_cache() .get(&stack.id) .await .unwrap_or_default() .curr .services .clone(); Ok(services) } } impl Resolve for GetStackLog { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let GetStackLog { stack, services, tail, timestamps, } = self; let (stack, server) = get_stack_and_server( &stack, user, PermissionLevel::Read.logs(), true, ) .await?; let res = periphery_client(&server)? .request(GetComposeLog { project: stack.project_name(false), services, tail, timestamps, }) .await .context("Failed to get stack log from periphery")?; Ok(res) } } impl Resolve for SearchStackLog { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let SearchStackLog { stack, services, terms, combinator, invert, timestamps, } = self; let (stack, server) = get_stack_and_server( &stack, user, PermissionLevel::Read.logs(), true, ) .await?; let res = periphery_client(&server)? .request(GetComposeLogSearch { project: stack.project_name(false), services, terms, combinator, invert, timestamps, }) .await .context("Failed to search stack log from periphery")?; Ok(res) } } impl Resolve for InspectStackContainer { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let InspectStackContainer { stack, service } = self; let stack = get_check_permissions::( &stack, user, PermissionLevel::Read.inspect(), ) .await?; if stack.config.server_id.is_empty() { return Err( anyhow!("Cannot inspect stack, not attached to any server") .into(), ); } let server = resource::get::(&stack.config.server_id).await?; let cache = server_status_cache() .get_or_insert_default(&server.id) .await; if cache.state != ServerState::Ok { return Err( anyhow!( "Cannot inspect container: server is {:?}", cache.state ) .into(), ); } let services = &stack_status_cache() .get(&stack.id) .await .unwrap_or_default() .curr .services; let Some(name) = services .iter() .find(|s| s.service == service) .and_then(|s| s.container.as_ref().map(|c| c.name.clone())) else { return Err(anyhow!( "No service found matching '{service}'. Was the stack last deployed manually?" ).into()); }; let res = periphery_client(&server)? .request(InspectContainer { name }) .await?; Ok(res) } } impl Resolve for ListCommonStackExtraArgs { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; let stacks = resource::list_full_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await .context("failed to get resources matching query")?; // first collect with guaranteed uniqueness let mut res = HashSet::::new(); for stack in stacks { for extra_arg in stack.config.extra_args { res.insert(extra_arg); } } let mut res = res.into_iter().collect::>(); res.sort(); Ok(res) } } impl Resolve for ListCommonStackBuildExtraArgs { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; let stacks = resource::list_full_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await .context("failed to get resources matching query")?; // first collect with guaranteed uniqueness let mut res = HashSet::::new(); for stack in stacks { for extra_arg in stack.config.build_extra_args { res.insert(extra_arg); } } let mut res = res.into_iter().collect::>(); res.sort(); Ok(res) } } impl Resolve for ListStacks { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result> { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; let only_update_available = self.query.specific.update_available; let stacks = resource::list_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?; let stacks = if only_update_available { stacks .into_iter() .filter(|stack| { stack .info .services .iter() .any(|service| service.update_available) }) .collect() } else { stacks }; Ok(stacks) } } impl Resolve for ListFullStacks { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_full_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for GetStackActionState { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let stack = get_check_permissions::( &self.stack, user, PermissionLevel::Read.into(), ) .await?; let action_state = action_states() .stack .get(&stack.id) .await .unwrap_or_default() .get()?; Ok(action_state) } } impl Resolve for GetStacksSummary { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let stacks = resource::list_full_for_user::( Default::default(), user, PermissionLevel::Read.into(), &[], ) .await .context("failed to get stacks from db")?; let mut res = GetStacksSummaryResponse::default(); let cache = stack_status_cache(); for stack in stacks { res.total += 1; match cache.get(&stack.id).await.unwrap_or_default().curr.state { StackState::Running => res.running += 1, StackState::Stopped | StackState::Paused => res.stopped += 1, StackState::Down => res.down += 1, StackState::Unknown => res.unknown += 1, _ => res.unhealthy += 1, } } Ok(res) } } impl Resolve for GetStackWebhooksEnabled { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let Some(github) = github_client() else { return Ok(GetStackWebhooksEnabledResponse { managed: false, refresh_enabled: false, deploy_enabled: false, }); }; let stack = get_check_permissions::( &self.stack, user, PermissionLevel::Read.into(), ) .await?; if stack.config.git_provider != "github.com" || stack.config.repo.is_empty() { return Ok(GetStackWebhooksEnabledResponse { managed: false, refresh_enabled: false, deploy_enabled: false, }); } let mut split = stack.config.repo.split('/'); let owner = split.next().context("Sync repo has no owner")?; let Some(github) = github.get(owner) else { return Ok(GetStackWebhooksEnabledResponse { managed: false, refresh_enabled: false, deploy_enabled: false, }); }; let repo_name = split.next().context("Repo repo has no repo after the /")?; let github_repos = github.repos(); let webhooks = github_repos .list_all_webhooks(owner, repo_name) .await .context("failed to list all webhooks on repo")? .body; let CoreConfig { host, webhook_base_url, .. } = core_config(); let host = if webhook_base_url.is_empty() { host } else { webhook_base_url }; let refresh_url = format!("{host}/listener/github/stack/{}/refresh", stack.id); let deploy_url = format!("{host}/listener/github/stack/{}/deploy", stack.id); let mut refresh_enabled = false; let mut deploy_enabled = false; for webhook in webhooks { if webhook.active && webhook.config.url == refresh_url { refresh_enabled = true } if webhook.active && webhook.config.url == deploy_url { deploy_enabled = true } } Ok(GetStackWebhooksEnabledResponse { managed: true, refresh_enabled, deploy_enabled, }) } } ================================================ FILE: bin/core/src/api/read/sync.rs ================================================ use anyhow::Context; use komodo_client::{ api::read::*, entities::{ config::core::CoreConfig, permission::PermissionLevel, sync::{ ResourceSync, ResourceSyncActionState, ResourceSyncListItem, }, }, }; use resolver_api::Resolve; use crate::{ config::core_config, helpers::query::get_all_tags, permission::get_check_permissions, resource, state::{action_states, github_client}, }; use super::ReadArgs; impl Resolve for GetResourceSync { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { Ok( get_check_permissions::( &self.sync, user, PermissionLevel::Read.into(), ) .await?, ) } } impl Resolve for ListResourceSyncs { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result> { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for ListFullResourceSyncs { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let all_tags = if self.query.tags.is_empty() { vec![] } else { get_all_tags(None).await? }; Ok( resource::list_full_for_user::( self.query, user, PermissionLevel::Read.into(), &all_tags, ) .await?, ) } } impl Resolve for GetResourceSyncActionState { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let sync = get_check_permissions::( &self.sync, user, PermissionLevel::Read.into(), ) .await?; let action_state = action_states() .sync .get(&sync.id) .await .unwrap_or_default() .get()?; Ok(action_state) } } impl Resolve for GetResourceSyncsSummary { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let resource_syncs = resource::list_full_for_user::( Default::default(), user, PermissionLevel::Read.into(), &[], ) .await .context("failed to get resource_syncs from db")?; let mut res = GetResourceSyncsSummaryResponse::default(); let action_states = action_states(); for resource_sync in resource_syncs { res.total += 1; if !(resource_sync.info.pending_deploy.to_deploy == 0 && resource_sync.info.resource_updates.is_empty() && resource_sync.info.variable_updates.is_empty() && resource_sync.info.user_group_updates.is_empty()) { res.pending += 1; continue; } else if resource_sync.info.pending_error.is_some() || !resource_sync.info.remote_errors.is_empty() { res.failed += 1; continue; } if action_states .sync .get(&resource_sync.id) .await .unwrap_or_default() .get()? .syncing { res.syncing += 1; continue; } res.ok += 1; } Ok(res) } } impl Resolve for GetSyncWebhooksEnabled { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let Some(github) = github_client() else { return Ok(GetSyncWebhooksEnabledResponse { managed: false, refresh_enabled: false, sync_enabled: false, }); }; let sync = get_check_permissions::( &self.sync, user, PermissionLevel::Read.into(), ) .await?; if sync.config.git_provider != "github.com" || sync.config.repo.is_empty() { return Ok(GetSyncWebhooksEnabledResponse { managed: false, refresh_enabled: false, sync_enabled: false, }); } let mut split = sync.config.repo.split('/'); let owner = split.next().context("Sync repo has no owner")?; let Some(github) = github.get(owner) else { return Ok(GetSyncWebhooksEnabledResponse { managed: false, refresh_enabled: false, sync_enabled: false, }); }; let repo_name = split.next().context("Repo repo has no repo after the /")?; let github_repos = github.repos(); let webhooks = github_repos .list_all_webhooks(owner, repo_name) .await .context("failed to list all webhooks on repo")? .body; let CoreConfig { host, webhook_base_url, .. } = core_config(); let host = if webhook_base_url.is_empty() { host } else { webhook_base_url }; let refresh_url = format!("{host}/listener/github/sync/{}/refresh", sync.id); let sync_url = format!("{host}/listener/github/sync/{}/sync", sync.id); let mut refresh_enabled = false; let mut sync_enabled = false; for webhook in webhooks { if webhook.active && webhook.config.url == refresh_url { refresh_enabled = true } if webhook.active && webhook.config.url == sync_url { sync_enabled = true } } Ok(GetSyncWebhooksEnabledResponse { managed: true, refresh_enabled, sync_enabled, }) } } ================================================ FILE: bin/core/src/api/read/tag.rs ================================================ use anyhow::Context; use database::mongo_indexed::doc; use database::mungos::{ find::find_collect, mongodb::options::FindOptions, }; use komodo_client::{ api::read::{GetTag, ListTags}, entities::tag::Tag, }; use resolver_api::Resolve; use crate::{helpers::query::get_tag, state::db_client}; use super::ReadArgs; impl Resolve for GetTag { async fn resolve(self, _: &ReadArgs) -> serror::Result { Ok(get_tag(&self.tag).await?) } } impl Resolve for ListTags { async fn resolve(self, _: &ReadArgs) -> serror::Result> { let res = find_collect( &db_client().tags, self.query, FindOptions::builder().sort(doc! { "name": 1 }).build(), ) .await .context("failed to get tags from db")?; Ok(res) } } ================================================ FILE: bin/core/src/api/read/toml.rs ================================================ use anyhow::Context; use database::mungos::find::find_collect; use komodo_client::{ api::read::{ ExportAllResourcesToToml, ExportAllResourcesToTomlResponse, ExportResourcesToToml, ExportResourcesToTomlResponse, ListUserGroups, }, entities::{ ResourceTarget, action::Action, alerter::Alerter, build::Build, builder::Builder, deployment::Deployment, permission::PermissionLevel, procedure::Procedure, repo::Repo, resource::ResourceQuery, server::Server, stack::Stack, sync::ResourceSync, toml::ResourcesToml, user::User, }, }; use resolver_api::Resolve; use crate::{ helpers::query::{ get_all_tags, get_id_to_tags, get_user_user_group_ids, }, permission::get_check_permissions, resource, state::db_client, sync::{ toml::{ToToml, convert_resource}, user_groups::{convert_user_groups, user_group_to_toml}, variables::variable_to_toml, }, }; use super::ReadArgs; async fn get_all_targets( tags: &[String], user: &User, ) -> anyhow::Result> { let mut targets = Vec::::new(); let all_tags = if tags.is_empty() { vec![] } else { get_all_tags(None).await? }; targets.extend( resource::list_full_for_user::( ResourceQuery::builder().tags(tags).build(), user, PermissionLevel::Read.into(), &all_tags, ) .await? .into_iter() .map(|resource| ResourceTarget::Alerter(resource.id)), ); targets.extend( resource::list_full_for_user::( ResourceQuery::builder().tags(tags).build(), user, PermissionLevel::Read.into(), &all_tags, ) .await? .into_iter() .map(|resource| ResourceTarget::Builder(resource.id)), ); targets.extend( resource::list_full_for_user::( ResourceQuery::builder().tags(tags).build(), user, PermissionLevel::Read.into(), &all_tags, ) .await? .into_iter() .map(|resource| ResourceTarget::Server(resource.id)), ); targets.extend( resource::list_full_for_user::( ResourceQuery::builder().tags(tags).build(), user, PermissionLevel::Read.into(), &all_tags, ) .await? .into_iter() .map(|resource| ResourceTarget::Stack(resource.id)), ); targets.extend( resource::list_full_for_user::( ResourceQuery::builder().tags(tags).build(), user, PermissionLevel::Read.into(), &all_tags, ) .await? .into_iter() .map(|resource| ResourceTarget::Deployment(resource.id)), ); targets.extend( resource::list_full_for_user::( ResourceQuery::builder().tags(tags).build(), user, PermissionLevel::Read.into(), &all_tags, ) .await? .into_iter() .map(|resource| ResourceTarget::Build(resource.id)), ); targets.extend( resource::list_full_for_user::( ResourceQuery::builder().tags(tags).build(), user, PermissionLevel::Read.into(), &all_tags, ) .await? .into_iter() .map(|resource| ResourceTarget::Repo(resource.id)), ); targets.extend( resource::list_full_for_user::( ResourceQuery::builder().tags(tags).build(), user, PermissionLevel::Read.into(), &all_tags, ) .await? .into_iter() .map(|resource| ResourceTarget::Procedure(resource.id)), ); targets.extend( resource::list_full_for_user::( ResourceQuery::builder().tags(tags).build(), user, PermissionLevel::Read.into(), &all_tags, ) .await? .into_iter() .map(|resource| ResourceTarget::Action(resource.id)), ); targets.extend( resource::list_full_for_user::( ResourceQuery::builder().tags(tags).build(), user, PermissionLevel::Read.into(), &all_tags, ) .await? .into_iter() // These will already be filtered by [ExportResourcesToToml] .map(|resource| ResourceTarget::ResourceSync(resource.id)), ); Ok(targets) } impl Resolve for ExportAllResourcesToToml { async fn resolve( self, args: &ReadArgs, ) -> serror::Result { let targets = if self.include_resources { get_all_targets(&self.tags, &args.user).await? } else { Vec::new() }; let user_groups = if self.include_user_groups { if args.user.admin { find_collect(&db_client().user_groups, None, None) .await .context("failed to query db for user groups")? .into_iter() .map(|user_group| user_group.id) .collect() } else { get_user_user_group_ids(&args.user.id).await? } } else { Vec::new() }; ExportResourcesToToml { targets, user_groups, include_variables: self.include_variables, } .resolve(args) .await } } impl Resolve for ExportResourcesToToml { async fn resolve( self, args: &ReadArgs, ) -> serror::Result { let ExportResourcesToToml { targets, user_groups, include_variables, } = self; let mut res = ResourcesToml::default(); let id_to_tags = get_id_to_tags(None).await?; let ReadArgs { user } = args; for target in targets { match target { ResourceTarget::Alerter(id) => { let mut alerter = get_check_permissions::( &id, user, PermissionLevel::Read.into(), ) .await?; Alerter::replace_ids(&mut alerter); res.alerters.push(convert_resource::( alerter, false, vec![], &id_to_tags, )) } ResourceTarget::ResourceSync(id) => { let mut sync = get_check_permissions::( &id, user, PermissionLevel::Read.into(), ) .await?; if sync.config.file_contents.is_empty() && (sync.config.files_on_host || !sync.config.repo.is_empty() || !sync.config.linked_repo.is_empty()) { ResourceSync::replace_ids(&mut sync); res.resource_syncs.push(convert_resource::( sync, false, vec![], &id_to_tags, )) } } ResourceTarget::Server(id) => { let mut server = get_check_permissions::( &id, user, PermissionLevel::Read.into(), ) .await?; Server::replace_ids(&mut server); res.servers.push(convert_resource::( server, false, vec![], &id_to_tags, )) } ResourceTarget::Builder(id) => { let mut builder = get_check_permissions::( &id, user, PermissionLevel::Read.into(), ) .await?; Builder::replace_ids(&mut builder); res.builders.push(convert_resource::( builder, false, vec![], &id_to_tags, )) } ResourceTarget::Build(id) => { let mut build = get_check_permissions::( &id, user, PermissionLevel::Read.into(), ) .await?; Build::replace_ids(&mut build); res.builds.push(convert_resource::( build, false, vec![], &id_to_tags, )) } ResourceTarget::Deployment(id) => { let mut deployment = get_check_permissions::( &id, user, PermissionLevel::Read.into(), ) .await?; Deployment::replace_ids(&mut deployment); res.deployments.push(convert_resource::( deployment, false, vec![], &id_to_tags, )) } ResourceTarget::Repo(id) => { let mut repo = get_check_permissions::( &id, user, PermissionLevel::Read.into(), ) .await?; Repo::replace_ids(&mut repo); res.repos.push(convert_resource::( repo, false, vec![], &id_to_tags, )) } ResourceTarget::Stack(id) => { let mut stack = get_check_permissions::( &id, user, PermissionLevel::Read.into(), ) .await?; Stack::replace_ids(&mut stack); res.stacks.push(convert_resource::( stack, false, vec![], &id_to_tags, )) } ResourceTarget::Procedure(id) => { let mut procedure = get_check_permissions::( &id, user, PermissionLevel::Read.into(), ) .await?; Procedure::replace_ids(&mut procedure); res.procedures.push(convert_resource::( procedure, false, vec![], &id_to_tags, )); } ResourceTarget::Action(id) => { let mut action = get_check_permissions::( &id, user, PermissionLevel::Read.into(), ) .await?; Action::replace_ids(&mut action); res.actions.push(convert_resource::( action, false, vec![], &id_to_tags, )); } ResourceTarget::System(_) => continue, }; } add_user_groups(user_groups, &mut res, args) .await .context("failed to add user groups")?; if include_variables { res.variables = find_collect(&db_client().variables, None, None) .await .context("failed to get variables from db")? .into_iter() .map(|mut variable| { if !user.admin && variable.is_secret { variable.value = "#".repeat(variable.value.len()) } variable }) .collect(); } let toml = serialize_resources_toml(res) .context("failed to serialize resources to toml")?; Ok(ExportResourcesToTomlResponse { toml }) } } async fn add_user_groups( user_groups: Vec, res: &mut ResourcesToml, args: &ReadArgs, ) -> anyhow::Result<()> { let user_groups = ListUserGroups {} .resolve(args) .await .map_err(|e| e.error)? .into_iter() .filter(|ug| { user_groups.contains(&ug.name) || user_groups.contains(&ug.id) }); let mut ug = Vec::with_capacity(user_groups.size_hint().0); convert_user_groups(user_groups, &mut ug).await?; res.user_groups = ug.into_iter().map(|ug| ug.1).collect(); Ok(()) } fn serialize_resources_toml( resources: ResourcesToml, ) -> anyhow::Result { let mut toml = String::new(); for server in resources.servers { if !toml.is_empty() { toml.push_str("\n\n##\n\n"); } toml.push_str("[[server]]\n"); Server::push_to_toml_string(server, &mut toml)?; } for stack in resources.stacks { if !toml.is_empty() { toml.push_str("\n\n##\n\n"); } toml.push_str("[[stack]]\n"); Stack::push_to_toml_string(stack, &mut toml)?; } for deployment in resources.deployments { if !toml.is_empty() { toml.push_str("\n\n##\n\n"); } toml.push_str("[[deployment]]\n"); Deployment::push_to_toml_string(deployment, &mut toml)?; } for build in resources.builds { if !toml.is_empty() { toml.push_str("\n\n##\n\n"); } toml.push_str("[[build]]\n"); Build::push_to_toml_string(build, &mut toml)?; } for repo in resources.repos { if !toml.is_empty() { toml.push_str("\n\n##\n\n"); } toml.push_str("[[repo]]\n"); Repo::push_to_toml_string(repo, &mut toml)?; } for procedure in resources.procedures { if !toml.is_empty() { toml.push_str("\n\n##\n\n"); } toml.push_str("[[procedure]]\n"); Procedure::push_to_toml_string(procedure, &mut toml)?; } for action in resources.actions { if !toml.is_empty() { toml.push_str("\n\n##\n\n"); } toml.push_str("[[action]]\n"); Action::push_to_toml_string(action, &mut toml)?; } for alerter in resources.alerters { if !toml.is_empty() { toml.push_str("\n\n##\n\n"); } toml.push_str("[[alerter]]\n"); Alerter::push_to_toml_string(alerter, &mut toml)?; } for builder in resources.builders { if !toml.is_empty() { toml.push_str("\n\n##\n\n"); } toml.push_str("[[builder]]\n"); Builder::push_to_toml_string(builder, &mut toml)?; } for resource_sync in resources.resource_syncs { if !toml.is_empty() { toml.push_str("\n\n##\n\n"); } toml.push_str("[[resource_sync]]\n"); ResourceSync::push_to_toml_string(resource_sync, &mut toml)?; } for variable in &resources.variables { if !toml.is_empty() { toml.push_str("\n\n##\n\n"); } toml.push_str(&variable_to_toml(variable)?); } for user_group in resources.user_groups { if !toml.is_empty() { toml.push_str("\n\n##\n\n"); } toml.push_str(&user_group_to_toml(user_group)?); } Ok(toml) } ================================================ FILE: bin/core/src/api/read/update.rs ================================================ use std::collections::HashMap; use anyhow::{Context, anyhow}; use database::mungos::{ by_id::find_one_by_id, find::find_collect, mongodb::{bson::doc, options::FindOptions}, }; use komodo_client::{ api::read::{GetUpdate, ListUpdates, ListUpdatesResponse}, entities::{ ResourceTarget, action::Action, alerter::Alerter, build::Build, builder::Builder, deployment::Deployment, permission::PermissionLevel, procedure::Procedure, repo::Repo, server::Server, stack::Stack, sync::ResourceSync, update::{Update, UpdateListItem}, user::User, }, }; use resolver_api::Resolve; use crate::{ config::core_config, permission::{get_check_permissions, get_resource_ids_for_user}, state::db_client, }; use super::ReadArgs; const UPDATES_PER_PAGE: i64 = 100; impl Resolve for ListUpdates { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let query = if user.admin || core_config().transparent_mode { self.query } else { let server_query = get_resource_ids_for_user::(user) .await? .map(|ids| { doc! { "target.type": "Server", "target.id": { "$in": ids } } }) .unwrap_or_else(|| doc! { "target.type": "Server" }); let deployment_query = get_resource_ids_for_user::(user) .await? .map(|ids| { doc! { "target.type": "Deployment", "target.id": { "$in": ids } } }) .unwrap_or_else(|| doc! { "target.type": "Deployment" }); let stack_query = get_resource_ids_for_user::(user) .await? .map(|ids| { doc! { "target.type": "Stack", "target.id": { "$in": ids } } }) .unwrap_or_else(|| doc! { "target.type": "Stack" }); let build_query = get_resource_ids_for_user::(user) .await? .map(|ids| { doc! { "target.type": "Build", "target.id": { "$in": ids } } }) .unwrap_or_else(|| doc! { "target.type": "Build" }); let repo_query = get_resource_ids_for_user::(user) .await? .map(|ids| { doc! { "target.type": "Repo", "target.id": { "$in": ids } } }) .unwrap_or_else(|| doc! { "target.type": "Repo" }); let procedure_query = get_resource_ids_for_user::(user) .await? .map(|ids| { doc! { "target.type": "Procedure", "target.id": { "$in": ids } } }) .unwrap_or_else(|| doc! { "target.type": "Procedure" }); let action_query = get_resource_ids_for_user::(user) .await? .map(|ids| { doc! { "target.type": "Action", "target.id": { "$in": ids } } }) .unwrap_or_else(|| doc! { "target.type": "Action" }); let builder_query = get_resource_ids_for_user::(user) .await? .map(|ids| { doc! { "target.type": "Builder", "target.id": { "$in": ids } } }) .unwrap_or_else(|| doc! { "target.type": "Builder" }); let alerter_query = get_resource_ids_for_user::(user) .await? .map(|ids| { doc! { "target.type": "Alerter", "target.id": { "$in": ids } } }) .unwrap_or_else(|| doc! { "target.type": "Alerter" }); let resource_sync_query = get_resource_ids_for_user::< ResourceSync, >(user) .await? .map(|ids| { doc! { "target.type": "ResourceSync", "target.id": { "$in": ids } } }) .unwrap_or_else(|| doc! { "target.type": "ResourceSync" }); let mut query = self.query.unwrap_or_default(); query.extend(doc! { "$or": [ server_query, deployment_query, stack_query, build_query, repo_query, procedure_query, action_query, alerter_query, builder_query, resource_sync_query, ] }); query.into() }; let usernames = find_collect(&db_client().users, None, None) .await .context("failed to pull users from db")? .into_iter() .map(|u| (u.id, u.username)) .collect::>(); let updates = find_collect( &db_client().updates, query, FindOptions::builder() .sort(doc! { "start_ts": -1 }) .skip(self.page as u64 * UPDATES_PER_PAGE as u64) .limit(UPDATES_PER_PAGE) .build(), ) .await .context("failed to pull updates from db")? .into_iter() .map(|u| { let username = if User::is_service_user(&u.operator) { u.operator.clone() } else { usernames .get(&u.operator) .cloned() .unwrap_or("unknown".to_string()) }; UpdateListItem { username, id: u.id, operation: u.operation, start_ts: u.start_ts, success: u.success, operator: u.operator, target: u.target, status: u.status, version: u.version, other_data: u.other_data, } }) .collect::>(); let next_page = if updates.len() == UPDATES_PER_PAGE as usize { Some(self.page + 1) } else { None }; Ok(ListUpdatesResponse { updates, next_page }) } } impl Resolve for GetUpdate { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let update = find_one_by_id(&db_client().updates, &self.id) .await .context("failed to query to db")? .context("no update exists with given id")?; if user.admin || core_config().transparent_mode { return Ok(update); } match &update.target { ResourceTarget::System(_) => { return Err( anyhow!("user must be admin to view system updates").into(), ); } ResourceTarget::Server(id) => { get_check_permissions::( id, user, PermissionLevel::Read.into(), ) .await?; } ResourceTarget::Deployment(id) => { get_check_permissions::( id, user, PermissionLevel::Read.into(), ) .await?; } ResourceTarget::Build(id) => { get_check_permissions::( id, user, PermissionLevel::Read.into(), ) .await?; } ResourceTarget::Repo(id) => { get_check_permissions::( id, user, PermissionLevel::Read.into(), ) .await?; } ResourceTarget::Builder(id) => { get_check_permissions::( id, user, PermissionLevel::Read.into(), ) .await?; } ResourceTarget::Alerter(id) => { get_check_permissions::( id, user, PermissionLevel::Read.into(), ) .await?; } ResourceTarget::Procedure(id) => { get_check_permissions::( id, user, PermissionLevel::Read.into(), ) .await?; } ResourceTarget::Action(id) => { get_check_permissions::( id, user, PermissionLevel::Read.into(), ) .await?; } ResourceTarget::ResourceSync(id) => { get_check_permissions::( id, user, PermissionLevel::Read.into(), ) .await?; } ResourceTarget::Stack(id) => { get_check_permissions::( id, user, PermissionLevel::Read.into(), ) .await?; } } Ok(update) } } ================================================ FILE: bin/core/src/api/read/user.rs ================================================ use anyhow::{Context, anyhow}; use database::mungos::{ by_id::find_one_by_id, find::find_collect, mongodb::{bson::doc, options::FindOptions}, }; use komodo_client::{ api::read::{ FindUser, FindUserResponse, GetUsername, GetUsernameResponse, ListApiKeys, ListApiKeysForServiceUser, ListApiKeysForServiceUserResponse, ListApiKeysResponse, ListUsers, ListUsersResponse, }, entities::user::{UserConfig, admin_service_user}, }; use resolver_api::Resolve; use crate::{helpers::query::get_user, state::db_client}; use super::ReadArgs; impl Resolve for GetUsername { async fn resolve( self, _: &ReadArgs, ) -> serror::Result { if let Some(user) = admin_service_user(&self.user_id) { return Ok(GetUsernameResponse { username: user.username, avatar: None, }); } let user = find_one_by_id(&db_client().users, &self.user_id) .await .context("failed at mongo query for user")? .context("no user found with id")?; let avatar = match user.config { UserConfig::Github { avatar, .. } => Some(avatar), UserConfig::Google { avatar, .. } => Some(avatar), _ => None, }; Ok(GetUsernameResponse { username: user.username, avatar, }) } } impl Resolve for FindUser { async fn resolve( self, ReadArgs { user: admin }: &ReadArgs, ) -> serror::Result { if !admin.admin { return Err(anyhow!("This method is admin only.").into()); } Ok(get_user(&self.user).await?) } } impl Resolve for ListUsers { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { if !user.admin { return Err( anyhow!("this route is only accessable by admins").into(), ); } let mut users = find_collect( &db_client().users, None, FindOptions::builder().sort(doc! { "username": 1 }).build(), ) .await .context("failed to pull users from db")?; users.iter_mut().for_each(|user| user.sanitize()); Ok(users) } } impl Resolve for ListApiKeys { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let api_keys = find_collect( &db_client().api_keys, doc! { "user_id": &user.id }, FindOptions::builder().sort(doc! { "name": 1 }).build(), ) .await .context("failed to query db for api keys")? .into_iter() .map(|mut api_keys| { api_keys.sanitize(); api_keys }) .collect(); Ok(api_keys) } } impl Resolve for ListApiKeysForServiceUser { async fn resolve( self, ReadArgs { user: admin }: &ReadArgs, ) -> serror::Result { if !admin.admin { return Err(anyhow!("This method is admin only.").into()); } let user = get_user(&self.user).await?; let UserConfig::Service { .. } = user.config else { return Err(anyhow!("Given user is not service user").into()); }; let api_keys = find_collect( &db_client().api_keys, doc! { "user_id": &user.id }, None, ) .await .context("failed to query db for api keys")? .into_iter() .map(|mut api_keys| { api_keys.sanitize(); api_keys }) .collect(); Ok(api_keys) } } ================================================ FILE: bin/core/src/api/read/user_group.rs ================================================ use std::str::FromStr; use anyhow::Context; use database::mungos::{ find::find_collect, mongodb::{ bson::{Document, doc, oid::ObjectId}, options::FindOptions, }, }; use komodo_client::api::read::*; use resolver_api::Resolve; use crate::state::db_client; use super::ReadArgs; impl Resolve for GetUserGroup { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let mut filter = match ObjectId::from_str(&self.user_group) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": &self.user_group }, }; // Don't allow non admin users to get UserGroups they aren't a part of. if !user.admin { // Filter for only UserGroups which contain the users id filter.insert("users", &user.id); } let res = db_client() .user_groups .find_one(filter) .await .context("failed to query db for user groups")? .context("no UserGroup found with given name or id")?; Ok(res) } } impl Resolve for ListUserGroups { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let mut filter = Document::new(); if !user.admin { filter.insert("users", &user.id); } let res = find_collect( &db_client().user_groups, filter, FindOptions::builder().sort(doc! { "name": 1 }).build(), ) .await .context("failed to query db for UserGroups")?; Ok(res) } } ================================================ FILE: bin/core/src/api/read/variable.rs ================================================ use anyhow::Context; use database::mongo_indexed::doc; use database::mungos::{ find::find_collect, mongodb::options::FindOptions, }; use komodo_client::api::read::*; use resolver_api::Resolve; use crate::{helpers::query::get_variable, state::db_client}; use super::ReadArgs; impl Resolve for GetVariable { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let mut variable = get_variable(&self.name).await?; if !variable.is_secret || user.admin { return Ok(variable); } variable.value = "#".repeat(variable.value.len()); Ok(variable) } } impl Resolve for ListVariables { async fn resolve( self, ReadArgs { user }: &ReadArgs, ) -> serror::Result { let variables = find_collect( &db_client().variables, None, FindOptions::builder().sort(doc! { "name": 1 }).build(), ) .await .context("failed to query db for variables")?; if user.admin { return Ok(variables); } let variables = variables .into_iter() .map(|mut variable| { if variable.is_secret { variable.value = "#".repeat(variable.value.len()); } variable }) .collect(); Ok(variables) } } ================================================ FILE: bin/core/src/api/terminal.rs ================================================ use anyhow::Context; use axum::{Extension, Router, middleware, routing::post}; use komodo_client::{ api::terminal::*, entities::{ deployment::Deployment, permission::PermissionLevel, server::Server, stack::Stack, user::User, }, }; use serror::Json; use uuid::Uuid; use crate::{ auth::auth_request, helpers::periphery_client, permission::get_check_permissions, resource::get, state::stack_status_cache, }; pub fn router() -> Router { Router::new() .route("/execute", post(execute_terminal)) .route("/execute/container", post(execute_container_exec)) .route("/execute/deployment", post(execute_deployment_exec)) .route("/execute/stack", post(execute_stack_exec)) .layer(middleware::from_fn(auth_request)) } // ================= // ExecuteTerminal // ================= async fn execute_terminal( Extension(user): Extension, Json(request): Json, ) -> serror::Result { execute_terminal_inner(Uuid::new_v4(), request, user).await } #[instrument( name = "ExecuteTerminal", skip(user), fields( user_id = user.id, ) )] async fn execute_terminal_inner( req_id: Uuid, ExecuteTerminalBody { server, terminal, command, }: ExecuteTerminalBody, user: User, ) -> serror::Result { info!("/terminal/execute request | user: {}", user.username); let res = async { let server = get_check_permissions::( &server, &user, PermissionLevel::Read.terminal(), ) .await?; let periphery = periphery_client(&server)?; let stream = periphery .execute_terminal(terminal, command) .await .context("Failed to execute command on periphery")?; anyhow::Ok(stream) } .await; let stream = match res { Ok(stream) => stream, Err(e) => { warn!("/terminal/execute request {req_id} error: {e:#}"); return Err(e.into()); } }; Ok(axum::body::Body::from_stream(stream.into_line_stream())) } // ====================== // ExecuteContainerExec // ====================== async fn execute_container_exec( Extension(user): Extension, Json(request): Json, ) -> serror::Result { execute_container_exec_inner(Uuid::new_v4(), request, user).await } #[instrument( name = "ExecuteContainerExec", skip(user), fields( user_id = user.id, ) )] async fn execute_container_exec_inner( req_id: Uuid, ExecuteContainerExecBody { server, container, shell, command, }: ExecuteContainerExecBody, user: User, ) -> serror::Result { info!( "/terminal/execute/container request | user: {}", user.username ); let res = async { let server = get_check_permissions::( &server, &user, PermissionLevel::Read.terminal(), ) .await?; let periphery = periphery_client(&server)?; let stream = periphery .execute_container_exec(container, shell, command) .await .context( "Failed to execute container exec command on periphery", )?; anyhow::Ok(stream) } .await; let stream = match res { Ok(stream) => stream, Err(e) => { warn!( "/terminal/execute/container request {req_id} error: {e:#}" ); return Err(e.into()); } }; Ok(axum::body::Body::from_stream(stream.into_line_stream())) } // ======================= // ExecuteDeploymentExec // ======================= async fn execute_deployment_exec( Extension(user): Extension, Json(request): Json, ) -> serror::Result { execute_deployment_exec_inner(Uuid::new_v4(), request, user).await } #[instrument( name = "ExecuteDeploymentExec", skip(user), fields( user_id = user.id, ) )] async fn execute_deployment_exec_inner( req_id: Uuid, ExecuteDeploymentExecBody { deployment, shell, command, }: ExecuteDeploymentExecBody, user: User, ) -> serror::Result { info!( "/terminal/execute/deployment request | user: {}", user.username ); let res = async { let deployment = get_check_permissions::( &deployment, &user, PermissionLevel::Read.terminal(), ) .await?; let server = get::(&deployment.config.server_id).await?; let periphery = periphery_client(&server)?; let stream = periphery .execute_container_exec(deployment.name, shell, command) .await .context( "Failed to execute container exec command on periphery", )?; anyhow::Ok(stream) } .await; let stream = match res { Ok(stream) => stream, Err(e) => { warn!( "/terminal/execute/deployment request {req_id} error: {e:#}" ); return Err(e.into()); } }; Ok(axum::body::Body::from_stream(stream.into_line_stream())) } // ================== // ExecuteStackExec // ================== async fn execute_stack_exec( Extension(user): Extension, Json(request): Json, ) -> serror::Result { execute_stack_exec_inner(Uuid::new_v4(), request, user).await } #[instrument( name = "ExecuteStackExec", skip(user), fields( user_id = user.id, ) )] async fn execute_stack_exec_inner( req_id: Uuid, ExecuteStackExecBody { stack, service, shell, command, }: ExecuteStackExecBody, user: User, ) -> serror::Result { info!("/terminal/execute/stack request | user: {}", user.username); let res = async { let stack = get_check_permissions::( &stack, &user, PermissionLevel::Read.terminal(), ) .await?; let server = get::(&stack.config.server_id).await?; let container = stack_status_cache() .get(&stack.id) .await .context("could not get stack status")? .curr .services .iter() .find(|s| s.service == service) .context("could not find service")? .container .as_ref() .context("could not find service container")? .name .clone(); let periphery = periphery_client(&server)?; let stream = periphery .execute_container_exec(container, shell, command) .await .context( "Failed to execute container exec command on periphery", )?; anyhow::Ok(stream) } .await; let stream = match res { Ok(stream) => stream, Err(e) => { warn!("/terminal/execute/stack request {req_id} error: {e:#}"); return Err(e.into()); } }; Ok(axum::body::Body::from_stream(stream.into_line_stream())) } ================================================ FILE: bin/core/src/api/user.rs ================================================ use std::{collections::VecDeque, time::Instant}; use anyhow::{Context, anyhow}; use axum::{ Extension, Json, Router, extract::Path, middleware, routing::post, }; use database::mongo_indexed::doc; use database::mungos::{ by_id::update_one_by_id, mongodb::bson::to_bson, }; use derive_variants::EnumVariants; use komodo_client::{ api::user::*, entities::{api_key::ApiKey, komodo_timestamp, user::User}, }; use resolver_api::Resolve; use response::Response; use serde::{Deserialize, Serialize}; use serde_json::json; use typeshare::typeshare; use uuid::Uuid; use crate::{ auth::auth_request, helpers::{query::get_user, random_string}, state::db_client, }; use super::Variant; pub struct UserArgs { pub user: User, } #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EnumVariants, )] #[args(UserArgs)] #[response(Response)] #[error(serror::Error)] #[serde(tag = "type", content = "params")] enum UserRequest { PushRecentlyViewed(PushRecentlyViewed), SetLastSeenUpdate(SetLastSeenUpdate), CreateApiKey(CreateApiKey), DeleteApiKey(DeleteApiKey), } pub fn router() -> Router { Router::new() .route("/", post(handler)) .route("/{variant}", post(variant_handler)) .layer(middleware::from_fn(auth_request)) } async fn variant_handler( user: Extension, Path(Variant { variant }): Path, Json(params): Json, ) -> serror::Result { let req: UserRequest = serde_json::from_value(json!({ "type": variant, "params": params, }))?; handler(user, Json(req)).await } #[instrument(name = "UserHandler", level = "debug", skip(user))] async fn handler( Extension(user): Extension, Json(request): Json, ) -> serror::Result { let timer = Instant::now(); let req_id = Uuid::new_v4(); debug!( "/user request {req_id} | user: {} ({})", user.username, user.id ); let res = request.resolve(&UserArgs { user }).await; if let Err(e) = &res { warn!("/user request {req_id} error: {:#}", e.error); } let elapsed = timer.elapsed(); debug!("/user request {req_id} | resolve time: {elapsed:?}"); res.map(|res| res.0) } const RECENTLY_VIEWED_MAX: usize = 10; impl Resolve for PushRecentlyViewed { #[instrument( name = "PushRecentlyViewed", level = "debug", skip(user) )] async fn resolve( self, UserArgs { user }: &UserArgs, ) -> serror::Result { let user = get_user(&user.id).await?; let (resource_type, id) = self.resource.extract_variant_id(); let update = match user.recents.get(&resource_type) { Some(recents) => { let mut recents = recents .iter() .filter(|_id| !id.eq(*_id)) .take(RECENTLY_VIEWED_MAX - 1) .collect::>(); recents.push_front(id); doc! { format!("recents.{resource_type}"): to_bson(&recents)? } } None => { doc! { format!("recents.{resource_type}"): [id] } } }; update_one_by_id( &db_client().users, &user.id, database::mungos::update::Update::Set(update), None, ) .await .with_context(|| { format!("failed to update recents.{resource_type}") })?; Ok(PushRecentlyViewedResponse {}) } } impl Resolve for SetLastSeenUpdate { #[instrument( name = "SetLastSeenUpdate", level = "debug", skip(user) )] async fn resolve( self, UserArgs { user }: &UserArgs, ) -> serror::Result { update_one_by_id( &db_client().users, &user.id, database::mungos::update::Update::Set(doc! { "last_update_view": komodo_timestamp() }), None, ) .await .context("failed to update user last_update_view")?; Ok(SetLastSeenUpdateResponse {}) } } const SECRET_LENGTH: usize = 40; const BCRYPT_COST: u32 = 10; impl Resolve for CreateApiKey { #[instrument(name = "CreateApiKey", level = "debug", skip(user))] async fn resolve( self, UserArgs { user }: &UserArgs, ) -> serror::Result { let user = get_user(&user.id).await?; let key = format!("K-{}", random_string(SECRET_LENGTH)); let secret = format!("S-{}", random_string(SECRET_LENGTH)); let secret_hash = bcrypt::hash(&secret, BCRYPT_COST) .context("failed at hashing secret string")?; let api_key = ApiKey { name: self.name, key: key.clone(), secret: secret_hash, user_id: user.id.clone(), created_at: komodo_timestamp(), expires: self.expires, }; db_client() .api_keys .insert_one(api_key) .await .context("failed to create api key on db")?; Ok(CreateApiKeyResponse { key, secret }) } } impl Resolve for DeleteApiKey { #[instrument(name = "DeleteApiKey", level = "debug", skip(user))] async fn resolve( self, UserArgs { user }: &UserArgs, ) -> serror::Result { let client = db_client(); let key = client .api_keys .find_one(doc! { "key": &self.key }) .await .context("failed at db query")? .context("no api key with key found")?; if user.id != key.user_id { return Err(anyhow!("api key does not belong to user").into()); } client .api_keys .delete_one(doc! { "key": key.key }) .await .context("failed to delete api key from db")?; Ok(DeleteApiKeyResponse {}) } } ================================================ FILE: bin/core/src/api/write/action.rs ================================================ use komodo_client::{ api::write::*, entities::{ action::Action, permission::PermissionLevel, update::Update, }, }; use resolver_api::Resolve; use crate::{permission::get_check_permissions, resource}; use super::WriteArgs; impl Resolve for CreateAction { #[instrument(name = "CreateAction", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { resource::create::(&self.name, self.config, user).await } } impl Resolve for CopyAction { #[instrument(name = "CopyAction", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let Action { config, .. } = get_check_permissions::( &self.id, user, PermissionLevel::Write.into(), ) .await?; resource::create::(&self.name, config.into(), user).await } } impl Resolve for UpdateAction { #[instrument(name = "UpdateAction", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok(resource::update::(&self.id, self.config, user).await?) } } impl Resolve for RenameAction { #[instrument(name = "RenameAction", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok(resource::rename::(&self.id, &self.name, user).await?) } } impl Resolve for DeleteAction { #[instrument(name = "DeleteAction", skip(args))] async fn resolve(self, args: &WriteArgs) -> serror::Result { Ok(resource::delete::(&self.id, args).await?) } } ================================================ FILE: bin/core/src/api/write/alerter.rs ================================================ use komodo_client::{ api::write::*, entities::{ alerter::Alerter, permission::PermissionLevel, update::Update, }, }; use resolver_api::Resolve; use crate::{permission::get_check_permissions, resource}; use super::WriteArgs; impl Resolve for CreateAlerter { #[instrument(name = "CreateAlerter", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { resource::create::(&self.name, self.config, user).await } } impl Resolve for CopyAlerter { #[instrument(name = "CopyAlerter", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let Alerter { config, .. } = get_check_permissions::( &self.id, user, PermissionLevel::Write.into(), ) .await?; resource::create::(&self.name, config.into(), user).await } } impl Resolve for DeleteAlerter { #[instrument(name = "DeleteAlerter", skip(args))] async fn resolve( self, args: &WriteArgs, ) -> serror::Result { Ok(resource::delete::(&self.id, args).await?) } } impl Resolve for UpdateAlerter { #[instrument(name = "UpdateAlerter", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok( resource::update::(&self.id, self.config, user) .await?, ) } } impl Resolve for RenameAlerter { #[instrument(name = "RenameAlerter", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok(resource::rename::(&self.id, &self.name, user).await?) } } ================================================ FILE: bin/core/src/api/write/build.rs ================================================ use std::{path::PathBuf, str::FromStr, time::Duration}; use anyhow::{Context, anyhow}; use database::mongo_indexed::doc; use database::mungos::mongodb::bson::to_document; use formatting::format_serror; use komodo_client::{ api::write::*, entities::{ FileContents, NoData, Operation, RepoExecutionArgs, all_logs_success, build::{Build, BuildInfo, PartialBuildConfig}, builder::{Builder, BuilderConfig}, config::core::CoreConfig, permission::PermissionLevel, repo::Repo, server::ServerState, update::Update, }, }; use octorust::types::{ ReposCreateWebhookRequest, ReposCreateWebhookRequestConfig, }; use periphery_client::{ PeripheryClient, api::build::{ GetDockerfileContentsOnHost, WriteDockerfileContentsToHost, }, }; use resolver_api::Resolve; use tokio::fs; use crate::{ config::core_config, helpers::{ git_token, periphery_client, query::get_server_with_state, update::{add_update, make_update}, }, permission::get_check_permissions, resource, state::{db_client, github_client}, }; use super::WriteArgs; impl Resolve for CreateBuild { #[instrument(name = "CreateBuild", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { resource::create::(&self.name, self.config, user).await } } impl Resolve for CopyBuild { #[instrument(name = "CopyBuild", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let Build { mut config, .. } = get_check_permissions::( &self.id, user, PermissionLevel::Read.into(), ) .await?; // reset version to 0.0.0 config.version = Default::default(); resource::create::(&self.name, config.into(), user).await } } impl Resolve for DeleteBuild { #[instrument(name = "DeleteBuild", skip(args))] async fn resolve(self, args: &WriteArgs) -> serror::Result { Ok(resource::delete::(&self.id, args).await?) } } impl Resolve for UpdateBuild { #[instrument(name = "UpdateBuild", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok(resource::update::(&self.id, self.config, user).await?) } } impl Resolve for RenameBuild { #[instrument(name = "RenameBuild", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok(resource::rename::(&self.id, &self.name, user).await?) } } impl Resolve for WriteBuildFileContents { #[instrument(name = "WriteBuildFileContents", skip(args))] async fn resolve(self, args: &WriteArgs) -> serror::Result { let build = get_check_permissions::( &self.build, &args.user, PermissionLevel::Write.into(), ) .await?; if !build.config.files_on_host && build.config.repo.is_empty() && build.config.linked_repo.is_empty() { return Err(anyhow!( "Build is not configured to use Files on Host or Git Repo, can't write dockerfile contents" ).into()); } let mut update = make_update(&build, Operation::WriteDockerfile, &args.user); update.push_simple_log("Dockerfile to write", &self.contents); if build.config.files_on_host { match get_on_host_periphery(&build) .await? .request(WriteDockerfileContentsToHost { name: build.name, build_path: build.config.build_path, dockerfile_path: build.config.dockerfile_path, contents: self.contents, }) .await .context("Failed to write dockerfile contents to host") { Ok(log) => { update.logs.push(log); } Err(e) => { update.push_error_log( "Write Dockerfile Contents", format_serror(&e.into()), ); } }; if !all_logs_success(&update.logs) { update.finalize(); update.id = add_update(update.clone()).await?; return Ok(update); } if let Err(e) = (RefreshBuildCache { build: build.id }).resolve(args).await { update.push_error_log( "Refresh build cache", format_serror(&e.error.into()), ); } update.finalize(); update.id = add_update(update.clone()).await?; Ok(update) } else { write_dockerfile_contents_git(self, args, build, update).await } } } async fn write_dockerfile_contents_git( req: WriteBuildFileContents, args: &WriteArgs, build: Build, mut update: Update, ) -> serror::Result { let WriteBuildFileContents { build: _, contents } = req; let mut repo_args: RepoExecutionArgs = if !build .config .files_on_host && !build.config.linked_repo.is_empty() { (&crate::resource::get::(&build.config.linked_repo).await?) .into() } else { (&build).into() }; let root = repo_args.unique_path(&core_config().repo_directory)?; repo_args.destination = Some(root.display().to_string()); let build_path = build .config .build_path .parse::() .context("Invalid build path")?; let dockerfile_path = build .config .dockerfile_path .parse::() .context("Invalid dockerfile path")?; let full_path = root.join(&build_path).join(&dockerfile_path); if let Some(parent) = full_path.parent() { fs::create_dir_all(parent).await.with_context(|| { format!( "Failed to initialize dockerfile parent directory {parent:?}" ) })?; } let access_token = if let Some(account) = &repo_args.account { git_token(&repo_args.provider, account, |https| repo_args.https = https) .await .with_context( || format!("Failed to get git token in call to db. Stopping run. | {} | {account}", repo_args.provider), )? } else { None }; // Ensure the folder is initialized as git repo. // This allows a new file to be committed on a branch that may not exist. if !root.join(".git").exists() { git::init_folder_as_repo( &root, &repo_args, access_token.as_deref(), &mut update.logs, ) .await; if !all_logs_success(&update.logs) { update.finalize(); update.id = add_update(update.clone()).await?; return Ok(update); } } // Save this for later -- repo_args moved next. let branch = repo_args.branch.clone(); // Pull latest changes to repo to ensure linear commit history match git::pull_or_clone( repo_args, &core_config().repo_directory, access_token, ) .await .context("Failed to pull latest changes before commit") { Ok((res, _)) => update.logs.extend(res.logs), Err(e) => { update.push_error_log("Pull Repo", format_serror(&e.into())); update.finalize(); return Ok(update); } }; if !all_logs_success(&update.logs) { update.finalize(); update.id = add_update(update.clone()).await?; return Ok(update); } if let Err(e) = fs::write(&full_path, &contents).await.with_context(|| { format!("Failed to write dockerfile contents to {full_path:?}") }) { update .push_error_log("Write Dockerfile", format_serror(&e.into())); } else { update.push_simple_log( "Write Dockerfile", format!("File written to {full_path:?}"), ); }; if !all_logs_success(&update.logs) { update.finalize(); update.id = add_update(update.clone()).await?; return Ok(update); } let commit_res = git::commit_file( &format!("{}: Commit Dockerfile", args.user.username), &root, &build_path.join(&dockerfile_path), &branch, ) .await; update.logs.extend(commit_res.logs); if let Err(e) = (RefreshBuildCache { build: build.name }) .resolve(args) .await { update.push_error_log( "Refresh build cache", format_serror(&e.error.into()), ); } update.finalize(); update.id = add_update(update.clone()).await?; Ok(update) } impl Resolve for RefreshBuildCache { #[instrument( name = "RefreshBuildCache", level = "debug", skip(user) )] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { // Even though this is a write request, this doesn't change any config. Anyone that can execute the // build should be able to do this. let build = get_check_permissions::( &self.build, user, PermissionLevel::Execute.into(), ) .await?; let repo = if !build.config.files_on_host && !build.config.linked_repo.is_empty() { crate::resource::get::(&build.config.linked_repo) .await? .into() } else { None }; let ( remote_path, remote_contents, remote_error, latest_hash, latest_message, ) = if build.config.files_on_host { // ============= // FILES ON HOST // ============= match get_on_host_dockerfile(&build).await { Ok(FileContents { path, contents }) => { (Some(path), Some(contents), None, None, None) } Err(e) => { (None, None, Some(format_serror(&e.into())), None, None) } } } else if let Some(repo) = &repo { let Some(res) = get_git_remote(&build, repo.into()).await? else { // Nothing to do here return Ok(NoData {}); }; res } else if !build.config.repo.is_empty() { let Some(res) = get_git_remote(&build, (&build).into()).await? else { // Nothing to do here return Ok(NoData {}); }; res } else { // ============= // UI BASED FILE // ============= (None, None, None, None, None) }; let info = BuildInfo { last_built_at: build.info.last_built_at, built_hash: build.info.built_hash, built_message: build.info.built_message, built_contents: build.info.built_contents, remote_path, remote_contents, remote_error, latest_hash, latest_message, }; let info = to_document(&info) .context("failed to serialize build info to bson")?; db_client() .builds .update_one( doc! { "name": &build.name }, doc! { "$set": { "info": info } }, ) .await .context("failed to update build info on db")?; Ok(NoData {}) } } async fn get_on_host_periphery( build: &Build, ) -> anyhow::Result { if build.config.builder_id.is_empty() { return Err(anyhow!("No builder associated with build")); } let builder = resource::get::(&build.config.builder_id) .await .context("Failed to get builder")?; match builder.config { BuilderConfig::Aws(_) => { Err(anyhow!("Files on host doesn't work with AWS builder")) } BuilderConfig::Url(config) => { let periphery = PeripheryClient::new( config.address, config.passkey, Duration::from_secs(3), ); periphery.health_check().await?; Ok(periphery) } BuilderConfig::Server(config) => { if config.server_id.is_empty() { return Err(anyhow!( "Builder is type server, but has no server attached" )); } let (server, state) = get_server_with_state(&config.server_id).await?; if state != ServerState::Ok { return Err(anyhow!( "Builder server is disabled or not reachable" )); }; periphery_client(&server) } } } /// The successful case will be included as Some(remote_contents). /// The error case will be included as Some(remote_error) async fn get_on_host_dockerfile( build: &Build, ) -> anyhow::Result { get_on_host_periphery(build) .await? .request(GetDockerfileContentsOnHost { name: build.name.clone(), build_path: build.config.build_path.clone(), dockerfile_path: build.config.dockerfile_path.clone(), }) .await } async fn get_git_remote( build: &Build, mut clone_args: RepoExecutionArgs, ) -> anyhow::Result< Option<( Option, Option, Option, Option, Option, )>, > { if clone_args.provider.is_empty() { // Nothing to do here return Ok(None); } let config = core_config(); let repo_path = clone_args.unique_path(&config.repo_directory)?; clone_args.destination = Some(repo_path.display().to_string()); let access_token = if let Some(username) = &clone_args.account { git_token(&clone_args.provider, username, |https| { clone_args.https = https }) .await .with_context( || format!("Failed to get git token in call to db. Stopping run. | {} | {username}", clone_args.provider), )? } else { None }; let (res, _) = git::pull_or_clone( clone_args, &config.repo_directory, access_token, ) .await .context("failed to clone build repo")?; let relative_path = PathBuf::from_str(&build.config.build_path) .context("Invalid build path")? .join(&build.config.dockerfile_path); let full_path = repo_path.join(&relative_path); let (contents, error) = match fs::read_to_string(&full_path).await.with_context(|| { format!("Failed to read dockerfile contents at {full_path:?}") }) { Ok(contents) => (Some(contents), None), Err(e) => (None, Some(format_serror(&e.into()))), }; Ok(Some(( Some(relative_path.display().to_string()), contents, error, res.commit_hash, res.commit_message, ))) } impl Resolve for CreateBuildWebhook { #[instrument(name = "CreateBuildWebhook", skip(args))] async fn resolve( self, args: &WriteArgs, ) -> serror::Result { let Some(github) = github_client() else { return Err( anyhow!( "github_webhook_app is not configured in core config toml" ) .into(), ); }; let WriteArgs { user } = args; let build = get_check_permissions::( &self.build, user, PermissionLevel::Write.into(), ) .await?; if build.config.repo.is_empty() { return Err( anyhow!("No repo configured, can't create webhook").into(), ); } let mut split = build.config.repo.split('/'); let owner = split.next().context("Build repo has no owner")?; let Some(github) = github.get(owner) else { return Err( anyhow!("Cannot manage repo webhooks under owner {owner}") .into(), ); }; let repo = split.next().context("Build repo has no repo after the /")?; let github_repos = github.repos(); // First make sure the webhook isn't already created (inactive ones are ignored) let webhooks = github_repos .list_all_webhooks(owner, repo) .await .context("failed to list all webhooks on repo")? .body; let CoreConfig { host, webhook_base_url, webhook_secret, .. } = core_config(); let webhook_secret = if build.config.webhook_secret.is_empty() { webhook_secret } else { &build.config.webhook_secret }; let host = if webhook_base_url.is_empty() { host } else { webhook_base_url }; let url = format!("{host}/listener/github/build/{}", build.id); for webhook in webhooks { if webhook.active && webhook.config.url == url { return Ok(NoData {}); } } // Now good to create the webhook let request = ReposCreateWebhookRequest { active: Some(true), config: Some(ReposCreateWebhookRequestConfig { url, secret: webhook_secret.to_string(), content_type: String::from("json"), insecure_ssl: None, digest: Default::default(), token: Default::default(), }), events: vec![String::from("push")], name: String::from("web"), }; github_repos .create_webhook(owner, repo, &request) .await .context("failed to create webhook")?; if !build.config.webhook_enabled { UpdateBuild { id: build.id, config: PartialBuildConfig { webhook_enabled: Some(true), ..Default::default() }, } .resolve(args) .await .map_err(|e| e.error) .context("failed to update build to enable webhook")?; } Ok(NoData {}) } } impl Resolve for DeleteBuildWebhook { #[instrument(name = "DeleteBuildWebhook", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let Some(github) = github_client() else { return Err( anyhow!( "github_webhook_app is not configured in core config toml" ) .into(), ); }; let build = get_check_permissions::( &self.build, user, PermissionLevel::Write.into(), ) .await?; if build.config.git_provider != "github.com" { return Err( anyhow!("Can only manage github.com repo webhooks").into(), ); } if build.config.repo.is_empty() { return Err( anyhow!("No repo configured, can't delete webhook").into(), ); } let mut split = build.config.repo.split('/'); let owner = split.next().context("Build repo has no owner")?; let Some(github) = github.get(owner) else { return Err( anyhow!("Cannot manage repo webhooks under owner {owner}") .into(), ); }; let repo = split.next().context("Build repo has no repo after the /")?; let github_repos = github.repos(); let webhooks = github_repos .list_all_webhooks(owner, repo) .await .context("failed to list all webhooks on repo")? .body; let CoreConfig { host, webhook_base_url, .. } = core_config(); let host = if webhook_base_url.is_empty() { host } else { webhook_base_url }; let url = format!("{host}/listener/github/build/{}", build.id); for webhook in webhooks { if webhook.active && webhook.config.url == url { github_repos .delete_webhook(owner, repo, webhook.id) .await .context("failed to delete webhook")?; return Ok(NoData {}); } } // No webhook to delete, all good Ok(NoData {}) } } ================================================ FILE: bin/core/src/api/write/builder.rs ================================================ use komodo_client::{ api::write::*, entities::{ builder::Builder, permission::PermissionLevel, update::Update, }, }; use resolver_api::Resolve; use crate::{permission::get_check_permissions, resource}; use super::WriteArgs; impl Resolve for CreateBuilder { #[instrument(name = "CreateBuilder", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { resource::create::(&self.name, self.config, user).await } } impl Resolve for CopyBuilder { #[instrument(name = "CopyBuilder", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let Builder { config, .. } = get_check_permissions::( &self.id, user, PermissionLevel::Write.into(), ) .await?; resource::create::(&self.name, config.into(), user).await } } impl Resolve for DeleteBuilder { #[instrument(name = "DeleteBuilder", skip(args))] async fn resolve( self, args: &WriteArgs, ) -> serror::Result { Ok(resource::delete::(&self.id, args).await?) } } impl Resolve for UpdateBuilder { #[instrument(name = "UpdateBuilder", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok( resource::update::(&self.id, self.config, user) .await?, ) } } impl Resolve for RenameBuilder { #[instrument(name = "RenameBuilder", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok(resource::rename::(&self.id, &self.name, user).await?) } } ================================================ FILE: bin/core/src/api/write/deployment.rs ================================================ use anyhow::{Context, anyhow}; use database::mungos::{by_id::update_one_by_id, mongodb::bson::doc}; use komodo_client::{ api::write::*, entities::{ Operation, deployment::{ Deployment, DeploymentImage, DeploymentState, PartialDeploymentConfig, RestartMode, }, docker::container::RestartPolicyNameEnum, komodo_timestamp, permission::PermissionLevel, server::{Server, ServerState}, to_container_compatible_name, update::Update, }, }; use periphery_client::api::{self, container::InspectContainer}; use resolver_api::Resolve; use crate::{ helpers::{ periphery_client, query::get_deployment_state, update::{add_update, make_update}, }, permission::get_check_permissions, resource, state::{action_states, db_client, server_status_cache}, }; use super::WriteArgs; impl Resolve for CreateDeployment { #[instrument(name = "CreateDeployment", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { resource::create::(&self.name, self.config, user) .await } } impl Resolve for CopyDeployment { #[instrument(name = "CopyDeployment", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let Deployment { config, .. } = get_check_permissions::( &self.id, user, PermissionLevel::Read.into(), ) .await?; resource::create::(&self.name, config.into(), user) .await } } impl Resolve for CreateDeploymentFromContainer { #[instrument(name = "CreateDeploymentFromContainer", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Read.inspect().attach(), ) .await?; let cache = server_status_cache() .get_or_insert_default(&server.id) .await; if cache.state != ServerState::Ok { return Err( anyhow!( "Cannot inspect container: server is {:?}", cache.state ) .into(), ); } let container = periphery_client(&server)? .request(InspectContainer { name: self.name.clone(), }) .await .context("Failed to inspect container")?; let mut config = PartialDeploymentConfig { server_id: server.id.into(), ..Default::default() }; if let Some(container_config) = container.config { config.image = container_config .image .map(|image| DeploymentImage::Image { image }); config.command = container_config.cmd.join(" ").into(); config.environment = container_config .env .into_iter() .map(|env| format!(" {env}")) .collect::>() .join("\n") .into(); config.labels = container_config .labels .into_iter() .map(|(key, val)| format!(" {key}: {val}")) .collect::>() .join("\n") .into(); } if let Some(host_config) = container.host_config { config.volumes = host_config .binds .into_iter() .map(|bind| format!(" {bind}")) .collect::>() .join("\n") .into(); config.network = host_config.network_mode; config.ports = host_config .port_bindings .into_iter() .filter_map(|(container, mut host)| { let host = host.pop()?.host_port?; Some(format!(" {host}:{}", container.replace("/tcp", ""))) }) .collect::>() .join("\n") .into(); config.restart = host_config.restart_policy.map(|restart| { match restart.name { RestartPolicyNameEnum::Always => RestartMode::Always, RestartPolicyNameEnum::No | RestartPolicyNameEnum::Empty => RestartMode::NoRestart, RestartPolicyNameEnum::UnlessStopped => { RestartMode::UnlessStopped } RestartPolicyNameEnum::OnFailure => RestartMode::OnFailure, } }); } resource::create::(&self.name, config, user).await } } impl Resolve for DeleteDeployment { #[instrument(name = "DeleteDeployment", skip(args))] async fn resolve( self, args: &WriteArgs, ) -> serror::Result { Ok(resource::delete::(&self.id, args).await?) } } impl Resolve for UpdateDeployment { #[instrument(name = "UpdateDeployment", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok( resource::update::(&self.id, self.config, user) .await?, ) } } impl Resolve for RenameDeployment { #[instrument(name = "RenameDeployment", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let deployment = get_check_permissions::( &self.id, user, PermissionLevel::Write.into(), ) .await?; // get the action state for the deployment (or insert default). let action_state = action_states() .deployment .get_or_insert_default(&deployment.id) .await; // Will check to ensure deployment not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.renaming = true)?; let name = to_container_compatible_name(&self.name); let container_state = get_deployment_state(&deployment.id).await?; if container_state == DeploymentState::Unknown { return Err( anyhow!( "Cannot rename Deployment when container status is unknown" ) .into(), ); } let mut update = make_update(&deployment, Operation::RenameDeployment, user); update_one_by_id( &db_client().deployments, &deployment.id, database::mungos::update::Update::Set( doc! { "name": &name, "updated_at": komodo_timestamp() }, ), None, ) .await .context("Failed to update Deployment name on db")?; if container_state != DeploymentState::NotDeployed { let server = resource::get::(&deployment.config.server_id).await?; let log = periphery_client(&server)? .request(api::container::RenameContainer { curr_name: deployment.name.clone(), new_name: name.clone(), }) .await .context("Failed to rename container on server")?; update.logs.push(log); } update.push_simple_log( "Rename Deployment", format!( "Renamed Deployment from {} to {}", deployment.name, name ), ); update.finalize(); update.id = add_update(update.clone()).await?; Ok(update) } } ================================================ FILE: bin/core/src/api/write/mod.rs ================================================ use std::time::Instant; use anyhow::Context; use axum::{ Extension, Router, extract::Path, middleware, routing::post, }; use derive_variants::{EnumVariants, ExtractVariant}; use komodo_client::{api::write::*, entities::user::User}; use resolver_api::Resolve; use response::Response; use serde::{Deserialize, Serialize}; use serde_json::json; use serror::Json; use typeshare::typeshare; use uuid::Uuid; use crate::auth::auth_request; use super::Variant; mod action; mod alerter; mod build; mod builder; mod deployment; mod permissions; mod procedure; mod provider; mod repo; mod resource; mod server; mod service_user; mod stack; mod sync; mod tag; mod user; mod user_group; mod variable; pub struct WriteArgs { pub user: User, } #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EnumVariants, )] #[variant_derive(Debug)] #[args(WriteArgs)] #[response(Response)] #[error(serror::Error)] #[serde(tag = "type", content = "params")] pub enum WriteRequest { // ==== USER ==== CreateLocalUser(CreateLocalUser), UpdateUserUsername(UpdateUserUsername), UpdateUserPassword(UpdateUserPassword), DeleteUser(DeleteUser), // ==== SERVICE USER ==== CreateServiceUser(CreateServiceUser), UpdateServiceUserDescription(UpdateServiceUserDescription), CreateApiKeyForServiceUser(CreateApiKeyForServiceUser), DeleteApiKeyForServiceUser(DeleteApiKeyForServiceUser), // ==== USER GROUP ==== CreateUserGroup(CreateUserGroup), RenameUserGroup(RenameUserGroup), DeleteUserGroup(DeleteUserGroup), AddUserToUserGroup(AddUserToUserGroup), RemoveUserFromUserGroup(RemoveUserFromUserGroup), SetUsersInUserGroup(SetUsersInUserGroup), SetEveryoneUserGroup(SetEveryoneUserGroup), // ==== PERMISSIONS ==== UpdateUserAdmin(UpdateUserAdmin), UpdateUserBasePermissions(UpdateUserBasePermissions), UpdatePermissionOnResourceType(UpdatePermissionOnResourceType), UpdatePermissionOnTarget(UpdatePermissionOnTarget), // ==== RESOURCE ==== UpdateResourceMeta(UpdateResourceMeta), // ==== SERVER ==== CreateServer(CreateServer), CopyServer(CopyServer), DeleteServer(DeleteServer), UpdateServer(UpdateServer), RenameServer(RenameServer), CreateNetwork(CreateNetwork), CreateTerminal(CreateTerminal), DeleteTerminal(DeleteTerminal), DeleteAllTerminals(DeleteAllTerminals), // ==== STACK ==== CreateStack(CreateStack), CopyStack(CopyStack), DeleteStack(DeleteStack), UpdateStack(UpdateStack), RenameStack(RenameStack), WriteStackFileContents(WriteStackFileContents), RefreshStackCache(RefreshStackCache), CreateStackWebhook(CreateStackWebhook), DeleteStackWebhook(DeleteStackWebhook), // ==== DEPLOYMENT ==== CreateDeployment(CreateDeployment), CopyDeployment(CopyDeployment), CreateDeploymentFromContainer(CreateDeploymentFromContainer), DeleteDeployment(DeleteDeployment), UpdateDeployment(UpdateDeployment), RenameDeployment(RenameDeployment), // ==== BUILD ==== CreateBuild(CreateBuild), CopyBuild(CopyBuild), DeleteBuild(DeleteBuild), UpdateBuild(UpdateBuild), RenameBuild(RenameBuild), WriteBuildFileContents(WriteBuildFileContents), RefreshBuildCache(RefreshBuildCache), CreateBuildWebhook(CreateBuildWebhook), DeleteBuildWebhook(DeleteBuildWebhook), // ==== BUILDER ==== CreateBuilder(CreateBuilder), CopyBuilder(CopyBuilder), DeleteBuilder(DeleteBuilder), UpdateBuilder(UpdateBuilder), RenameBuilder(RenameBuilder), // ==== REPO ==== CreateRepo(CreateRepo), CopyRepo(CopyRepo), DeleteRepo(DeleteRepo), UpdateRepo(UpdateRepo), RenameRepo(RenameRepo), RefreshRepoCache(RefreshRepoCache), CreateRepoWebhook(CreateRepoWebhook), DeleteRepoWebhook(DeleteRepoWebhook), // ==== ALERTER ==== CreateAlerter(CreateAlerter), CopyAlerter(CopyAlerter), DeleteAlerter(DeleteAlerter), UpdateAlerter(UpdateAlerter), RenameAlerter(RenameAlerter), // ==== PROCEDURE ==== CreateProcedure(CreateProcedure), CopyProcedure(CopyProcedure), DeleteProcedure(DeleteProcedure), UpdateProcedure(UpdateProcedure), RenameProcedure(RenameProcedure), // ==== ACTION ==== CreateAction(CreateAction), CopyAction(CopyAction), DeleteAction(DeleteAction), UpdateAction(UpdateAction), RenameAction(RenameAction), // ==== SYNC ==== CreateResourceSync(CreateResourceSync), CopyResourceSync(CopyResourceSync), DeleteResourceSync(DeleteResourceSync), UpdateResourceSync(UpdateResourceSync), RenameResourceSync(RenameResourceSync), WriteSyncFileContents(WriteSyncFileContents), CommitSync(CommitSync), RefreshResourceSyncPending(RefreshResourceSyncPending), CreateSyncWebhook(CreateSyncWebhook), DeleteSyncWebhook(DeleteSyncWebhook), // ==== TAG ==== CreateTag(CreateTag), DeleteTag(DeleteTag), RenameTag(RenameTag), UpdateTagColor(UpdateTagColor), // ==== VARIABLE ==== CreateVariable(CreateVariable), UpdateVariableValue(UpdateVariableValue), UpdateVariableDescription(UpdateVariableDescription), UpdateVariableIsSecret(UpdateVariableIsSecret), DeleteVariable(DeleteVariable), // ==== PROVIDERS ==== CreateGitProviderAccount(CreateGitProviderAccount), UpdateGitProviderAccount(UpdateGitProviderAccount), DeleteGitProviderAccount(DeleteGitProviderAccount), CreateDockerRegistryAccount(CreateDockerRegistryAccount), UpdateDockerRegistryAccount(UpdateDockerRegistryAccount), DeleteDockerRegistryAccount(DeleteDockerRegistryAccount), } pub fn router() -> Router { Router::new() .route("/", post(handler)) .route("/{variant}", post(variant_handler)) .layer(middleware::from_fn(auth_request)) } async fn variant_handler( user: Extension, Path(Variant { variant }): Path, Json(params): Json, ) -> serror::Result { let req: WriteRequest = serde_json::from_value(json!({ "type": variant, "params": params, }))?; handler(user, Json(req)).await } async fn handler( Extension(user): Extension, Json(request): Json, ) -> serror::Result { let req_id = Uuid::new_v4(); let res = tokio::spawn(task(req_id, request, user)) .await .context("failure in spawned task"); res? } #[instrument( name = "WriteRequest", skip(user, request), fields( user_id = user.id, request = format!("{:?}", request.extract_variant()) ) )] async fn task( req_id: Uuid, request: WriteRequest, user: User, ) -> serror::Result { info!("/write request | user: {}", user.username); let timer = Instant::now(); let res = request.resolve(&WriteArgs { user }).await; if let Err(e) = &res { warn!("/write request {req_id} error: {:#}", e.error); } let elapsed = timer.elapsed(); debug!("/write request {req_id} | resolve time: {elapsed:?}"); res.map(|res| res.0) } ================================================ FILE: bin/core/src/api/write/permissions.rs ================================================ use std::str::FromStr; use anyhow::{Context, anyhow}; use database::mungos::{ by_id::{find_one_by_id, update_one_by_id}, mongodb::{ bson::{Document, doc, oid::ObjectId, to_bson}, options::UpdateOptions, }, }; use komodo_client::{ api::write::*, entities::{ ResourceTarget, ResourceTargetVariant, permission::{UserTarget, UserTargetVariant}, }, }; use resolver_api::Resolve; use crate::{helpers::query::get_user, state::db_client}; use super::WriteArgs; impl Resolve for UpdateUserAdmin { #[instrument(name = "UpdateUserAdmin", skip(super_admin))] async fn resolve( self, WriteArgs { user: super_admin }: &WriteArgs, ) -> serror::Result { if !super_admin.super_admin { return Err( anyhow!("Only super admins can call this method.").into(), ); } let user = find_one_by_id(&db_client().users, &self.user_id) .await .context("failed to query mongo for user")? .context("did not find user with given id")?; if !user.enabled { return Err( anyhow!("User is disabled. Enable user first.").into(), ); } if user.super_admin { return Err(anyhow!("Cannot update other super admins").into()); } update_one_by_id( &db_client().users, &self.user_id, doc! { "$set": { "admin": self.admin } }, None, ) .await?; Ok(UpdateUserAdminResponse {}) } } impl Resolve for UpdateUserBasePermissions { #[instrument(name = "UpdateUserBasePermissions", skip(admin))] async fn resolve( self, WriteArgs { user: admin }: &WriteArgs, ) -> serror::Result { if !admin.admin { return Err(anyhow!("this method is admin only").into()); } let UpdateUserBasePermissions { user_id, enabled, create_servers, create_builds, } = self; let user = find_one_by_id(&db_client().users, &user_id) .await .context("failed to query mongo for user")? .context("did not find user with given id")?; if user.super_admin { return Err( anyhow!( "Cannot use this method to update super admins permissions" ) .into(), ); } if user.admin && !admin.super_admin { return Err(anyhow!( "Only super admins can use this method to update other admins permissions" ).into()); } let mut update_doc = Document::new(); if let Some(enabled) = enabled { update_doc.insert("enabled", enabled); } if let Some(create_servers) = create_servers { update_doc.insert("create_server_permissions", create_servers); } if let Some(create_builds) = create_builds { update_doc.insert("create_build_permissions", create_builds); } update_one_by_id( &db_client().users, &user_id, database::mungos::update::Update::Set(update_doc), None, ) .await?; Ok(UpdateUserBasePermissionsResponse {}) } } impl Resolve for UpdatePermissionOnResourceType { #[instrument(name = "UpdatePermissionOnResourceType", skip(admin))] async fn resolve( self, WriteArgs { user: admin }: &WriteArgs, ) -> serror::Result { if !admin.admin { return Err(anyhow!("this method is admin only").into()); } let Self { user_target, resource_type, permission, } = self; // Some extra checks if user target is an actual User if let UserTarget::User(user_id) = &user_target { let user = get_user(user_id).await?; if user.admin { return Err( anyhow!( "cannot use this method to update other admins permissions" ) .into(), ); } if !user.enabled { return Err(anyhow!("user not enabled").into()); } } let (user_target_variant, user_target_id) = extract_user_target_with_validation(&user_target).await?; let id = ObjectId::from_str(&user_target_id) .context("id is not ObjectId")?; let filter = doc! { "_id": id }; let field = format!("all.{resource_type}"); let set = to_bson(&permission).context("permission is not Bson")?; let update = doc! { "$set": { &field: &set } }; match user_target_variant { UserTargetVariant::User => { db_client() .users .update_one(filter, update) .await .with_context(|| { format!("failed to set {field}: {set} on db") })?; } UserTargetVariant::UserGroup => { db_client() .user_groups .update_one(filter, update) .await .with_context(|| { format!("failed to set {field}: {set} on db") })?; } } Ok(UpdatePermissionOnResourceTypeResponse {}) } } impl Resolve for UpdatePermissionOnTarget { #[instrument(name = "UpdatePermissionOnTarget", skip(admin))] async fn resolve( self, WriteArgs { user: admin }: &WriteArgs, ) -> serror::Result { if !admin.admin { return Err(anyhow!("this method is admin only").into()); } let UpdatePermissionOnTarget { user_target, resource_target, permission, } = self; // Some extra checks relevant if user target is an actual User if let UserTarget::User(user_id) = &user_target { let user = get_user(user_id).await?; if !user.enabled { return Err(anyhow!("user not enabled").into()); } if user.admin { return Err( anyhow!( "cannot use this method to update other admins permissions" ) .into(), ); } } let (user_target_variant, user_target_id) = extract_user_target_with_validation(&user_target).await?; let (resource_variant, resource_id) = extract_resource_target_with_validation(&resource_target) .await?; let (user_target_variant, resource_variant) = (user_target_variant.as_ref(), resource_variant.as_ref()); let specific = to_bson(&permission.specific) .context("permission.specific is not valid Bson")?; db_client() .permissions .update_one( doc! { "user_target.type": user_target_variant, "user_target.id": &user_target_id, "resource_target.type": resource_variant, "resource_target.id": &resource_id }, doc! { "$set": { "user_target.type": user_target_variant, "user_target.id": user_target_id, "resource_target.type": resource_variant, "resource_target.id": resource_id, "level": permission.level.as_ref(), "specific": specific } }, ) .with_options(UpdateOptions::builder().upsert(true).build()) .await?; Ok(UpdatePermissionOnTargetResponse {}) } } /// checks if inner id is actually a `name`, and replaces it with id if so. async fn extract_user_target_with_validation( user_target: &UserTarget, ) -> serror::Result<(UserTargetVariant, String)> { match user_target { UserTarget::User(ident) => { let filter = match ObjectId::from_str(ident) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "username": ident }, }; let id = db_client() .users .find_one(filter) .await .context("failed to query db for users")? .context("no matching user found")? .id; Ok((UserTargetVariant::User, id)) } UserTarget::UserGroup(ident) => { let filter = match ObjectId::from_str(ident) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": ident }, }; let id = db_client() .user_groups .find_one(filter) .await .context("failed to query db for user_groups")? .context("no matching user_group found")? .id; Ok((UserTargetVariant::UserGroup, id)) } } } /// checks if inner id is actually a `name`, and replaces it with id if so. async fn extract_resource_target_with_validation( resource_target: &ResourceTarget, ) -> serror::Result<(ResourceTargetVariant, String)> { match resource_target { ResourceTarget::System(_) => { let res = resource_target.extract_variant_id(); Ok((res.0, res.1.clone())) } ResourceTarget::Build(ident) => { let filter = match ObjectId::from_str(ident) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": ident }, }; let id = db_client() .builds .find_one(filter) .await .context("failed to query db for builds")? .context("no matching build found")? .id; Ok((ResourceTargetVariant::Build, id)) } ResourceTarget::Builder(ident) => { let filter = match ObjectId::from_str(ident) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": ident }, }; let id = db_client() .builders .find_one(filter) .await .context("failed to query db for builders")? .context("no matching builder found")? .id; Ok((ResourceTargetVariant::Builder, id)) } ResourceTarget::Deployment(ident) => { let filter = match ObjectId::from_str(ident) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": ident }, }; let id = db_client() .deployments .find_one(filter) .await .context("failed to query db for deployments")? .context("no matching deployment found")? .id; Ok((ResourceTargetVariant::Deployment, id)) } ResourceTarget::Server(ident) => { let filter = match ObjectId::from_str(ident) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": ident }, }; let id = db_client() .servers .find_one(filter) .await .context("failed to query db for servers")? .context("no matching server found")? .id; Ok((ResourceTargetVariant::Server, id)) } ResourceTarget::Repo(ident) => { let filter = match ObjectId::from_str(ident) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": ident }, }; let id = db_client() .repos .find_one(filter) .await .context("failed to query db for repos")? .context("no matching repo found")? .id; Ok((ResourceTargetVariant::Repo, id)) } ResourceTarget::Alerter(ident) => { let filter = match ObjectId::from_str(ident) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": ident }, }; let id = db_client() .alerters .find_one(filter) .await .context("failed to query db for alerters")? .context("no matching alerter found")? .id; Ok((ResourceTargetVariant::Alerter, id)) } ResourceTarget::Procedure(ident) => { let filter = match ObjectId::from_str(ident) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": ident }, }; let id = db_client() .procedures .find_one(filter) .await .context("failed to query db for procedures")? .context("no matching procedure found")? .id; Ok((ResourceTargetVariant::Procedure, id)) } ResourceTarget::Action(ident) => { let filter = match ObjectId::from_str(ident) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": ident }, }; let id = db_client() .actions .find_one(filter) .await .context("failed to query db for actions")? .context("no matching action found")? .id; Ok((ResourceTargetVariant::Action, id)) } ResourceTarget::ResourceSync(ident) => { let filter = match ObjectId::from_str(ident) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": ident }, }; let id = db_client() .resource_syncs .find_one(filter) .await .context("failed to query db for resource syncs")? .context("no matching resource sync found")? .id; Ok((ResourceTargetVariant::ResourceSync, id)) } ResourceTarget::Stack(ident) => { let filter = match ObjectId::from_str(ident) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": ident }, }; let id = db_client() .stacks .find_one(filter) .await .context("failed to query db for stacks")? .context("no matching stack found")? .id; Ok((ResourceTargetVariant::Stack, id)) } } } ================================================ FILE: bin/core/src/api/write/procedure.rs ================================================ use komodo_client::{ api::write::*, entities::{ permission::PermissionLevel, procedure::Procedure, update::Update, }, }; use resolver_api::Resolve; use crate::{permission::get_check_permissions, resource}; use super::WriteArgs; impl Resolve for CreateProcedure { #[instrument(name = "CreateProcedure", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { resource::create::(&self.name, self.config, user).await } } impl Resolve for CopyProcedure { #[instrument(name = "CopyProcedure", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let Procedure { config, .. } = get_check_permissions::( &self.id, user, PermissionLevel::Write.into(), ) .await?; resource::create::(&self.name, config.into(), user) .await } } impl Resolve for UpdateProcedure { #[instrument(name = "UpdateProcedure", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok( resource::update::(&self.id, self.config, user) .await?, ) } } impl Resolve for RenameProcedure { #[instrument(name = "RenameProcedure", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok( resource::rename::(&self.id, &self.name, user) .await?, ) } } impl Resolve for DeleteProcedure { #[instrument(name = "DeleteProcedure", skip(args))] async fn resolve( self, args: &WriteArgs, ) -> serror::Result { Ok(resource::delete::(&self.id, args).await?) } } ================================================ FILE: bin/core/src/api/write/provider.rs ================================================ use anyhow::{Context, anyhow}; use database::mungos::{ by_id::{delete_one_by_id, find_one_by_id, update_one_by_id}, mongodb::bson::{doc, to_document}, }; use komodo_client::{ api::write::*, entities::{ Operation, ResourceTarget, provider::{DockerRegistryAccount, GitProviderAccount}, }, }; use resolver_api::Resolve; use crate::{ helpers::update::{add_update, make_update}, state::db_client, }; use super::WriteArgs; impl Resolve for CreateGitProviderAccount { async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { if !user.admin { return Err( anyhow!("only admins can create git provider accounts") .into(), ); } let mut account: GitProviderAccount = self.account.into(); if account.domain.is_empty() { return Err(anyhow!("domain cannot be empty string.").into()); } if account.username.is_empty() { return Err(anyhow!("username cannot be empty string.").into()); } let mut update = make_update( ResourceTarget::system(), Operation::CreateGitProviderAccount, user, ); account.id = db_client() .git_accounts .insert_one(&account) .await .context("failed to create git provider account on db")? .inserted_id .as_object_id() .context("inserted id is not ObjectId")? .to_string(); update.push_simple_log( "create git provider account", format!( "Created git provider account for {} with username {}", account.domain, account.username ), ); update.finalize(); add_update(update) .await .inspect_err(|e| { error!("failed to add update for create git provider account | {e:#}") }) .ok(); Ok(account) } } impl Resolve for UpdateGitProviderAccount { async fn resolve( mut self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { if !user.admin { return Err( anyhow!("only admins can update git provider accounts") .into(), ); } if let Some(domain) = &self.account.domain && domain.is_empty() { return Err( anyhow!("cannot update git provider with empty domain") .into(), ); } if let Some(username) = &self.account.username && username.is_empty() { return Err( anyhow!("cannot update git provider with empty username") .into(), ); } // Ensure update does not change id self.account.id = None; let mut update = make_update( ResourceTarget::system(), Operation::UpdateGitProviderAccount, user, ); let account = to_document(&self.account).context( "failed to serialize partial git provider account to bson", )?; let db = db_client(); update_one_by_id( &db.git_accounts, &self.id, doc! { "$set": account }, None, ) .await .context("failed to update git provider account on db")?; let Some(account) = find_one_by_id(&db.git_accounts, &self.id) .await .context("failed to query db for git accounts")? else { return Err(anyhow!("no account found with given id").into()); }; update.push_simple_log( "update git provider account", format!( "Updated git provider account for {} with username {}", account.domain, account.username ), ); update.finalize(); add_update(update) .await .inspect_err(|e| { error!("failed to add update for update git provider account | {e:#}") }) .ok(); Ok(account) } } impl Resolve for DeleteGitProviderAccount { async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { if !user.admin { return Err( anyhow!("only admins can delete git provider accounts") .into(), ); } let mut update = make_update( ResourceTarget::system(), Operation::UpdateGitProviderAccount, user, ); let db = db_client(); let Some(account) = find_one_by_id(&db.git_accounts, &self.id) .await .context("failed to query db for git accounts")? else { return Err(anyhow!("no account found with given id").into()); }; delete_one_by_id(&db.git_accounts, &self.id, None) .await .context("failed to delete git account on db")?; update.push_simple_log( "delete git provider account", format!( "Deleted git provider account for {} with username {}", account.domain, account.username ), ); update.finalize(); add_update(update) .await .inspect_err(|e| { error!("failed to add update for delete git provider account | {e:#}") }) .ok(); Ok(account) } } impl Resolve for CreateDockerRegistryAccount { async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { if !user.admin { return Err( anyhow!( "only admins can create docker registry account accounts" ) .into(), ); } let mut account: DockerRegistryAccount = self.account.into(); if account.domain.is_empty() { return Err(anyhow!("domain cannot be empty string.").into()); } if account.username.is_empty() { return Err(anyhow!("username cannot be empty string.").into()); } let mut update = make_update( ResourceTarget::system(), Operation::CreateDockerRegistryAccount, user, ); account.id = db_client() .registry_accounts .insert_one(&account) .await .context( "failed to create docker registry account account on db", )? .inserted_id .as_object_id() .context("inserted id is not ObjectId")? .to_string(); update.push_simple_log( "create docker registry account", format!( "Created docker registry account account for {} with username {}", account.domain, account.username ), ); update.finalize(); add_update(update) .await .inspect_err(|e| { error!("failed to add update for create docker registry account | {e:#}") }) .ok(); Ok(account) } } impl Resolve for UpdateDockerRegistryAccount { async fn resolve( mut self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { if !user.admin { return Err( anyhow!("only admins can update docker registry accounts") .into(), ); } if let Some(domain) = &self.account.domain && domain.is_empty() { return Err( anyhow!( "cannot update docker registry account with empty domain" ) .into(), ); } if let Some(username) = &self.account.username && username.is_empty() { return Err( anyhow!( "cannot update docker registry account with empty username" ) .into(), ); } self.account.id = None; let mut update = make_update( ResourceTarget::system(), Operation::UpdateDockerRegistryAccount, user, ); let account = to_document(&self.account).context( "failed to serialize partial docker registry account account to bson", )?; let db = db_client(); update_one_by_id( &db.registry_accounts, &self.id, doc! { "$set": account }, None, ) .await .context( "failed to update docker registry account account on db", )?; let Some(account) = find_one_by_id(&db.registry_accounts, &self.id) .await .context("failed to query db for registry accounts")? else { return Err(anyhow!("no account found with given id").into()); }; update.push_simple_log( "update docker registry account", format!( "Updated docker registry account account for {} with username {}", account.domain, account.username ), ); update.finalize(); add_update(update) .await .inspect_err(|e| { error!("failed to add update for update docker registry account | {e:#}") }) .ok(); Ok(account) } } impl Resolve for DeleteDockerRegistryAccount { async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { if !user.admin { return Err( anyhow!("only admins can delete docker registry accounts") .into(), ); } let mut update = make_update( ResourceTarget::system(), Operation::UpdateDockerRegistryAccount, user, ); let db = db_client(); let Some(account) = find_one_by_id(&db.registry_accounts, &self.id) .await .context("failed to query db for git accounts")? else { return Err(anyhow!("no account found with given id").into()); }; delete_one_by_id(&db.registry_accounts, &self.id, None) .await .context("failed to delete registry account on db")?; update.push_simple_log( "delete registry account", format!( "Deleted registry account for {} with username {}", account.domain, account.username ), ); update.finalize(); add_update(update) .await .inspect_err(|e| { error!("failed to add update for delete docker registry account | {e:#}") }) .ok(); Ok(account) } } ================================================ FILE: bin/core/src/api/write/repo.rs ================================================ use anyhow::{Context, anyhow}; use database::mongo_indexed::doc; use database::mungos::{ by_id::update_one_by_id, mongodb::bson::to_document, }; use formatting::format_serror; use komodo_client::{ api::write::*, entities::{ NoData, Operation, RepoExecutionArgs, config::core::CoreConfig, komodo_timestamp, permission::PermissionLevel, repo::{PartialRepoConfig, Repo, RepoInfo}, server::Server, to_path_compatible_name, update::{Log, Update}, }, }; use octorust::types::{ ReposCreateWebhookRequest, ReposCreateWebhookRequestConfig, }; use periphery_client::api; use resolver_api::Resolve; use crate::{ config::core_config, helpers::{ git_token, periphery_client, update::{add_update, make_update}, }, permission::get_check_permissions, resource, state::{action_states, db_client, github_client}, }; use super::WriteArgs; impl Resolve for CreateRepo { #[instrument(name = "CreateRepo", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { resource::create::(&self.name, self.config, user).await } } impl Resolve for CopyRepo { #[instrument(name = "CopyRepo", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let Repo { config, .. } = get_check_permissions::( &self.id, user, PermissionLevel::Read.into(), ) .await?; resource::create::(&self.name, config.into(), user).await } } impl Resolve for DeleteRepo { #[instrument(name = "DeleteRepo", skip(args))] async fn resolve(self, args: &WriteArgs) -> serror::Result { Ok(resource::delete::(&self.id, args).await?) } } impl Resolve for UpdateRepo { #[instrument(name = "UpdateRepo", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok(resource::update::(&self.id, self.config, user).await?) } } impl Resolve for RenameRepo { #[instrument(name = "RenameRepo", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let repo = get_check_permissions::( &self.id, user, PermissionLevel::Write.into(), ) .await?; if repo.config.server_id.is_empty() || !repo.config.path.is_empty() { return Ok( resource::rename::(&repo.id, &self.name, user).await?, ); } // get the action state for the repo (or insert default). let action_state = action_states().repo.get_or_insert_default(&repo.id).await; // Will check to ensure repo not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(|state| state.renaming = true)?; let name = to_path_compatible_name(&self.name); let mut update = make_update(&repo, Operation::RenameRepo, user); update_one_by_id( &db_client().repos, &repo.id, database::mungos::update::Update::Set( doc! { "name": &name, "updated_at": komodo_timestamp() }, ), None, ) .await .context("Failed to update Repo name on db")?; let server = resource::get::(&repo.config.server_id).await?; let log = match periphery_client(&server)? .request(api::git::RenameRepo { curr_name: to_path_compatible_name(&repo.name), new_name: name.clone(), }) .await .context("Failed to rename Repo directory on Server") { Ok(log) => log, Err(e) => Log::error( "Rename Repo directory failure", format_serror(&e.into()), ), }; update.logs.push(log); update.push_simple_log( "Rename Repo", format!("Renamed Repo from {} to {}", repo.name, name), ); update.finalize(); update.id = add_update(update.clone()).await?; Ok(update) } } impl Resolve for RefreshRepoCache { #[instrument( name = "RefreshRepoCache", level = "debug", skip(user) )] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { // Even though this is a write request, this doesn't change any config. Anyone that can execute the // repo should be able to do this. let repo = get_check_permissions::( &self.repo, user, PermissionLevel::Execute.into(), ) .await?; if repo.config.git_provider.is_empty() || repo.config.repo.is_empty() { // Nothing to do return Ok(NoData {}); } let mut clone_args: RepoExecutionArgs = (&repo).into(); let repo_path = clone_args.unique_path(&core_config().repo_directory)?; clone_args.destination = Some(repo_path.display().to_string()); let access_token = if let Some(username) = &clone_args.account { git_token(&clone_args.provider, username, |https| { clone_args.https = https }) .await .with_context( || format!("Failed to get git token in call to db. Stopping run. | {} | {username}", clone_args.provider), )? } else { None }; let (res, _) = git::pull_or_clone( clone_args, &core_config().repo_directory, access_token, ) .await .with_context(|| { format!("Failed to update repo at {repo_path:?}") })?; let info = RepoInfo { last_pulled_at: repo.info.last_pulled_at, last_built_at: repo.info.last_built_at, built_hash: repo.info.built_hash, built_message: repo.info.built_message, latest_hash: res.commit_hash, latest_message: res.commit_message, }; let info = to_document(&info) .context("failed to serialize repo info to bson")?; db_client() .repos .update_one( doc! { "name": &repo.name }, doc! { "$set": { "info": info } }, ) .await .context("failed to update repo info on db")?; Ok(NoData {}) } } impl Resolve for CreateRepoWebhook { #[instrument(name = "CreateRepoWebhook", skip(args))] async fn resolve( self, args: &WriteArgs, ) -> serror::Result { let Some(github) = github_client() else { return Err( anyhow!( "github_webhook_app is not configured in core config toml" ) .into(), ); }; let repo = get_check_permissions::( &self.repo, &args.user, PermissionLevel::Write.into(), ) .await?; if repo.config.repo.is_empty() { return Err( anyhow!("No repo configured, can't create webhook").into(), ); } let mut split = repo.config.repo.split('/'); let owner = split.next().context("Repo repo has no owner")?; let Some(github) = github.get(owner) else { return Err( anyhow!("Cannot manage repo webhooks under owner {owner}") .into(), ); }; let repo_name = split.next().context("Repo repo has no repo after the /")?; let github_repos = github.repos(); // First make sure the webhook isn't already created (inactive ones are ignored) let webhooks = github_repos .list_all_webhooks(owner, repo_name) .await .context("failed to list all webhooks on repo")? .body; let CoreConfig { host, webhook_base_url, webhook_secret, .. } = core_config(); let webhook_secret = if repo.config.webhook_secret.is_empty() { webhook_secret } else { &repo.config.webhook_secret }; let host = if webhook_base_url.is_empty() { host } else { webhook_base_url }; let url = match self.action { RepoWebhookAction::Clone => { format!("{host}/listener/github/repo/{}/clone", repo.id) } RepoWebhookAction::Pull => { format!("{host}/listener/github/repo/{}/pull", repo.id) } RepoWebhookAction::Build => { format!("{host}/listener/github/repo/{}/build", repo.id) } }; for webhook in webhooks { if webhook.active && webhook.config.url == url { return Ok(NoData {}); } } // Now good to create the webhook let request = ReposCreateWebhookRequest { active: Some(true), config: Some(ReposCreateWebhookRequestConfig { url, secret: webhook_secret.to_string(), content_type: String::from("json"), insecure_ssl: None, digest: Default::default(), token: Default::default(), }), events: vec![String::from("push")], name: String::from("web"), }; github_repos .create_webhook(owner, repo_name, &request) .await .context("failed to create webhook")?; if !repo.config.webhook_enabled { UpdateRepo { id: repo.id, config: PartialRepoConfig { webhook_enabled: Some(true), ..Default::default() }, } .resolve(args) .await .map_err(|e| e.error) .context("failed to update repo to enable webhook")?; } Ok(NoData {}) } } impl Resolve for DeleteRepoWebhook { #[instrument(name = "DeleteRepoWebhook", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let Some(github) = github_client() else { return Err( anyhow!( "github_webhook_app is not configured in core config toml" ) .into(), ); }; let repo = get_check_permissions::( &self.repo, user, PermissionLevel::Write.into(), ) .await?; if repo.config.git_provider != "github.com" { return Err( anyhow!("Can only manage github.com repo webhooks").into(), ); } if repo.config.repo.is_empty() { return Err( anyhow!("No repo configured, can't create webhook").into(), ); } let mut split = repo.config.repo.split('/'); let owner = split.next().context("Repo repo has no owner")?; let Some(github) = github.get(owner) else { return Err( anyhow!("Cannot manage repo webhooks under owner {owner}") .into(), ); }; let repo_name = split.next().context("Repo repo has no repo after the /")?; let github_repos = github.repos(); // First make sure the webhook isn't already created (inactive ones are ignored) let webhooks = github_repos .list_all_webhooks(owner, repo_name) .await .context("failed to list all webhooks on repo")? .body; let CoreConfig { host, webhook_base_url, .. } = core_config(); let host = if webhook_base_url.is_empty() { host } else { webhook_base_url }; let url = match self.action { RepoWebhookAction::Clone => { format!("{host}/listener/github/repo/{}/clone", repo.id) } RepoWebhookAction::Pull => { format!("{host}/listener/github/repo/{}/pull", repo.id) } RepoWebhookAction::Build => { format!("{host}/listener/github/repo/{}/build", repo.id) } }; for webhook in webhooks { if webhook.active && webhook.config.url == url { github_repos .delete_webhook(owner, repo_name, webhook.id) .await .context("failed to delete webhook")?; return Ok(NoData {}); } } // No webhook to delete, all good Ok(NoData {}) } } ================================================ FILE: bin/core/src/api/write/resource.rs ================================================ use anyhow::anyhow; use komodo_client::{ api::write::{UpdateResourceMeta, UpdateResourceMetaResponse}, entities::{ ResourceTarget, action::Action, alerter::Alerter, build::Build, builder::Builder, deployment::Deployment, procedure::Procedure, repo::Repo, server::Server, stack::Stack, sync::ResourceSync, }, }; use resolver_api::Resolve; use crate::resource::{self, ResourceMetaUpdate}; use super::WriteArgs; impl Resolve for UpdateResourceMeta { #[instrument(name = "UpdateResourceMeta", skip(args))] async fn resolve( self, args: &WriteArgs, ) -> serror::Result { let meta = ResourceMetaUpdate { description: self.description, template: self.template, tags: self.tags, }; match self.target { ResourceTarget::System(_) => { return Err( anyhow!("cannot update meta of System resource target") .into(), ); } ResourceTarget::Server(id) => { resource::update_meta::(&id, meta, args).await?; } ResourceTarget::Deployment(id) => { resource::update_meta::(&id, meta, args).await?; } ResourceTarget::Build(id) => { resource::update_meta::(&id, meta, args).await?; } ResourceTarget::Repo(id) => { resource::update_meta::(&id, meta, args).await?; } ResourceTarget::Builder(id) => { resource::update_meta::(&id, meta, args).await?; } ResourceTarget::Alerter(id) => { resource::update_meta::(&id, meta, args).await?; } ResourceTarget::Procedure(id) => { resource::update_meta::(&id, meta, args).await?; } ResourceTarget::Action(id) => { resource::update_meta::(&id, meta, args).await?; } ResourceTarget::ResourceSync(id) => { resource::update_meta::(&id, meta, args) .await?; } ResourceTarget::Stack(id) => { resource::update_meta::(&id, meta, args).await?; } } Ok(UpdateResourceMetaResponse {}) } } ================================================ FILE: bin/core/src/api/write/server.rs ================================================ use anyhow::Context; use formatting::format_serror; use komodo_client::{ api::write::*, entities::{ NoData, Operation, permission::PermissionLevel, server::Server, to_docker_compatible_name, update::{Update, UpdateStatus}, }, }; use periphery_client::api; use resolver_api::Resolve; use crate::{ helpers::{ periphery_client, update::{add_update, make_update, update_update}, }, permission::get_check_permissions, resource, }; use super::WriteArgs; impl Resolve for CreateServer { #[instrument(name = "CreateServer", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { resource::create::(&self.name, self.config, user).await } } impl Resolve for CopyServer { #[instrument(name = "CopyServer", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let Server { config, .. } = get_check_permissions::( &self.id, user, PermissionLevel::Read.into(), ) .await?; resource::create::(&self.name, config.into(), user).await } } impl Resolve for DeleteServer { #[instrument(name = "DeleteServer", skip(args))] async fn resolve(self, args: &WriteArgs) -> serror::Result { Ok(resource::delete::(&self.id, args).await?) } } impl Resolve for UpdateServer { #[instrument(name = "UpdateServer", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok(resource::update::(&self.id, self.config, user).await?) } } impl Resolve for RenameServer { #[instrument(name = "RenameServer", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok(resource::rename::(&self.id, &self.name, user).await?) } } impl Resolve for CreateNetwork { #[instrument(name = "CreateNetwork", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Write.into(), ) .await?; let periphery = periphery_client(&server)?; let mut update = make_update(&server, Operation::CreateNetwork, user); update.status = UpdateStatus::InProgress; update.id = add_update(update.clone()).await?; match periphery .request(api::network::CreateNetwork { name: to_docker_compatible_name(&self.name), driver: None, }) .await { Ok(log) => update.logs.push(log), Err(e) => update.push_error_log( "create network", format_serror(&e.context("failed to create network").into()), ), }; update.finalize(); update_update(update.clone()).await?; Ok(update) } } impl Resolve for CreateTerminal { #[instrument(name = "CreateTerminal", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Write.terminal(), ) .await?; let periphery = periphery_client(&server)?; periphery .request(api::terminal::CreateTerminal { name: self.name, command: self.command, recreate: self.recreate, }) .await .context("Failed to create terminal on periphery")?; Ok(NoData {}) } } impl Resolve for DeleteTerminal { #[instrument(name = "DeleteTerminal", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Write.terminal(), ) .await?; let periphery = periphery_client(&server)?; periphery .request(api::terminal::DeleteTerminal { terminal: self.terminal, }) .await .context("Failed to delete terminal on periphery")?; Ok(NoData {}) } } impl Resolve for DeleteAllTerminals { #[instrument(name = "DeleteAllTerminals", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let server = get_check_permissions::( &self.server, user, PermissionLevel::Write.terminal(), ) .await?; let periphery = periphery_client(&server)?; periphery .request(api::terminal::DeleteAllTerminals {}) .await .context("Failed to delete all terminals on periphery")?; Ok(NoData {}) } } ================================================ FILE: bin/core/src/api/write/service_user.rs ================================================ use std::str::FromStr; use anyhow::{Context, anyhow}; use database::mungos::{ by_id::find_one_by_id, mongodb::bson::{doc, oid::ObjectId}, }; use komodo_client::{ api::{user::CreateApiKey, write::*}, entities::{ komodo_timestamp, user::{User, UserConfig}, }, }; use resolver_api::Resolve; use crate::{api::user::UserArgs, state::db_client}; use super::WriteArgs; impl Resolve for CreateServiceUser { #[instrument(name = "CreateServiceUser", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { if !user.admin { return Err(anyhow!("user not admin").into()); } if ObjectId::from_str(&self.username).is_ok() { return Err( anyhow!("username cannot be valid ObjectId").into(), ); } let config = UserConfig::Service { description: self.description, }; let mut user = User { id: Default::default(), username: self.username, config, enabled: true, admin: false, super_admin: false, create_server_permissions: false, create_build_permissions: false, last_update_view: 0, recents: Default::default(), all: Default::default(), updated_at: komodo_timestamp(), }; user.id = db_client() .users .insert_one(&user) .await .context("failed to create service user on db")? .inserted_id .as_object_id() .context("inserted id is not object id")? .to_string(); Ok(user) } } impl Resolve for UpdateServiceUserDescription { #[instrument(name = "UpdateServiceUserDescription", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { if !user.admin { return Err(anyhow!("user not admin").into()); } let db = db_client(); let service_user = db .users .find_one(doc! { "username": &self.username }) .await .context("failed to query db for user")? .context("no user with given username")?; let UserConfig::Service { .. } = &service_user.config else { return Err(anyhow!("user is not service user").into()); }; db.users .update_one( doc! { "username": &self.username }, doc! { "$set": { "config.data.description": self.description } }, ) .await .context("failed to update user on db")?; let res = db .users .find_one(doc! { "username": &self.username }) .await .context("failed to query db for user")? .context("user with username not found")?; Ok(res) } } impl Resolve for CreateApiKeyForServiceUser { #[instrument(name = "CreateApiKeyForServiceUser", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { if !user.admin { return Err(anyhow!("user not admin").into()); } let service_user = find_one_by_id(&db_client().users, &self.user_id) .await .context("failed to query db for user")? .context("no user found with id")?; let UserConfig::Service { .. } = &service_user.config else { return Err(anyhow!("user is not service user").into()); }; CreateApiKey { name: self.name, expires: self.expires, } .resolve(&UserArgs { user: service_user }) .await } } impl Resolve for DeleteApiKeyForServiceUser { #[instrument(name = "DeleteApiKeyForServiceUser", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { if !user.admin { return Err(anyhow!("user not admin").into()); } let db = db_client(); let api_key = db .api_keys .find_one(doc! { "key": &self.key }) .await .context("failed to query db for api key")? .context("did not find matching api key")?; let service_user = find_one_by_id(&db_client().users, &api_key.user_id) .await .context("failed to query db for user")? .context("no user found with id")?; let UserConfig::Service { .. } = &service_user.config else { return Err(anyhow!("user is not service user").into()); }; db.api_keys .delete_one(doc! { "key": self.key }) .await .context("failed to delete api key on db")?; Ok(DeleteApiKeyForServiceUserResponse {}) } } ================================================ FILE: bin/core/src/api/write/stack.rs ================================================ use std::path::PathBuf; use anyhow::{Context, anyhow}; use database::mungos::mongodb::bson::{doc, to_document}; use formatting::format_serror; use komodo_client::{ api::write::*, entities::{ FileContents, NoData, Operation, RepoExecutionArgs, all_logs_success, config::core::CoreConfig, permission::PermissionLevel, repo::Repo, server::ServerState, stack::{PartialStackConfig, Stack, StackInfo}, update::Update, user::stack_user, }, }; use octorust::types::{ ReposCreateWebhookRequest, ReposCreateWebhookRequestConfig, }; use periphery_client::api::compose::{ GetComposeContentsOnHost, GetComposeContentsOnHostResponse, WriteComposeContentsToHost, }; use resolver_api::Resolve; use crate::{ config::core_config, helpers::{ periphery_client, query::get_server_with_state, stack_git_token, update::{add_update, make_update}, }, permission::get_check_permissions, resource, stack::{ remote::{RemoteComposeContents, get_repo_compose_contents}, services::extract_services_into_res, }, state::{db_client, github_client}, }; use super::WriteArgs; impl Resolve for CreateStack { #[instrument(name = "CreateStack", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { resource::create::(&self.name, self.config, user).await } } impl Resolve for CopyStack { #[instrument(name = "CopyStack", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let Stack { config, .. } = get_check_permissions::( &self.id, user, PermissionLevel::Read.into(), ) .await?; resource::create::(&self.name, config.into(), user).await } } impl Resolve for DeleteStack { #[instrument(name = "DeleteStack", skip(args))] async fn resolve(self, args: &WriteArgs) -> serror::Result { Ok(resource::delete::(&self.id, args).await?) } } impl Resolve for UpdateStack { #[instrument(name = "UpdateStack", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok(resource::update::(&self.id, self.config, user).await?) } } impl Resolve for RenameStack { #[instrument(name = "RenameStack", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok(resource::rename::(&self.id, &self.name, user).await?) } } impl Resolve for WriteStackFileContents { #[instrument(name = "WriteStackFileContents", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let WriteStackFileContents { stack, file_path, contents, } = self; let stack = get_check_permissions::( &stack, user, PermissionLevel::Write.into(), ) .await?; if !stack.config.files_on_host && stack.config.repo.is_empty() && stack.config.linked_repo.is_empty() { return Err(anyhow!( "Stack is not configured to use Files on Host, Git Repo, or Linked Repo, can't write file contents" ).into()); } let mut update = make_update(&stack, Operation::WriteStackContents, user); update.push_simple_log("File contents to write", &contents); if stack.config.files_on_host { write_stack_file_contents_on_host( stack, file_path, contents, update, ) .await } else { write_stack_file_contents_git( stack, &file_path, &contents, &user.username, update, ) .await } } } async fn write_stack_file_contents_on_host( stack: Stack, file_path: String, contents: String, mut update: Update, ) -> serror::Result { if stack.config.server_id.is_empty() { return Err(anyhow!( "Cannot write file, Files on host Stack has not configured a Server" ).into()); } let (server, state) = get_server_with_state(&stack.config.server_id).await?; if state != ServerState::Ok { return Err( anyhow!( "Cannot write file when server is unreachable or disabled" ) .into(), ); } match periphery_client(&server)? .request(WriteComposeContentsToHost { name: stack.name, run_directory: stack.config.run_directory, file_path, contents, }) .await .context("Failed to write contents to host") { Ok(log) => { update.logs.push(log); } Err(e) => { update.push_error_log( "Write File Contents", format_serror(&e.into()), ); } }; if !all_logs_success(&update.logs) { update.finalize(); update.id = add_update(update.clone()).await?; return Ok(update); } // Finish with a cache refresh if let Err(e) = (RefreshStackCache { stack: stack.id }) .resolve(&WriteArgs { user: stack_user().to_owned(), }) .await .map_err(|e| e.error) .context( "Failed to refresh stack cache after writing file contents", ) { update.push_error_log( "Refresh stack cache", format_serror(&e.into()), ); } update.finalize(); update.id = add_update(update.clone()).await?; Ok(update) } async fn write_stack_file_contents_git( mut stack: Stack, file_path: &str, contents: &str, username: &str, mut update: Update, ) -> serror::Result { let mut repo = if !stack.config.linked_repo.is_empty() { crate::resource::get::(&stack.config.linked_repo) .await? .into() } else { None }; let git_token = stack_git_token(&mut stack, repo.as_mut()).await?; let mut repo_args: RepoExecutionArgs = if let Some(repo) = &repo { repo.into() } else { (&stack).into() }; let root = repo_args.unique_path(&core_config().repo_directory)?; repo_args.destination = Some(root.display().to_string()); let file_path = stack .config .run_directory .parse::() .context("Run directory is not a valid path")? .join(file_path); let full_path = root.join(&file_path).components().collect::(); if let Some(parent) = full_path.parent() { tokio::fs::create_dir_all(parent).await.with_context(|| { format!( "Failed to initialize stack file parent directory {parent:?}" ) })?; } // Ensure the folder is initialized as git repo. // This allows a new file to be committed on a branch that may not exist. if !root.join(".git").exists() { git::init_folder_as_repo( &root, &repo_args, git_token.as_deref(), &mut update.logs, ) .await; if !all_logs_success(&update.logs) { update.finalize(); update.id = add_update(update.clone()).await?; return Ok(update); } } // Save this for later -- repo_args moved next. let branch = repo_args.branch.clone(); // Pull latest changes to repo to ensure linear commit history match git::pull_or_clone( repo_args, &core_config().repo_directory, git_token, ) .await .context("Failed to pull latest changes before commit") { Ok((res, _)) => update.logs.extend(res.logs), Err(e) => { update.push_error_log("Pull Repo", format_serror(&e.into())); update.finalize(); return Ok(update); } }; if !all_logs_success(&update.logs) { update.finalize(); update.id = add_update(update.clone()).await?; return Ok(update); } if let Err(e) = tokio::fs::write(&full_path, &contents) .await .with_context(|| { format!( "Failed to write compose file contents to {full_path:?}" ) }) { update.push_error_log("Write File", format_serror(&e.into())); } else { update.push_simple_log( "Write File", format!("File written to {full_path:?}"), ); }; if !all_logs_success(&update.logs) { update.finalize(); update.id = add_update(update.clone()).await?; return Ok(update); } let commit_res = git::commit_file( &format!("{username}: Write Stack File"), &root, &file_path, &branch, ) .await; update.logs.extend(commit_res.logs); // Finish with a cache refresh if let Err(e) = (RefreshStackCache { stack: stack.id }) .resolve(&WriteArgs { user: stack_user().to_owned(), }) .await .map_err(|e| e.error) .context( "Failed to refresh stack cache after writing file contents", ) { update.push_error_log( "Refresh stack cache", format_serror(&e.into()), ); } update.finalize(); update.id = add_update(update.clone()).await?; Ok(update) } impl Resolve for RefreshStackCache { #[instrument( name = "RefreshStackCache", level = "debug", skip(user) )] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { // Even though this is a write request, this doesn't change any config. Anyone that can execute the // stack should be able to do this. let stack = get_check_permissions::( &self.stack, user, PermissionLevel::Execute.into(), ) .await?; let repo = if !stack.config.files_on_host && !stack.config.linked_repo.is_empty() { crate::resource::get::(&stack.config.linked_repo) .await? .into() } else { None }; let file_contents_empty = stack.config.file_contents.is_empty(); let repo_empty = stack.config.repo.is_empty() && repo.as_ref().is_none(); if !stack.config.files_on_host && file_contents_empty && repo_empty { // Nothing to do without one of these return Ok(NoData {}); } let mut missing_files = Vec::new(); let ( latest_services, remote_contents, remote_errors, latest_hash, latest_message, ) = if stack.config.files_on_host { // ============= // FILES ON HOST // ============= let (server, state) = if stack.config.server_id.is_empty() { (None, ServerState::Disabled) } else { let (server, state) = get_server_with_state(&stack.config.server_id).await?; (Some(server), state) }; if state != ServerState::Ok { (vec![], None, None, None, None) } else if let Some(server) = server { let GetComposeContentsOnHostResponse { contents, errors } = match periphery_client(&server)? .request(GetComposeContentsOnHost { file_paths: stack.all_file_dependencies(), name: stack.name.clone(), run_directory: stack.config.run_directory.clone(), }) .await .context("failed to get compose file contents from host") { Ok(res) => res, Err(e) => GetComposeContentsOnHostResponse { contents: Default::default(), errors: vec![FileContents { path: stack.config.run_directory.clone(), contents: format_serror(&e.into()), }], }, }; let project_name = stack.project_name(true); let mut services = Vec::new(); for contents in &contents { // Don't include additional files in service parsing if !stack.is_compose_file(&contents.path) { continue; } if let Err(e) = extract_services_into_res( &project_name, &contents.contents, &mut services, ) { warn!( "failed to extract stack services, things won't works correctly. stack: {} | {e:#}", stack.name ); } } (services, Some(contents), Some(errors), None, None) } else { (vec![], None, None, None, None) } } else if !repo_empty { // ================ // REPO BASED STACK // ================ let RemoteComposeContents { successful: remote_contents, errored: remote_errors, hash: latest_hash, message: latest_message, .. } = get_repo_compose_contents( &stack, repo.as_ref(), Some(&mut missing_files), ) .await?; let project_name = stack.project_name(true); let mut services = Vec::new(); for contents in &remote_contents { // Don't include additional files in service parsing if !stack.is_compose_file(&contents.path) { continue; } if let Err(e) = extract_services_into_res( &project_name, &contents.contents, &mut services, ) { warn!( "failed to extract stack services, things won't works correctly. stack: {} | {e:#}", stack.name ); } } ( services, Some(remote_contents), Some(remote_errors), latest_hash, latest_message, ) } else { // ============= // UI BASED FILE // ============= let mut services = Vec::new(); if let Err(e) = extract_services_into_res( // this should latest (not deployed), so make the project name fresh. &stack.project_name(true), &stack.config.file_contents, &mut services, ) { warn!( "Failed to extract Stack services for {}, things may not work correctly. | {e:#}", stack.name ); services.extend(stack.info.latest_services.clone()); }; (services, None, None, None, None) }; let info = StackInfo { missing_files, deployed_services: stack.info.deployed_services.clone(), deployed_project_name: stack.info.deployed_project_name.clone(), deployed_contents: stack.info.deployed_contents.clone(), deployed_config: stack.info.deployed_config.clone(), deployed_hash: stack.info.deployed_hash.clone(), deployed_message: stack.info.deployed_message.clone(), latest_services, remote_contents, remote_errors, latest_hash, latest_message, }; let info = to_document(&info) .context("failed to serialize stack info to bson")?; db_client() .stacks .update_one( doc! { "name": &stack.name }, doc! { "$set": { "info": info } }, ) .await .context("failed to update stack info on db")?; Ok(NoData {}) } } impl Resolve for CreateStackWebhook { #[instrument(name = "CreateStackWebhook", skip(args))] async fn resolve( self, args: &WriteArgs, ) -> serror::Result { let WriteArgs { user } = args; let Some(github) = github_client() else { return Err( anyhow!( "github_webhook_app is not configured in core config toml" ) .into(), ); }; let stack = get_check_permissions::( &self.stack, user, PermissionLevel::Write.into(), ) .await?; if stack.config.repo.is_empty() { return Err( anyhow!("No repo configured, can't create webhook").into(), ); } let mut split = stack.config.repo.split('/'); let owner = split.next().context("Stack repo has no owner")?; let Some(github) = github.get(owner) else { return Err( anyhow!("Cannot manage repo webhooks under owner {owner}") .into(), ); }; let repo = split.next().context("Stack repo has no repo after the /")?; let github_repos = github.repos(); // First make sure the webhook isn't already created (inactive ones are ignored) let webhooks = github_repos .list_all_webhooks(owner, repo) .await .context("failed to list all webhooks on repo")? .body; let CoreConfig { host, webhook_base_url, webhook_secret, .. } = core_config(); let webhook_secret = if stack.config.webhook_secret.is_empty() { webhook_secret } else { &stack.config.webhook_secret }; let host = if webhook_base_url.is_empty() { host } else { webhook_base_url }; let url = match self.action { StackWebhookAction::Refresh => { format!("{host}/listener/github/stack/{}/refresh", stack.id) } StackWebhookAction::Deploy => { format!("{host}/listener/github/stack/{}/deploy", stack.id) } }; for webhook in webhooks { if webhook.active && webhook.config.url == url { return Ok(NoData {}); } } // Now good to create the webhook let request = ReposCreateWebhookRequest { active: Some(true), config: Some(ReposCreateWebhookRequestConfig { url, secret: webhook_secret.to_string(), content_type: String::from("json"), insecure_ssl: None, digest: Default::default(), token: Default::default(), }), events: vec![String::from("push")], name: String::from("web"), }; github_repos .create_webhook(owner, repo, &request) .await .context("failed to create webhook")?; if !stack.config.webhook_enabled { UpdateStack { id: stack.id, config: PartialStackConfig { webhook_enabled: Some(true), ..Default::default() }, } .resolve(args) .await .map_err(|e| e.error) .context("failed to update stack to enable webhook")?; } Ok(NoData {}) } } impl Resolve for DeleteStackWebhook { #[instrument(name = "DeleteStackWebhook", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let Some(github) = github_client() else { return Err( anyhow!( "github_webhook_app is not configured in core config toml" ) .into(), ); }; let stack = get_check_permissions::( &self.stack, user, PermissionLevel::Write.into(), ) .await?; if stack.config.git_provider != "github.com" { return Err( anyhow!("Can only manage github.com repo webhooks").into(), ); } if stack.config.repo.is_empty() { return Err( anyhow!("No repo configured, can't create webhook").into(), ); } let mut split = stack.config.repo.split('/'); let owner = split.next().context("Stack repo has no owner")?; let Some(github) = github.get(owner) else { return Err( anyhow!("Cannot manage repo webhooks under owner {owner}") .into(), ); }; let repo = split.next().context("Sync repo has no repo after the /")?; let github_repos = github.repos(); // First make sure the webhook isn't already created (inactive ones are ignored) let webhooks = github_repos .list_all_webhooks(owner, repo) .await .context("failed to list all webhooks on repo")? .body; let CoreConfig { host, webhook_base_url, .. } = core_config(); let host = if webhook_base_url.is_empty() { host } else { webhook_base_url }; let url = match self.action { StackWebhookAction::Refresh => { format!("{host}/listener/github/stack/{}/refresh", stack.id) } StackWebhookAction::Deploy => { format!("{host}/listener/github/stack/{}/deploy", stack.id) } }; for webhook in webhooks { if webhook.active && webhook.config.url == url { github_repos .delete_webhook(owner, repo, webhook.id) .await .context("failed to delete webhook")?; return Ok(NoData {}); } } // No webhook to delete, all good Ok(NoData {}) } } ================================================ FILE: bin/core/src/api/write/sync.rs ================================================ use std::{ collections::HashMap, path::{Path, PathBuf}, }; use anyhow::{Context, anyhow}; use database::mungos::{ by_id::update_one_by_id, mongodb::bson::{doc, to_document}, }; use formatting::format_serror; use komodo_client::{ api::{read::ExportAllResourcesToToml, write::*}, entities::{ self, NoData, Operation, RepoExecutionArgs, ResourceTarget, action::Action, alert::{Alert, AlertData, SeverityLevel}, alerter::Alerter, all_logs_success, build::Build, builder::Builder, config::core::CoreConfig, deployment::Deployment, komodo_timestamp, permission::PermissionLevel, procedure::Procedure, repo::Repo, server::Server, stack::Stack, sync::{ PartialResourceSyncConfig, ResourceSync, ResourceSyncInfo, SyncDeployUpdate, }, to_path_compatible_name, update::{Log, Update}, user::sync_user, }, }; use octorust::types::{ ReposCreateWebhookRequest, ReposCreateWebhookRequestConfig, }; use resolver_api::Resolve; use crate::{ alert::send_alerts, api::read::ReadArgs, config::core_config, helpers::{ all_resources::AllResourcesById, git_token, query::get_id_to_tags, update::{add_update, make_update, update_update}, }, permission::get_check_permissions, resource, state::{db_client, github_client}, sync::{ deploy::SyncDeployParams, remote::RemoteResources, view::push_updates_for_view, }, }; use super::WriteArgs; impl Resolve for CreateResourceSync { #[instrument(name = "CreateResourceSync", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { resource::create::(&self.name, self.config, user) .await } } impl Resolve for CopyResourceSync { #[instrument(name = "CopyResourceSync", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let ResourceSync { config, .. } = get_check_permissions::( &self.id, user, PermissionLevel::Write.into(), ) .await?; resource::create::(&self.name, config.into(), user) .await } } impl Resolve for DeleteResourceSync { #[instrument(name = "DeleteResourceSync", skip(args))] async fn resolve( self, args: &WriteArgs, ) -> serror::Result { Ok(resource::delete::(&self.id, args).await?) } } impl Resolve for UpdateResourceSync { #[instrument(name = "UpdateResourceSync", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok( resource::update::(&self.id, self.config, user) .await?, ) } } impl Resolve for RenameResourceSync { #[instrument(name = "RenameResourceSync", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { Ok( resource::rename::(&self.id, &self.name, user) .await?, ) } } impl Resolve for WriteSyncFileContents { #[instrument(name = "WriteSyncFileContents", skip(args))] async fn resolve(self, args: &WriteArgs) -> serror::Result { let sync = get_check_permissions::( &self.sync, &args.user, PermissionLevel::Write.into(), ) .await?; let repo = if !sync.config.files_on_host && !sync.config.linked_repo.is_empty() { crate::resource::get::(&sync.config.linked_repo) .await? .into() } else { None }; if !sync.config.files_on_host && sync.config.repo.is_empty() && sync.config.linked_repo.is_empty() { return Err( anyhow!( "This method is only for 'files on host' or 'repo' based syncs." ) .into(), ); } let mut update = make_update(&sync, Operation::WriteSyncContents, &args.user); update.push_simple_log("File contents", &self.contents); if sync.config.files_on_host { write_sync_file_contents_on_host(self, args, sync, update).await } else { write_sync_file_contents_git(self, args, sync, repo, update) .await } } } async fn write_sync_file_contents_on_host( req: WriteSyncFileContents, args: &WriteArgs, sync: ResourceSync, mut update: Update, ) -> serror::Result { let WriteSyncFileContents { sync: _, resource_path, file_path, contents, } = req; let root = core_config() .sync_directory .join(to_path_compatible_name(&sync.name)); let file_path = file_path.parse::().context("Invalid file path")?; let resource_path = resource_path .parse::() .context("Invalid resource path")?; let full_path = root.join(&resource_path).join(&file_path); if let Some(parent) = full_path.parent() { tokio::fs::create_dir_all(parent).await.with_context(|| { format!( "Failed to initialize resource file parent directory {parent:?}" ) })?; } if let Err(e) = tokio::fs::write(&full_path, &contents) .await .with_context(|| { format!( "Failed to write resource file contents to {full_path:?}" ) }) { update.push_error_log("Write File", format_serror(&e.into())); } else { update.push_simple_log( "Write File", format!("File written to {full_path:?}"), ); }; if !all_logs_success(&update.logs) { update.finalize(); update.id = add_update(update.clone()).await?; return Ok(update); } if let Err(e) = (RefreshResourceSyncPending { sync: sync.name }) .resolve(args) .await { update.push_error_log( "Refresh failed", format_serror(&e.error.into()), ); } update.finalize(); update.id = add_update(update.clone()).await?; Ok(update) } async fn write_sync_file_contents_git( req: WriteSyncFileContents, args: &WriteArgs, sync: ResourceSync, repo: Option, mut update: Update, ) -> serror::Result { let WriteSyncFileContents { sync: _, resource_path, file_path, contents, } = req; let mut repo_args: RepoExecutionArgs = if let Some(repo) = &repo { repo.into() } else { (&sync).into() }; let root = repo_args.unique_path(&core_config().repo_directory)?; repo_args.destination = Some(root.display().to_string()); let git_token = if let Some(account) = &repo_args.account { git_token(&repo_args.provider, account, |https| repo_args.https = https) .await .with_context( || format!("Failed to get git token in call to db. Stopping run. | {} | {account}", repo_args.provider), )? } else { None }; let file_path = file_path.parse::().with_context(|| { format!("File path is not a valid path: {file_path}") })?; let resource_path = resource_path.parse::().with_context(|| { format!("Resource path is not a valid path: {resource_path}") })?; let full_path = root .join(&resource_path) .join(&file_path) .components() .collect::(); if let Some(parent) = full_path.parent() { tokio::fs::create_dir_all(parent).await.with_context(|| { format!( "Failed to initialize resource file parent directory {parent:?}" ) })?; } // Ensure the folder is initialized as git repo. // This allows a new file to be committed on a branch that may not exist. if !root.join(".git").exists() { git::init_folder_as_repo( &root, &repo_args, git_token.as_deref(), &mut update.logs, ) .await; if !all_logs_success(&update.logs) { update.finalize(); update.id = add_update(update.clone()).await?; return Ok(update); } } // Save this for later -- repo_args moved next. let branch = repo_args.branch.clone(); // Pull latest changes to repo to ensure linear commit history match git::pull_or_clone( repo_args, &core_config().repo_directory, git_token, ) .await .context("Failed to pull latest changes before commit") { Ok((res, _)) => update.logs.extend(res.logs), Err(e) => { update.push_error_log("Pull Repo", format_serror(&e.into())); update.finalize(); return Ok(update); } }; if !all_logs_success(&update.logs) { update.finalize(); update.id = add_update(update.clone()).await?; return Ok(update); } if let Err(e) = tokio::fs::write(&full_path, &contents) .await .with_context(|| { format!( "Failed to write resource file contents to {full_path:?}" ) }) { update.push_error_log("Write File", format_serror(&e.into())); } else { update.push_simple_log( "Write File", format!("File written to {full_path:?}"), ); }; if !all_logs_success(&update.logs) { update.finalize(); update.id = add_update(update.clone()).await?; return Ok(update); } let commit_res = git::commit_file( &format!("{}: Commit Resource File", args.user.username), &root, &resource_path.join(&file_path), &branch, ) .await; update.logs.extend(commit_res.logs); if let Err(e) = (RefreshResourceSyncPending { sync: sync.name }) .resolve(args) .await .map_err(|e| e.error) .context( "Failed to refresh sync pending after writing file contents", ) { update.push_error_log( "Refresh sync pending", format_serror(&e.into()), ); } update.finalize(); update.id = add_update(update.clone()).await?; Ok(update) } impl Resolve for CommitSync { #[instrument(name = "CommitSync", skip(args))] async fn resolve(self, args: &WriteArgs) -> serror::Result { let WriteArgs { user } = args; let sync = get_check_permissions::( &self.sync, user, PermissionLevel::Write.into(), ) .await?; let repo = if !sync.config.files_on_host && !sync.config.linked_repo.is_empty() { crate::resource::get::(&sync.config.linked_repo) .await? .into() } else { None }; let file_contents_empty = sync.config.file_contents_empty(); let fresh_sync = !sync.config.files_on_host && sync.config.repo.is_empty() && repo.is_none() && file_contents_empty; if !sync.config.managed && !fresh_sync { return Err( anyhow!("Cannot commit to sync. Enabled 'managed' mode.") .into(), ); } // Get this here so it can fail before update created. let resource_path = if sync.config.files_on_host || !sync.config.repo.is_empty() || repo.is_some() { let resource_path = sync .config .resource_path .first() .context("Sync does not have resource path configured.")? .parse::() .context("Invalid resource path")?; if resource_path .extension() .context("Resource path missing '.toml' extension")? != "toml" { return Err( anyhow!("Resource path missing '.toml' extension").into(), ); } Some(resource_path) } else { None }; let res = ExportAllResourcesToToml { include_resources: sync.config.include_resources, tags: sync.config.match_tags.clone(), include_variables: sync.config.include_variables, include_user_groups: sync.config.include_user_groups, } .resolve(&ReadArgs { user: sync_user().to_owned(), }) .await?; let mut update = make_update(&sync, Operation::CommitSync, user); update.id = add_update(update.clone()).await?; update.logs.push(Log::simple("Resources", res.toml.clone())); if sync.config.files_on_host { let Some(resource_path) = resource_path else { // Resource path checked above for files_on_host mode. unreachable!() }; let file_path = core_config() .sync_directory .join(to_path_compatible_name(&sync.name)) .join(&resource_path); if let Some(parent) = file_path.parent() { tokio::fs::create_dir_all(parent) .await .with_context(|| format!("Failed to initialize resource file parent directory {parent:?}"))?; }; if let Err(e) = tokio::fs::write(&file_path, &res.toml) .await .with_context(|| { format!("Failed to write resource file to {file_path:?}",) }) { update.push_error_log( "Write resource file", format_serror(&e.into()), ); update.finalize(); add_update(update.clone()).await?; return Ok(update); } else { update.push_simple_log( "Write contents", format!("File contents written to {file_path:?}"), ); } } else if let Some(repo) = &repo { let Some(resource_path) = resource_path else { // Resource path checked above for repo mode. unreachable!() }; let args: RepoExecutionArgs = repo.into(); if let Err(e) = commit_git_sync(args, &resource_path, &res.toml, &mut update) .await { update.push_error_log( "Write resource file", format_serror(&e.into()), ); update.finalize(); add_update(update.clone()).await?; return Ok(update); } } else if !sync.config.repo.is_empty() { let Some(resource_path) = resource_path else { // Resource path checked above for repo mode. unreachable!() }; let args: RepoExecutionArgs = (&sync).into(); if let Err(e) = commit_git_sync(args, &resource_path, &res.toml, &mut update) .await { update.push_error_log( "Write resource file", format_serror(&e.into()), ); update.finalize(); add_update(update.clone()).await?; return Ok(update); } // =========== // UI DEFINED } else if let Err(e) = db_client() .resource_syncs .update_one( doc! { "name": &sync.name }, doc! { "$set": { "config.file_contents": res.toml } }, ) .await .context("failed to update file_contents on db") { update.push_error_log( "Write resource to database", format_serror(&e.into()), ); update.finalize(); add_update(update.clone()).await?; return Ok(update); } if let Err(e) = (RefreshResourceSyncPending { sync: sync.name }) .resolve(args) .await { update.push_error_log( "Refresh sync pending", format_serror(&e.error.into()), ); }; update.finalize(); update_update(update.clone()).await?; Ok(update) } } async fn commit_git_sync( mut args: RepoExecutionArgs, resource_path: &Path, toml: &str, update: &mut Update, ) -> anyhow::Result<()> { let root = args.unique_path(&core_config().repo_directory)?; args.destination = Some(root.display().to_string()); let access_token = if let Some(account) = &args.account { git_token(&args.provider, account, |https| args.https = https) .await .with_context( || format!("Failed to get git token in call to db. Stopping run. | {} | {account}", args.provider), )? } else { None }; let (pull_res, _) = git::pull_or_clone( args.clone(), &core_config().repo_directory, access_token, ) .await?; update.logs.extend(pull_res.logs); if !all_logs_success(&update.logs) { return Ok(()); } let res = git::write_commit_file( "Commit Sync", &root, resource_path, toml, &args.branch, ) .await?; update.logs.extend(res.logs); Ok(()) } impl Resolve for RefreshResourceSyncPending { #[instrument( name = "RefreshResourceSyncPending", level = "debug", skip(user) )] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { // Even though this is a write request, this doesn't change any config. Anyone that can execute the // sync should be able to do this. let mut sync = get_check_permissions::( &self.sync, user, PermissionLevel::Execute.into(), ) .await?; let repo = if !sync.config.files_on_host && !sync.config.linked_repo.is_empty() { crate::resource::get::(&sync.config.linked_repo) .await? .into() } else { None }; if !sync.config.managed && !sync.config.files_on_host && sync.config.file_contents.is_empty() && sync.config.repo.is_empty() && sync.config.linked_repo.is_empty() { // Sync not configured, nothing to refresh return Ok(sync); } let res = async { let RemoteResources { resources, files, file_errors, hash, message, .. } = crate::sync::remote::get_remote_resources( &sync, repo.as_ref(), ) .await .context("failed to get remote resources")?; sync.info.remote_contents = files; sync.info.remote_errors = file_errors; sync.info.pending_hash = hash; sync.info.pending_message = message; if !sync.info.remote_errors.is_empty() { return Err(anyhow!( "Remote resources have errors. Cannot compute diffs." )); } let resources = resources?; let delete = sync.config.managed || sync.config.delete; let all_resources = AllResourcesById::load().await?; let (resource_updates, deploy_updates) = if sync.config.include_resources { let id_to_tags = get_id_to_tags(None).await?; let deployments_by_name = all_resources .deployments .values() .map(|deployment| { (deployment.name.clone(), deployment.clone()) }) .collect::>(); let stacks_by_name = all_resources .stacks .values() .map(|stack| (stack.name.clone(), stack.clone())) .collect::>(); let deploy_updates = crate::sync::deploy::get_updates_for_view( SyncDeployParams { deployments: &resources.deployments, deployment_map: &deployments_by_name, stacks: &resources.stacks, stack_map: &stacks_by_name, }, ) .await; let mut diffs = Vec::new(); push_updates_for_view::( resources.servers, delete, None, None, &id_to_tags, &sync.config.match_tags, &mut diffs, ) .await?; push_updates_for_view::( resources.stacks, delete, None, None, &id_to_tags, &sync.config.match_tags, &mut diffs, ) .await?; push_updates_for_view::( resources.deployments, delete, None, None, &id_to_tags, &sync.config.match_tags, &mut diffs, ) .await?; push_updates_for_view::( resources.builds, delete, None, None, &id_to_tags, &sync.config.match_tags, &mut diffs, ) .await?; push_updates_for_view::( resources.repos, delete, None, None, &id_to_tags, &sync.config.match_tags, &mut diffs, ) .await?; push_updates_for_view::( resources.procedures, delete, None, None, &id_to_tags, &sync.config.match_tags, &mut diffs, ) .await?; push_updates_for_view::( resources.actions, delete, None, None, &id_to_tags, &sync.config.match_tags, &mut diffs, ) .await?; push_updates_for_view::( resources.builders, delete, None, None, &id_to_tags, &sync.config.match_tags, &mut diffs, ) .await?; push_updates_for_view::( resources.alerters, delete, None, None, &id_to_tags, &sync.config.match_tags, &mut diffs, ) .await?; push_updates_for_view::( resources.resource_syncs, delete, None, None, &id_to_tags, &sync.config.match_tags, &mut diffs, ) .await?; (diffs, deploy_updates) } else { (Vec::new(), SyncDeployUpdate::default()) }; let variable_updates = if sync.config.include_variables { crate::sync::variables::get_updates_for_view( &resources.variables, delete, ) .await? } else { Default::default() }; let user_group_updates = if sync.config.include_user_groups { crate::sync::user_groups::get_updates_for_view( resources.user_groups, delete, ) .await? } else { Default::default() }; anyhow::Ok(( resource_updates, deploy_updates, variable_updates, user_group_updates, )) } .await; let ( resource_updates, deploy_updates, variable_updates, user_group_updates, pending_error, ) = match res { Ok(res) => (res.0, res.1, res.2, res.3, None), Err(e) => ( Default::default(), Default::default(), Default::default(), Default::default(), Some(format_serror(&e.into())), ), }; let has_updates = !resource_updates.is_empty() || !deploy_updates.to_deploy == 0 || !variable_updates.is_empty() || !user_group_updates.is_empty(); let info = ResourceSyncInfo { last_sync_ts: sync.info.last_sync_ts, last_sync_hash: sync.info.last_sync_hash, last_sync_message: sync.info.last_sync_message, remote_contents: sync.info.remote_contents, remote_errors: sync.info.remote_errors, pending_hash: sync.info.pending_hash, pending_message: sync.info.pending_message, pending_deploy: deploy_updates, resource_updates, variable_updates, user_group_updates, pending_error, }; let info = to_document(&info) .context("failed to serialize pending to document")?; update_one_by_id( &db_client().resource_syncs, &sync.id, doc! { "$set": { "info": info } }, None, ) .await?; // check to update alert let id = sync.id.clone(); let name = sync.name.clone(); tokio::task::spawn(async move { let db = db_client(); let Some(existing) = db_client() .alerts .find_one(doc! { "resolved": false, "target.type": "ResourceSync", "target.id": &id, }) .await .context("failed to query db for alert") .inspect_err(|e| warn!("{e:#}")) .ok() else { return; }; match (existing, has_updates) { // OPEN A NEW ALERT (None, true) => { let alert = Alert { id: Default::default(), ts: komodo_timestamp(), resolved: false, level: SeverityLevel::Ok, target: ResourceTarget::ResourceSync(id.clone()), data: AlertData::ResourceSyncPendingUpdates { id, name }, resolved_ts: None, }; db.alerts .insert_one(&alert) .await .context("failed to open existing pending resource sync updates alert") .inspect_err(|e| warn!("{e:#}")) .ok(); if sync.config.pending_alert { send_alerts(&[alert]).await; } } // CLOSE ALERT (Some(existing), false) => { update_one_by_id( &db.alerts, &existing.id, doc! { "$set": { "resolved": true, "resolved_ts": komodo_timestamp() } }, None, ) .await .context("failed to close existing pending resource sync updates alert") .inspect_err(|e| warn!("{e:#}")) .ok(); } // NOTHING TO DO _ => {} } }); Ok(crate::resource::get::(&sync.id).await?) } } impl Resolve for CreateSyncWebhook { #[instrument(name = "CreateSyncWebhook", skip(args))] async fn resolve( self, args: &WriteArgs, ) -> serror::Result { let WriteArgs { user } = args; let Some(github) = github_client() else { return Err( anyhow!( "github_webhook_app is not configured in core config toml" ) .into(), ); }; let sync = get_check_permissions::( &self.sync, user, PermissionLevel::Write.into(), ) .await?; if sync.config.repo.is_empty() { return Err( anyhow!("No repo configured, can't create webhook").into(), ); } let mut split = sync.config.repo.split('/'); let owner = split.next().context("Sync repo has no owner")?; let Some(github) = github.get(owner) else { return Err( anyhow!("Cannot manage repo webhooks under owner {owner}") .into(), ); }; let repo = split.next().context("Repo repo has no repo after the /")?; let github_repos = github.repos(); // First make sure the webhook isn't already created (inactive ones are ignored) let webhooks = github_repos .list_all_webhooks(owner, repo) .await .context("failed to list all webhooks on repo")? .body; let CoreConfig { host, webhook_base_url, webhook_secret, .. } = core_config(); let webhook_secret = if sync.config.webhook_secret.is_empty() { webhook_secret } else { &sync.config.webhook_secret }; let host = if webhook_base_url.is_empty() { host } else { webhook_base_url }; let url = match self.action { SyncWebhookAction::Refresh => { format!("{host}/listener/github/sync/{}/refresh", sync.id) } SyncWebhookAction::Sync => { format!("{host}/listener/github/sync/{}/sync", sync.id) } }; for webhook in webhooks { if webhook.active && webhook.config.url == url { return Ok(NoData {}); } } // Now good to create the webhook let request = ReposCreateWebhookRequest { active: Some(true), config: Some(ReposCreateWebhookRequestConfig { url, secret: webhook_secret.to_string(), content_type: String::from("json"), insecure_ssl: None, digest: Default::default(), token: Default::default(), }), events: vec![String::from("push")], name: String::from("web"), }; github_repos .create_webhook(owner, repo, &request) .await .context("failed to create webhook")?; if !sync.config.webhook_enabled { UpdateResourceSync { id: sync.id, config: PartialResourceSyncConfig { webhook_enabled: Some(true), ..Default::default() }, } .resolve(args) .await .map_err(|e| e.error) .context("failed to update sync to enable webhook")?; } Ok(NoData {}) } } impl Resolve for DeleteSyncWebhook { #[instrument(name = "DeleteSyncWebhook", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let Some(github) = github_client() else { return Err( anyhow!( "github_webhook_app is not configured in core config toml" ) .into(), ); }; let sync = get_check_permissions::( &self.sync, user, PermissionLevel::Write.into(), ) .await?; if sync.config.git_provider != "github.com" { return Err( anyhow!("Can only manage github.com repo webhooks").into(), ); } if sync.config.repo.is_empty() { return Err( anyhow!("No repo configured, can't create webhook").into(), ); } let mut split = sync.config.repo.split('/'); let owner = split.next().context("Sync repo has no owner")?; let Some(github) = github.get(owner) else { return Err( anyhow!("Cannot manage repo webhooks under owner {owner}") .into(), ); }; let repo = split.next().context("Sync repo has no repo after the /")?; let github_repos = github.repos(); // First make sure the webhook isn't already created (inactive ones are ignored) let webhooks = github_repos .list_all_webhooks(owner, repo) .await .context("failed to list all webhooks on repo")? .body; let CoreConfig { host, webhook_base_url, .. } = core_config(); let host = if webhook_base_url.is_empty() { host } else { webhook_base_url }; let url = match self.action { SyncWebhookAction::Refresh => { format!("{host}/listener/github/sync/{}/refresh", sync.id) } SyncWebhookAction::Sync => { format!("{host}/listener/github/sync/{}/sync", sync.id) } }; for webhook in webhooks { if webhook.active && webhook.config.url == url { github_repos .delete_webhook(owner, repo, webhook.id) .await .context("failed to delete webhook")?; return Ok(NoData {}); } } // No webhook to delete, all good Ok(NoData {}) } } ================================================ FILE: bin/core/src/api/write/tag.rs ================================================ use std::str::FromStr; use anyhow::{Context, anyhow}; use database::mungos::{ by_id::{delete_one_by_id, update_one_by_id}, mongodb::bson::{doc, oid::ObjectId}, }; use komodo_client::{ api::write::{CreateTag, DeleteTag, RenameTag, UpdateTagColor}, entities::{ action::Action, alerter::Alerter, build::Build, builder::Builder, deployment::Deployment, procedure::Procedure, repo::Repo, server::Server, stack::Stack, sync::ResourceSync, tag::Tag, }, }; use reqwest::StatusCode; use resolver_api::Resolve; use serror::AddStatusCodeError; use crate::{ config::core_config, helpers::query::{get_tag, get_tag_check_owner}, resource, state::db_client, }; use super::WriteArgs; impl Resolve for CreateTag { #[instrument(name = "CreateTag", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { if core_config().disable_non_admin_create && !user.admin { return Err( anyhow!("Non admins cannot create tags") .status_code(StatusCode::FORBIDDEN), ); } if ObjectId::from_str(&self.name).is_ok() { return Err( anyhow!("Tag name cannot be ObjectId") .status_code(StatusCode::BAD_REQUEST), ); } let mut tag = Tag { id: Default::default(), name: self.name, color: self.color.unwrap_or_default(), owner: user.id.clone(), }; tag.id = db_client() .tags .insert_one(&tag) .await .context("failed to create tag on db")? .inserted_id .as_object_id() .context("inserted_id is not ObjectId")? .to_string(); Ok(tag) } } impl Resolve for RenameTag { #[instrument(name = "RenameTag", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { if ObjectId::from_str(&self.name).is_ok() { return Err(anyhow!("tag name cannot be ObjectId").into()); } get_tag_check_owner(&self.id, user).await?; update_one_by_id( &db_client().tags, &self.id, doc! { "$set": { "name": self.name } }, None, ) .await .context("failed to rename tag on db")?; Ok(get_tag(&self.id).await?) } } impl Resolve for UpdateTagColor { #[instrument(name = "UpdateTagColor", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let tag = get_tag_check_owner(&self.tag, user).await?; update_one_by_id( &db_client().tags, &tag.id, doc! { "$set": { "color": self.color.as_ref() } }, None, ) .await .context("failed to rename tag on db")?; Ok(get_tag(&self.tag).await?) } } impl Resolve for DeleteTag { #[instrument(name = "DeleteTag", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { let tag = get_tag_check_owner(&self.id, user).await?; tokio::try_join!( resource::remove_tag_from_all::(&self.id), resource::remove_tag_from_all::(&self.id), resource::remove_tag_from_all::(&self.id), resource::remove_tag_from_all::(&self.id), resource::remove_tag_from_all::(&self.id), resource::remove_tag_from_all::(&self.id), resource::remove_tag_from_all::(&self.id), resource::remove_tag_from_all::(&self.id), resource::remove_tag_from_all::(&self.id), resource::remove_tag_from_all::(&self.id), )?; delete_one_by_id(&db_client().tags, &self.id, None).await?; Ok(tag) } } ================================================ FILE: bin/core/src/api/write/user.rs ================================================ use std::str::FromStr; use anyhow::{Context, anyhow}; use async_timing_util::unix_timestamp_ms; use database::{ hash_password, mungos::mongodb::bson::{doc, oid::ObjectId}, }; use komodo_client::{ api::write::*, entities::{ NoData, user::{User, UserConfig}, }, }; use reqwest::StatusCode; use resolver_api::Resolve; use serror::AddStatusCodeError; use crate::{config::core_config, state::db_client}; use super::WriteArgs; // impl Resolve for CreateLocalUser { #[instrument(name = "CreateLocalUser", skip(admin, self), fields(admin_id = admin.id, username = self.username))] async fn resolve( self, WriteArgs { user: admin }: &WriteArgs, ) -> serror::Result { if !admin.admin { return Err( anyhow!("This method is admin-only.") .status_code(StatusCode::FORBIDDEN), ); } if self.username.is_empty() { return Err(anyhow!("Username cannot be empty.").into()); } if ObjectId::from_str(&self.username).is_ok() { return Err( anyhow!("Username cannot be valid ObjectId").into(), ); } if self.password.is_empty() { return Err(anyhow!("Password cannot be empty.").into()); } let db = db_client(); if db .users .find_one(doc! { "username": &self.username }) .await .context("Failed to query for existing users")? .is_some() { return Err(anyhow!("Username already taken.").into()); } let ts = unix_timestamp_ms() as i64; let hashed_password = hash_password(self.password)?; let mut user = User { id: Default::default(), username: self.username, enabled: true, admin: false, super_admin: false, create_server_permissions: false, create_build_permissions: false, updated_at: ts, last_update_view: 0, recents: Default::default(), all: Default::default(), config: UserConfig::Local { password: hashed_password, }, }; user.id = db_client() .users .insert_one(&user) .await .context("failed to create user")? .inserted_id .as_object_id() .context("inserted_id is not ObjectId")? .to_string(); user.sanitize(); Ok(user) } } // impl Resolve for UpdateUserUsername { #[instrument(name = "UpdateUserUsername", skip(user), fields(user_id = user.id))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { for locked_username in &core_config().lock_login_credentials_for { if locked_username == "__ALL__" || *locked_username == user.username { return Err( anyhow!("User not allowed to update their username.") .into(), ); } } if self.username.is_empty() { return Err(anyhow!("Username cannot be empty.").into()); } if ObjectId::from_str(&self.username).is_ok() { return Err( anyhow!("Username cannot be valid ObjectId").into(), ); } let db = db_client(); if db .users .find_one(doc! { "username": &self.username }) .await .context("Failed to query for existing users")? .is_some() { return Err(anyhow!("Username already taken.").into()); } let id = ObjectId::from_str(&user.id) .context("User id not valid ObjectId.")?; db.users .update_one( doc! { "_id": id }, doc! { "$set": { "username": self.username } }, ) .await .context("Failed to update user username on database.")?; Ok(NoData {}) } } // impl Resolve for UpdateUserPassword { #[instrument(name = "UpdateUserPassword", skip(user, self), fields(user_id = user.id))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { for locked_username in &core_config().lock_login_credentials_for { if locked_username == "__ALL__" || *locked_username == user.username { return Err( anyhow!("User not allowed to update their password.") .into(), ); } } db_client().set_user_password(user, &self.password).await?; Ok(NoData {}) } } // impl Resolve for DeleteUser { #[instrument(name = "DeleteUser", skip(admin), fields(user = self.user))] async fn resolve( self, WriteArgs { user: admin }: &WriteArgs, ) -> serror::Result { if !admin.admin { return Err( anyhow!("This method is admin-only.") .status_code(StatusCode::FORBIDDEN), ); } if admin.username == self.user || admin.id == self.user { return Err(anyhow!("User cannot delete themselves.").into()); } let query = if let Ok(id) = ObjectId::from_str(&self.user) { doc! { "_id": id } } else { doc! { "username": self.user } }; let db = db_client(); let Some(user) = db .users .find_one(query.clone()) .await .context("Failed to query database for users.")? else { return Err( anyhow!("No user found with given id / username").into(), ); }; if user.super_admin { return Err( anyhow!("Cannot delete a super admin user.").into(), ); } if user.admin && !admin.super_admin { return Err( anyhow!("Only a Super Admin can delete an admin user.") .into(), ); } db.users .delete_one(query) .await .context("Failed to delete user from database")?; // Also remove user id from all user groups if let Err(e) = db .user_groups .update_many(doc! {}, doc! { "$pull": { "users": &user.id } }) .await { warn!("Failed to remove deleted user from user groups | {e:?}"); }; Ok(user) } } ================================================ FILE: bin/core/src/api/write/user_group.rs ================================================ use std::{collections::HashMap, str::FromStr}; use anyhow::{Context, anyhow}; use database::mungos::{ by_id::{delete_one_by_id, find_one_by_id, update_one_by_id}, find::find_collect, mongodb::bson::{doc, oid::ObjectId}, }; use komodo_client::{ api::write::*, entities::{komodo_timestamp, user_group::UserGroup}, }; use reqwest::StatusCode; use resolver_api::Resolve; use serror::AddStatusCodeError; use crate::state::db_client; use super::WriteArgs; impl Resolve for CreateUserGroup { #[instrument(name = "CreateUserGroup", skip(admin), fields(admin = admin.username))] async fn resolve( self, WriteArgs { user: admin }: &WriteArgs, ) -> serror::Result { if !admin.admin { return Err( anyhow!("This call is admin-only") .status_code(StatusCode::FORBIDDEN), ); } let user_group = UserGroup { name: self.name, id: Default::default(), everyone: Default::default(), users: Default::default(), all: Default::default(), updated_at: komodo_timestamp(), }; let db = db_client(); let id = db .user_groups .insert_one(user_group) .await .context("failed to create UserGroup on db")? .inserted_id .as_object_id() .context("inserted id is not ObjectId")? .to_string(); let res = find_one_by_id(&db.user_groups, &id) .await .context("failed to query db for user groups")? .context("user group at id not found")?; Ok(res) } } impl Resolve for RenameUserGroup { #[instrument(name = "RenameUserGroup", skip(admin), fields(admin = admin.username))] async fn resolve( self, WriteArgs { user: admin }: &WriteArgs, ) -> serror::Result { if !admin.admin { return Err( anyhow!("This call is admin-only") .status_code(StatusCode::FORBIDDEN), ); } let db = db_client(); update_one_by_id( &db.user_groups, &self.id, doc! { "$set": { "name": self.name } }, None, ) .await .context("failed to rename UserGroup on db")?; let res = find_one_by_id(&db.user_groups, &self.id) .await .context("failed to query db for UserGroups")? .context("no user group with given id")?; Ok(res) } } impl Resolve for DeleteUserGroup { #[instrument(name = "DeleteUserGroup", skip(admin), fields(admin = admin.username))] async fn resolve( self, WriteArgs { user: admin }: &WriteArgs, ) -> serror::Result { if !admin.admin { return Err( anyhow!("This call is admin-only") .status_code(StatusCode::FORBIDDEN), ); } let db = db_client(); let ug = find_one_by_id(&db.user_groups, &self.id) .await .context("failed to query db for UserGroups")? .context("no UserGroup found with given id")?; delete_one_by_id(&db.user_groups, &self.id, None) .await .context("failed to delete UserGroup from db")?; db.permissions .delete_many(doc! { "user_target.type": "UserGroup", "user_target.id": self.id, }) .await .context("failed to clean up UserGroups permissions. User Group has been deleted")?; Ok(ug) } } impl Resolve for AddUserToUserGroup { #[instrument(name = "AddUserToUserGroup", skip(admin), fields(admin = admin.username))] async fn resolve( self, WriteArgs { user: admin }: &WriteArgs, ) -> serror::Result { if !admin.admin { return Err( anyhow!("This call is admin-only") .status_code(StatusCode::FORBIDDEN), ); } let db = db_client(); let filter = match ObjectId::from_str(&self.user) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "username": &self.user }, }; let user = db .users .find_one(filter) .await .context("failed to query mongo for users")? .context("no matching user found")?; let filter = match ObjectId::from_str(&self.user_group) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": &self.user_group }, }; db.user_groups .update_one( filter.clone(), doc! { "$addToSet": { "users": &user.id } }, ) .await .context("failed to add user to group on db")?; let res = db .user_groups .find_one(filter) .await .context("failed to query db for UserGroups")? .context("no user group with given id")?; Ok(res) } } impl Resolve for RemoveUserFromUserGroup { #[instrument(name = "RemoveUserFromUserGroup", skip(admin), fields(admin = admin.username))] async fn resolve( self, WriteArgs { user: admin }: &WriteArgs, ) -> serror::Result { if !admin.admin { return Err( anyhow!("This call is admin-only") .status_code(StatusCode::FORBIDDEN), ); } let db = db_client(); let filter = match ObjectId::from_str(&self.user) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "username": &self.user }, }; let user = db .users .find_one(filter) .await .context("failed to query mongo for users")? .context("no matching user found")?; let filter = match ObjectId::from_str(&self.user_group) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": &self.user_group }, }; db.user_groups .update_one( filter.clone(), doc! { "$pull": { "users": &user.id } }, ) .await .context("failed to add user to group on db")?; let res = db .user_groups .find_one(filter) .await .context("failed to query db for UserGroups")? .context("no user group with given id")?; Ok(res) } } impl Resolve for SetUsersInUserGroup { #[instrument(name = "SetUsersInUserGroup", skip(admin), fields(admin = admin.username))] async fn resolve( self, WriteArgs { user: admin }: &WriteArgs, ) -> serror::Result { if !admin.admin { return Err( anyhow!("This call is admin-only") .status_code(StatusCode::FORBIDDEN), ); } let db = db_client(); let all_users = find_collect(&db.users, None, None) .await .context("failed to query db for users")? .into_iter() .map(|u| (u.username, u.id)) .collect::>(); // Make sure all users are user ids let users = self .users .into_iter() .filter_map(|user| match ObjectId::from_str(&user) { Ok(_) => Some(user), Err(_) => all_users.get(&user).cloned(), }) .collect::>(); let filter = match ObjectId::from_str(&self.user_group) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": &self.user_group }, }; db.user_groups .update_one(filter.clone(), doc! { "$set": { "users": users } }) .await .context("failed to set users on user group")?; let res = db .user_groups .find_one(filter) .await .context("failed to query db for UserGroups")? .context("no user group with given id")?; Ok(res) } } impl Resolve for SetEveryoneUserGroup { #[instrument(name = "SetEveryoneUserGroup", skip(admin), fields(admin = admin.username))] async fn resolve( self, WriteArgs { user: admin }: &WriteArgs, ) -> serror::Result { if !admin.admin { return Err( anyhow!("This call is admin-only") .status_code(StatusCode::FORBIDDEN), ); } let db = db_client(); let filter = match ObjectId::from_str(&self.user_group) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": &self.user_group }, }; db.user_groups .update_one( filter.clone(), doc! { "$set": { "everyone": self.everyone } }, ) .await .context("failed to set everyone on user group")?; let res = db .user_groups .find_one(filter) .await .context("failed to query db for UserGroups")? .context("no user group with given id")?; Ok(res) } } ================================================ FILE: bin/core/src/api/write/variable.rs ================================================ use anyhow::{Context, anyhow}; use database::mungos::mongodb::bson::doc; use komodo_client::{ api::write::*, entities::{Operation, ResourceTarget, variable::Variable}, }; use reqwest::StatusCode; use resolver_api::Resolve; use serror::AddStatusCodeError; use crate::{ helpers::{ query::get_variable, update::{add_update, make_update}, }, state::db_client, }; use super::WriteArgs; impl Resolve for CreateVariable { #[instrument(name = "CreateVariable", skip(user, self), fields(name = &self.name))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { if !user.admin { return Err( anyhow!("Only admins can create variables") .status_code(StatusCode::FORBIDDEN), ); } let CreateVariable { name, value, description, is_secret, } = self; let variable = Variable { name, value, description, is_secret, }; db_client() .variables .insert_one(&variable) .await .context("Failed to create variable on db")?; let mut update = make_update( ResourceTarget::system(), Operation::CreateVariable, user, ); update .push_simple_log("create variable", format!("{variable:#?}")); update.finalize(); add_update(update).await?; Ok(get_variable(&variable.name).await?) } } impl Resolve for UpdateVariableValue { #[instrument(name = "UpdateVariableValue", skip(user, self), fields(name = &self.name))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { if !user.admin { return Err( anyhow!("Only admins can update variables") .status_code(StatusCode::FORBIDDEN), ); } let UpdateVariableValue { name, value } = self; let variable = get_variable(&name).await?; if value == variable.value { return Ok(variable); } db_client() .variables .update_one( doc! { "name": &name }, doc! { "$set": { "value": &value } }, ) .await .context("Failed to update variable value on db")?; let mut update = make_update( ResourceTarget::system(), Operation::UpdateVariableValue, user, ); let log = if variable.is_secret { format!( "variable: '{name}'\nfrom: {}\nto: {value}", variable.value.replace(|_| true, "#") ) } else { format!( "variable: '{name}'\nfrom: {}\nto: {value}", variable.value ) }; update.push_simple_log("Update Variable Value", log); update.finalize(); add_update(update).await?; Ok(get_variable(&name).await?) } } impl Resolve for UpdateVariableDescription { #[instrument(name = "UpdateVariableDescription", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { if !user.admin { return Err( anyhow!("Only admins can update variables") .status_code(StatusCode::FORBIDDEN), ); } db_client() .variables .update_one( doc! { "name": &self.name }, doc! { "$set": { "description": &self.description } }, ) .await .context("Failed to update variable description on db")?; Ok(get_variable(&self.name).await?) } } impl Resolve for UpdateVariableIsSecret { #[instrument(name = "UpdateVariableIsSecret", skip(user))] async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { if !user.admin { return Err( anyhow!("Only admins can update variables") .status_code(StatusCode::FORBIDDEN), ); } db_client() .variables .update_one( doc! { "name": &self.name }, doc! { "$set": { "is_secret": self.is_secret } }, ) .await .context("Failed to update variable is secret on db")?; Ok(get_variable(&self.name).await?) } } impl Resolve for DeleteVariable { async fn resolve( self, WriteArgs { user }: &WriteArgs, ) -> serror::Result { if !user.admin { return Err( anyhow!("Only admins can delete variables") .status_code(StatusCode::FORBIDDEN), ); } let variable = get_variable(&self.name).await?; db_client() .variables .delete_one(doc! { "name": &self.name }) .await .context("Failed to delete variable on db")?; let mut update = make_update( ResourceTarget::system(), Operation::DeleteVariable, user, ); update .push_simple_log("Delete Variable", format!("{variable:#?}")); update.finalize(); add_update(update).await?; Ok(variable) } } ================================================ FILE: bin/core/src/auth/github/client.rs ================================================ use std::sync::OnceLock; use anyhow::{Context, anyhow}; use komodo_client::entities::config::core::{ CoreConfig, OauthCredentials, }; use reqwest::StatusCode; use serde::{Deserialize, Serialize, de::DeserializeOwned}; use tokio::sync::Mutex; use crate::{ auth::STATE_PREFIX_LENGTH, config::core_config, helpers::random_string, }; pub fn github_oauth_client() -> &'static Option { static GITHUB_OAUTH_CLIENT: OnceLock> = OnceLock::new(); GITHUB_OAUTH_CLIENT .get_or_init(|| GithubOauthClient::new(core_config())) } pub struct GithubOauthClient { http: reqwest::Client, client_id: String, client_secret: String, redirect_uri: String, scopes: String, states: Mutex>, user_agent: String, } impl GithubOauthClient { pub fn new( CoreConfig { github_oauth: OauthCredentials { enabled, id, secret, }, host, .. }: &CoreConfig, ) -> Option { if !enabled { return None; } if host.is_empty() { warn!( "github oauth is enabled, but 'config.host' is not configured" ); return None; } if id.is_empty() { warn!( "github oauth is enabled, but 'config.github_oauth.id' is not configured" ); return None; } if secret.is_empty() { warn!( "github oauth is enabled, but 'config.github_oauth.secret' is not configured" ); return None; } GithubOauthClient { http: reqwest::Client::new(), client_id: id.clone(), client_secret: secret.clone(), redirect_uri: format!("{host}/auth/github/callback"), user_agent: Default::default(), scopes: Default::default(), states: Default::default(), } .into() } #[instrument(level = "debug", skip(self))] pub async fn get_login_redirect_url( &self, redirect: Option, ) -> String { let state_prefix = random_string(STATE_PREFIX_LENGTH); let state = match redirect { Some(redirect) => format!("{state_prefix}{redirect}"), None => state_prefix, }; let redirect_url = format!( "https://github.com/login/oauth/authorize?state={state}&client_id={}&redirect_uri={}&scope={}", self.client_id, self.redirect_uri, self.scopes ); let mut states = self.states.lock().await; states.push(state); redirect_url } #[instrument(level = "debug", skip(self))] pub async fn check_state(&self, state: &str) -> bool { let mut contained = false; self.states.lock().await.retain(|s| { if s.as_str() == state { contained = true; false } else { true } }); contained } #[instrument(level = "debug", skip(self))] pub async fn get_access_token( &self, code: &str, ) -> anyhow::Result { self .post::<(), _>( "https://github.com/login/oauth/access_token", &[ ("client_id", self.client_id.as_str()), ("client_secret", self.client_secret.as_str()), ("redirect_uri", self.redirect_uri.as_str()), ("code", code), ], None, None, ) .await .context("failed to get github access token using code") } #[instrument(level = "debug", skip(self))] pub async fn get_github_user( &self, token: &str, ) -> anyhow::Result { self .get("https://api.github.com/user", &[], Some(token)) .await .context("failed to get github user using access token") } #[instrument(level = "debug", skip(self))] async fn get( &self, endpoint: &str, query: &[(&str, &str)], bearer_token: Option<&str>, ) -> anyhow::Result { let mut req = self .http .get(endpoint) .query(query) .header("User-Agent", &self.user_agent); if let Some(bearer_token) = bearer_token { req = req.header("Authorization", format!("Bearer {bearer_token}")); } let res = req.send().await.context("failed to reach github")?; let status = res.status(); if status == StatusCode::OK { let body = res .json() .await .context("failed to parse body into expected type")?; Ok(body) } else { let text = res.text().await.context(format!( "status: {status} | failed to get response text" ))?; Err(anyhow!("status: {status} | text: {text}")) } } async fn post( &self, endpoint: &str, query: &[(&str, &str)], body: Option<&B>, bearer_token: Option<&str>, ) -> anyhow::Result { let mut req = self .http .post(endpoint) .query(query) .header("Accept", "application/json") .header("User-Agent", &self.user_agent); if let Some(body) = body { req = req.json(body); } if let Some(bearer_token) = bearer_token { req = req.header("Authorization", format!("Bearer {bearer_token}")); } let res = req.send().await.context("failed to reach github")?; let status = res.status(); if status == StatusCode::OK { let body = res .json() .await .context("failed to parse POST body into expected type")?; Ok(body) } else { let text = res.text().await.with_context(|| format!( "method: POST | status: {status} | failed to get response text" ))?; Err(anyhow!("method: POST | status: {status} | text: {text}")) } } } #[derive(Deserialize)] pub struct AccessTokenResponse { pub access_token: String, // pub scope: String, // pub token_type: String, } #[derive(Deserialize)] pub struct GithubUserResponse { pub login: String, pub id: u128, pub avatar_url: String, // pub email: Option, } ================================================ FILE: bin/core/src/auth/github/mod.rs ================================================ use anyhow::{Context, anyhow}; use axum::{ Router, extract::Query, response::Redirect, routing::get, }; use database::mongo_indexed::Document; use database::mungos::mongodb::bson::doc; use komodo_client::entities::{ komodo_timestamp, user::{User, UserConfig}, }; use reqwest::StatusCode; use serde::Deserialize; use serror::AddStatusCode; use crate::{ config::core_config, helpers::random_string, state::{db_client, jwt_client}, }; use self::client::github_oauth_client; use super::{RedirectQuery, STATE_PREFIX_LENGTH}; pub mod client; pub fn router() -> Router { Router::new() .route( "/login", get(|Query(query): Query| async { Redirect::to( &github_oauth_client() .as_ref() // OK: the router is only mounted in case that the client is populated .unwrap() .get_login_redirect_url(query.redirect) .await, ) }), ) .route( "/callback", get(|query| async { callback(query).await.status_code(StatusCode::UNAUTHORIZED) }), ) } #[derive(Debug, Deserialize)] struct CallbackQuery { state: String, code: String, } #[instrument(name = "GithubCallback", level = "debug")] async fn callback( Query(query): Query, ) -> anyhow::Result { let client = github_oauth_client().as_ref().unwrap(); if !client.check_state(&query.state).await { return Err(anyhow!("state mismatch")); } let token = client.get_access_token(&query.code).await?; let github_user = client.get_github_user(&token.access_token).await?; let github_id = github_user.id.to_string(); let db_client = db_client(); let user = db_client .users .find_one(doc! { "config.data.github_id": &github_id }) .await .context("failed at find user query from database")?; let jwt = match user { Some(user) => jwt_client() .encode(user.id) .context("failed to generate jwt")?, None => { let ts = komodo_timestamp(); let no_users_exist = db_client.users.find_one(Document::new()).await?.is_none(); let core_config = core_config(); if !no_users_exist && core_config.disable_user_registration { return Err(anyhow!("User registration is disabled")); } let mut username = github_user.login; // Modify username if it already exists if db_client .users .find_one(doc! { "username": &username }) .await .context("Failed to query users collection")? .is_some() { username += "-"; username += &random_string(5); }; let user = User { id: Default::default(), username, enabled: no_users_exist || core_config.enable_new_users, admin: no_users_exist, super_admin: no_users_exist, create_server_permissions: no_users_exist, create_build_permissions: no_users_exist, updated_at: ts, last_update_view: 0, recents: Default::default(), all: Default::default(), config: UserConfig::Github { github_id, avatar: github_user.avatar_url, }, }; let user_id = db_client .users .insert_one(user) .await .context("failed to create user on mongo")? .inserted_id .as_object_id() .context("inserted_id is not ObjectId")? .to_string(); jwt_client() .encode(user_id) .context("failed to generate jwt")? } }; let exchange_token = jwt_client().create_exchange_token(jwt).await; let redirect = &query.state[STATE_PREFIX_LENGTH..]; let redirect_url = if redirect.is_empty() { format!("{}?token={exchange_token}", core_config().host) } else { let splitter = if redirect.contains('?') { '&' } else { '?' }; format!("{redirect}{splitter}token={exchange_token}") }; Ok(Redirect::to(&redirect_url)) } ================================================ FILE: bin/core/src/auth/google/client.rs ================================================ use std::sync::OnceLock; use anyhow::{Context, anyhow}; use jsonwebtoken::{DecodingKey, Validation, decode}; use komodo_client::entities::config::core::{ CoreConfig, OauthCredentials, }; use reqwest::StatusCode; use serde::{Deserialize, de::DeserializeOwned}; use tokio::sync::Mutex; use crate::{ auth::STATE_PREFIX_LENGTH, config::core_config, helpers::random_string, }; pub fn google_oauth_client() -> &'static Option { static GOOGLE_OAUTH_CLIENT: OnceLock> = OnceLock::new(); GOOGLE_OAUTH_CLIENT .get_or_init(|| GoogleOauthClient::new(core_config())) } pub struct GoogleOauthClient { http: reqwest::Client, client_id: String, client_secret: String, redirect_uri: String, scopes: String, states: Mutex>, user_agent: String, } impl GoogleOauthClient { pub fn new( CoreConfig { google_oauth: OauthCredentials { enabled, id, secret, }, host, .. }: &CoreConfig, ) -> Option { if !enabled { return None; } if host.is_empty() { warn!( "google oauth is enabled, but 'config.host' is not configured" ); return None; } if id.is_empty() { warn!( "google oauth is enabled, but 'config.google_oauth.id' is not configured" ); return None; } if secret.is_empty() { warn!( "google oauth is enabled, but 'config.google_oauth.secret' is not configured" ); return None; } let scopes = urlencoding::encode( &[ "https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email", ] .join(" "), ) .to_string(); GoogleOauthClient { http: Default::default(), client_id: id.clone(), client_secret: secret.clone(), redirect_uri: format!("{host}/auth/google/callback"), user_agent: String::from("komodo"), states: Default::default(), scopes, } .into() } #[instrument(level = "debug", skip(self))] pub async fn get_login_redirect_url( &self, redirect: Option, ) -> String { let state_prefix = random_string(STATE_PREFIX_LENGTH); let state = match redirect { Some(redirect) => format!("{state_prefix}{redirect}"), None => state_prefix, }; let redirect_url = format!( "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&state={state}&client_id={}&redirect_uri={}&scope={}", self.client_id, self.redirect_uri, self.scopes ); let mut states = self.states.lock().await; states.push(state); redirect_url } #[instrument(level = "debug", skip(self))] pub async fn check_state(&self, state: &str) -> bool { let mut contained = false; self.states.lock().await.retain(|s| { if s.as_str() == state { contained = true; false } else { true } }); contained } #[instrument(level = "debug", skip(self))] pub async fn get_access_token( &self, code: &str, ) -> anyhow::Result { self .post::<_>( "https://oauth2.googleapis.com/token", &[ ("client_id", self.client_id.as_str()), ("client_secret", self.client_secret.as_str()), ("redirect_uri", self.redirect_uri.as_str()), ("code", code), ("grant_type", "authorization_code"), ], None, ) .await .context("failed to get google access token using code") } #[instrument(level = "debug", skip(self))] pub fn get_google_user( &self, id_token: &str, ) -> anyhow::Result { let mut v = Validation::new(Default::default()); v.insecure_disable_signature_validation(); v.validate_aud = false; let res = decode::( id_token, &DecodingKey::from_secret(b""), &v, ) .context("failed to decode google id token")?; Ok(res.claims) } #[instrument(level = "debug", skip(self))] async fn post( &self, endpoint: &str, body: &[(&str, &str)], bearer_token: Option<&str>, ) -> anyhow::Result { let mut req = self .http .post(endpoint) .form(body) .header("Accept", "application/json") .header("User-Agent", &self.user_agent); if let Some(bearer_token) = bearer_token { req = req.header("Authorization", format!("Bearer {bearer_token}")); } let res = req.send().await.context("failed to reach google")?; let status = res.status(); if status == StatusCode::OK { let body = res .json() .await .context("failed to parse POST body into expected type")?; Ok(body) } else { let text = res.text().await.context(format!( "method: POST | status: {status} | failed to get response text" ))?; Err(anyhow!("method: POST | status: {status} | text: {text}")) } } } #[derive(Deserialize)] pub struct AccessTokenResponse { // pub access_token: String, pub id_token: String, // pub scope: String, // pub token_type: String, } #[derive(Deserialize, Clone)] pub struct GoogleUser { #[serde(rename = "sub")] pub id: String, pub email: String, #[serde(default)] pub picture: String, } ================================================ FILE: bin/core/src/auth/google/mod.rs ================================================ use anyhow::{Context, anyhow}; use async_timing_util::unix_timestamp_ms; use axum::{ Router, extract::Query, response::Redirect, routing::get, }; use database::mongo_indexed::Document; use database::mungos::mongodb::bson::doc; use komodo_client::entities::user::{User, UserConfig}; use reqwest::StatusCode; use serde::Deserialize; use serror::AddStatusCode; use crate::{ config::core_config, helpers::random_string, state::{db_client, jwt_client}, }; use self::client::google_oauth_client; use super::{RedirectQuery, STATE_PREFIX_LENGTH}; pub mod client; pub fn router() -> Router { Router::new() .route( "/login", get(|Query(query): Query| async move { Redirect::to( &google_oauth_client() .as_ref() // OK: its not mounted unless the client is populated .unwrap() .get_login_redirect_url(query.redirect) .await, ) }), ) .route( "/callback", get(|query| async { callback(query).await.status_code(StatusCode::UNAUTHORIZED) }), ) } #[derive(Debug, Deserialize)] struct CallbackQuery { state: Option, code: Option, error: Option, } #[instrument(name = "GoogleCallback", level = "debug")] async fn callback( Query(query): Query, ) -> anyhow::Result { // Safe: the method is only called after the client is_some let client = google_oauth_client().as_ref().unwrap(); if let Some(error) = query.error { return Err(anyhow!("auth error from google: {error}")); } let state = query .state .context("callback query does not contain state")?; if !client.check_state(&state).await { return Err(anyhow!("state mismatch")); } let token = client .get_access_token( &query.code.context("callback query does not contain code")?, ) .await?; let google_user = client.get_google_user(&token.id_token)?; let google_id = google_user.id.to_string(); let db_client = db_client(); let user = db_client .users .find_one(doc! { "config.data.google_id": &google_id }) .await .context("failed at find user query from mongo")?; let jwt = match user { Some(user) => jwt_client() .encode(user.id) .context("failed to generate jwt")?, None => { let ts = unix_timestamp_ms() as i64; let no_users_exist = db_client.users.find_one(Document::new()).await?.is_none(); let core_config = core_config(); if !no_users_exist && core_config.disable_user_registration { return Err(anyhow!("User registration is disabled")); } let mut username = google_user .email .split('@') .collect::>() .first() .unwrap() .to_string(); // Modify username if it already exists if db_client .users .find_one(doc! { "username": &username }) .await .context("Failed to query users collection")? .is_some() { username += "-"; username += &random_string(5); }; let user = User { id: Default::default(), username, enabled: no_users_exist || core_config.enable_new_users, admin: no_users_exist, super_admin: no_users_exist, create_server_permissions: no_users_exist, create_build_permissions: no_users_exist, updated_at: ts, last_update_view: 0, recents: Default::default(), all: Default::default(), config: UserConfig::Google { google_id, avatar: google_user.picture, }, }; let user_id = db_client .users .insert_one(user) .await .context("failed to create user on mongo")? .inserted_id .as_object_id() .context("inserted_id is not ObjectId")? .to_string(); jwt_client() .encode(user_id) .context("failed to generate jwt")? } }; let exchange_token = jwt_client().create_exchange_token(jwt).await; let redirect = &state[STATE_PREFIX_LENGTH..]; let redirect_url = if redirect.is_empty() { format!("{}?token={exchange_token}", core_config().host) } else { let splitter = if redirect.contains('?') { '&' } else { '?' }; format!("{redirect}{splitter}token={exchange_token}") }; Ok(Redirect::to(&redirect_url)) } ================================================ FILE: bin/core/src/auth/jwt.rs ================================================ use std::collections::HashMap; use anyhow::{Context, anyhow}; use async_timing_util::{ Timelength, get_timelength_in_ms, unix_timestamp_ms, }; use database::mungos::mongodb::bson::doc; use jsonwebtoken::{ DecodingKey, EncodingKey, Header, Validation, decode, encode, }; use komodo_client::{ api::auth::JwtResponse, entities::config::core::CoreConfig, }; use serde::{Deserialize, Serialize}; use tokio::sync::Mutex; use crate::helpers::random_string; type ExchangeTokenMap = Mutex>; #[derive(Serialize, Deserialize)] pub struct JwtClaims { pub id: String, pub iat: u128, pub exp: u128, } pub struct JwtClient { header: Header, validation: Validation, encoding_key: EncodingKey, decoding_key: DecodingKey, ttl_ms: u128, exchange_tokens: ExchangeTokenMap, } impl JwtClient { pub fn new(config: &CoreConfig) -> anyhow::Result { let secret = if config.jwt_secret.is_empty() { random_string(40) } else { config.jwt_secret.clone() }; Ok(JwtClient { header: Header::default(), validation: Validation::new(Default::default()), encoding_key: EncodingKey::from_secret(secret.as_bytes()), decoding_key: DecodingKey::from_secret(secret.as_bytes()), ttl_ms: get_timelength_in_ms( config.jwt_ttl.to_string().parse()?, ), exchange_tokens: Default::default(), }) } pub fn encode( &self, user_id: String, ) -> anyhow::Result { let iat = unix_timestamp_ms(); let exp = iat + self.ttl_ms; let claims = JwtClaims { id: user_id.clone(), iat, exp, }; let jwt = encode(&self.header, &claims, &self.encoding_key) .context("failed at signing claim")?; Ok(JwtResponse { user_id, jwt }) } pub fn decode(&self, jwt: &str) -> anyhow::Result { decode::(jwt, &self.decoding_key, &self.validation) .map(|res| res.claims) .context("failed to decode token claims") } #[instrument(level = "debug", skip_all)] pub async fn create_exchange_token( &self, jwt: JwtResponse, ) -> String { let exchange_token = random_string(40); self.exchange_tokens.lock().await.insert( exchange_token.clone(), ( jwt, unix_timestamp_ms() + get_timelength_in_ms(Timelength::OneMinute), ), ); exchange_token } #[instrument(level = "debug", skip(self))] pub async fn redeem_exchange_token( &self, exchange_token: &str, ) -> anyhow::Result { let (jwt, valid_until) = self .exchange_tokens .lock() .await .remove(exchange_token) .context("invalid exchange token: unrecognized")?; if unix_timestamp_ms() < valid_until { Ok(jwt) } else { Err(anyhow!("invalid exchange token: expired")) } } } ================================================ FILE: bin/core/src/auth/local.rs ================================================ use std::str::FromStr; use anyhow::{Context, anyhow}; use async_timing_util::unix_timestamp_ms; use database::{ hash_password, mungos::mongodb::bson::{Document, doc, oid::ObjectId}, }; use komodo_client::{ api::auth::{ LoginLocalUser, LoginLocalUserResponse, SignUpLocalUser, SignUpLocalUserResponse, }, entities::user::{User, UserConfig}, }; use resolver_api::Resolve; use crate::{ api::auth::AuthArgs, config::core_config, state::{db_client, jwt_client}, }; impl Resolve for SignUpLocalUser { #[instrument(name = "SignUpLocalUser", skip(self))] async fn resolve( self, _: &AuthArgs, ) -> serror::Result { let core_config = core_config(); if !core_config.local_auth { return Err(anyhow!("Local auth is not enabled").into()); } if self.username.is_empty() { return Err(anyhow!("Username cannot be empty string").into()); } if ObjectId::from_str(&self.username).is_ok() { return Err( anyhow!("Username cannot be valid ObjectId").into(), ); } if self.password.is_empty() { return Err(anyhow!("Password cannot be empty string").into()); } let db = db_client(); let no_users_exist = db.users.find_one(Document::new()).await?.is_none(); if !no_users_exist && core_config.disable_user_registration { return Err(anyhow!("User registration is disabled").into()); } if db .users .find_one(doc! { "username": &self.username }) .await .context("Failed to query for existing users")? .is_some() { return Err(anyhow!("Username already taken.").into()); } let ts = unix_timestamp_ms() as i64; let hashed_password = hash_password(self.password)?; let user = User { id: Default::default(), username: self.username, enabled: no_users_exist || core_config.enable_new_users, admin: no_users_exist, super_admin: no_users_exist, create_server_permissions: no_users_exist, create_build_permissions: no_users_exist, updated_at: ts, last_update_view: 0, recents: Default::default(), all: Default::default(), config: UserConfig::Local { password: hashed_password, }, }; let user_id = db_client() .users .insert_one(user) .await .context("failed to create user")? .inserted_id .as_object_id() .context("inserted_id is not ObjectId")? .to_string(); jwt_client() .encode(user_id.clone()) .context("failed to generate jwt for user") .map_err(Into::into) } } impl Resolve for LoginLocalUser { #[instrument(name = "LoginLocalUser", level = "debug", skip(self))] async fn resolve( self, _: &AuthArgs, ) -> serror::Result { if !core_config().local_auth { return Err(anyhow!("local auth is not enabled").into()); } let user = db_client() .users .find_one(doc! { "username": &self.username }) .await .context("failed at db query for users")? .with_context(|| { format!("did not find user with username {}", self.username) })?; let UserConfig::Local { password: user_pw_hash, } = user.config else { return Err( anyhow!( "non-local auth users can not log in with a password" ) .into(), ); }; let verified = bcrypt::verify(self.password, &user_pw_hash) .context("failed at verify password")?; if !verified { return Err(anyhow!("invalid credentials").into()); } jwt_client() .encode(user.id.clone()) .context("failed at generating jwt for user") .map_err(Into::into) } } ================================================ FILE: bin/core/src/auth/mod.rs ================================================ use anyhow::{Context, anyhow}; use async_timing_util::unix_timestamp_ms; use axum::{ extract::Request, http::HeaderMap, middleware::Next, response::Response, }; use database::mungos::mongodb::bson::doc; use komodo_client::entities::{komodo_timestamp, user::User}; use reqwest::StatusCode; use serde::Deserialize; use serror::AddStatusCode; use crate::{ helpers::query::get_user, state::{db_client, jwt_client}, }; use self::jwt::JwtClaims; pub mod github; pub mod google; pub mod jwt; pub mod oidc; mod local; const STATE_PREFIX_LENGTH: usize = 20; #[derive(Debug, Deserialize)] struct RedirectQuery { redirect: Option, } #[instrument(level = "debug")] pub async fn auth_request( headers: HeaderMap, mut req: Request, next: Next, ) -> serror::Result { let user = authenticate_check_enabled(&headers) .await .status_code(StatusCode::UNAUTHORIZED)?; req.extensions_mut().insert(user); Ok(next.run(req).await) } #[instrument(level = "debug")] pub async fn get_user_id_from_headers( headers: &HeaderMap, ) -> anyhow::Result { match ( headers.get("authorization"), headers.get("x-api-key"), headers.get("x-api-secret"), ) { (Some(jwt), _, _) => { // USE JWT let jwt = jwt.to_str().context("jwt is not str")?; auth_jwt_get_user_id(jwt) .await .context("failed to authenticate jwt") } (None, Some(key), Some(secret)) => { // USE API KEY / SECRET let key = key.to_str().context("key is not str")?; let secret = secret.to_str().context("secret is not str")?; auth_api_key_get_user_id(key, secret) .await .context("failed to authenticate api key") } _ => { // AUTH FAIL Err(anyhow!( "must attach either AUTHORIZATION header with jwt OR pass X-API-KEY and X-API-SECRET" )) } } } #[instrument(level = "debug")] pub async fn authenticate_check_enabled( headers: &HeaderMap, ) -> anyhow::Result { let user_id = get_user_id_from_headers(headers).await?; let user = get_user(&user_id).await?; if user.enabled { Ok(user) } else { Err(anyhow!("user not enabled")) } } #[instrument(level = "debug")] pub async fn auth_jwt_get_user_id( jwt: &str, ) -> anyhow::Result { let claims: JwtClaims = jwt_client().decode(jwt)?; if claims.exp > unix_timestamp_ms() { Ok(claims.id) } else { Err(anyhow!("token has expired")) } } #[instrument(level = "debug")] pub async fn auth_jwt_check_enabled( jwt: &str, ) -> anyhow::Result { let user_id = auth_jwt_get_user_id(jwt).await?; check_enabled(user_id).await } #[instrument(level = "debug")] pub async fn auth_api_key_get_user_id( key: &str, secret: &str, ) -> anyhow::Result { let key = db_client() .api_keys .find_one(doc! { "key": key }) .await .context("failed to query db")? .context("no api key matching key")?; if key.expires != 0 && key.expires < komodo_timestamp() { return Err(anyhow!("api key expired")); } if bcrypt::verify(secret, &key.secret) .context("failed to verify secret hash")? { // secret matches Ok(key.user_id) } else { // secret mismatch Err(anyhow!("invalid api secret")) } } #[instrument(level = "debug")] pub async fn auth_api_key_check_enabled( key: &str, secret: &str, ) -> anyhow::Result { let user_id = auth_api_key_get_user_id(key, secret).await?; check_enabled(user_id).await } #[instrument(level = "debug")] async fn check_enabled(user_id: String) -> anyhow::Result { let user = get_user(&user_id).await?; if user.enabled { Ok(user) } else { Err(anyhow!("user not enabled")) } } ================================================ FILE: bin/core/src/auth/oidc/client.rs ================================================ use std::{sync::OnceLock, time::Duration}; use anyhow::Context; use arc_swap::ArcSwapOption; use openidconnect::{ Client, ClientId, ClientSecret, EmptyAdditionalClaims, EndpointMaybeSet, EndpointNotSet, EndpointSet, IssuerUrl, RedirectUrl, StandardErrorResponse, core::*, }; use crate::config::core_config; type OidcClient = Client< EmptyAdditionalClaims, CoreAuthDisplay, CoreGenderClaim, CoreJweContentEncryptionAlgorithm, CoreJsonWebKey, CoreAuthPrompt, StandardErrorResponse, CoreTokenResponse, CoreTokenIntrospectionResponse, CoreRevocableToken, CoreRevocationErrorResponse, EndpointSet, EndpointNotSet, EndpointNotSet, EndpointNotSet, EndpointMaybeSet, EndpointMaybeSet, >; pub fn oidc_client() -> &'static ArcSwapOption { static OIDC_CLIENT: OnceLock> = OnceLock::new(); OIDC_CLIENT.get_or_init(Default::default) } /// The OIDC client must be reinitialized to /// pick up the latest provider JWKs. This /// function spawns a management thread to do this /// on a loop. pub async fn spawn_oidc_client_management() { let config = core_config(); if !config.oidc_enabled || config.oidc_provider.is_empty() || config.oidc_client_id.is_empty() { return; } if let Err(e) = reset_oidc_client().await { error!("Failed to initialize OIDC client | {e:#}"); } tokio::spawn(async move { loop { tokio::time::sleep(Duration::from_secs(60)).await; if let Err(e) = reset_oidc_client().await { warn!("Failed to reinitialize OIDC client | {e:#}"); } } }); } async fn reset_oidc_client() -> anyhow::Result<()> { let config = core_config(); // Use OpenID Connect Discovery to fetch the provider metadata. let provider_metadata = CoreProviderMetadata::discover_async( IssuerUrl::new(config.oidc_provider.clone())?, super::reqwest_client(), ) .await .context("Failed to get OIDC /.well-known/openid-configuration")?; let client = CoreClient::from_provider_metadata( provider_metadata, ClientId::new(config.oidc_client_id.to_string()), // The secret may be empty / ommitted if auth provider supports PKCE if config.oidc_client_secret.is_empty() { None } else { Some(ClientSecret::new(config.oidc_client_secret.to_string())) }, ) // Set the URL the user will be redirected to after the authorization process. .set_redirect_uri(RedirectUrl::new(format!( "{}/auth/oidc/callback", core_config().host ))?); oidc_client().store(Some(client.into())); Ok(()) } ================================================ FILE: bin/core/src/auth/oidc/mod.rs ================================================ use std::sync::OnceLock; use anyhow::{Context, anyhow}; use axum::{ Router, extract::Query, response::Redirect, routing::get, }; use client::oidc_client; use dashmap::DashMap; use database::mungos::mongodb::bson::{Document, doc}; use komodo_client::entities::{ komodo_timestamp, user::{User, UserConfig}, }; use openidconnect::{ AccessTokenHash, AuthorizationCode, CsrfToken, EmptyAdditionalClaims, Nonce, OAuth2TokenResponse, PkceCodeChallenge, PkceCodeVerifier, Scope, TokenResponse, core::{CoreAuthenticationFlow, CoreGenderClaim}, }; use reqwest::StatusCode; use serde::Deserialize; use serror::AddStatusCode; use crate::{ config::core_config, helpers::random_string, state::{db_client, jwt_client}, }; use super::RedirectQuery; pub mod client; static APP_USER_AGENT: &str = concat!("Komodo/", env!("CARGO_PKG_VERSION"),); fn reqwest_client() -> &'static reqwest::Client { static REQWEST: OnceLock = OnceLock::new(); REQWEST.get_or_init(|| { reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .user_agent(APP_USER_AGENT) .build() .expect("Invalid OIDC reqwest client") }) } /// CSRF tokens can only be used once from the callback, /// and must be used within this timeframe const CSRF_VALID_FOR_MS: i64 = 120_000; // 2 minutes for user to log in. type RedirectUrl = Option; /// Maps the csrf secrets to other information added in the "login" method (before auth provider redirect). /// This information is retrieved in the "callback" method (after auth provider redirect). type VerifierMap = DashMap; fn verifier_tokens() -> &'static VerifierMap { static VERIFIERS: OnceLock = OnceLock::new(); VERIFIERS.get_or_init(Default::default) } pub fn router() -> Router { Router::new() .route( "/login", get(|query| async { login(query).await.status_code(StatusCode::UNAUTHORIZED) }), ) .route( "/callback", get(|query| async { callback(query).await.status_code(StatusCode::UNAUTHORIZED) }), ) } #[instrument(name = "OidcRedirect", level = "debug")] async fn login( Query(RedirectQuery { redirect }): Query, ) -> anyhow::Result { let client = oidc_client().load(); let client = client.as_ref().context("OIDC Client not configured")?; let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); // Generate the authorization URL. let (auth_url, csrf_token, nonce) = client .authorize_url( CoreAuthenticationFlow::AuthorizationCode, CsrfToken::new_random, Nonce::new_random, ) .set_pkce_challenge(pkce_challenge) .add_scope(Scope::new("openid".to_string())) .add_scope(Scope::new("profile".to_string())) .add_scope(Scope::new("email".to_string())) .url(); // Data inserted here will be matched on callback side for csrf protection. verifier_tokens().insert( csrf_token.secret().clone(), ( pkce_verifier, nonce, redirect, komodo_timestamp() + CSRF_VALID_FOR_MS, ), ); let config = core_config(); let redirect = if !config.oidc_redirect_host.is_empty() { let auth_url = auth_url.as_str(); let (protocol, rest) = auth_url .split_once("://") .context("Invalid URL: Missing protocol (eg 'https://')")?; let host = rest .split_once(['/', '?']) .map(|(host, _)| host) .unwrap_or(rest); Redirect::to(&auth_url.replace( &format!("{protocol}://{host}"), &config.oidc_redirect_host, )) } else { Redirect::to(auth_url.as_str()) }; Ok(redirect) } #[derive(Debug, Deserialize)] struct CallbackQuery { state: Option, code: Option, error: Option, } #[instrument(name = "OidcCallback", level = "debug")] async fn callback( Query(query): Query, ) -> anyhow::Result { let client = oidc_client().load(); let client = client.as_ref().context("OIDC Client not initialized successfully. Is the provider properly configured?")?; if let Some(e) = query.error { return Err(anyhow!("Provider returned error: {e}")); } let code = query.code.context("Provider did not return code")?; let state = CsrfToken::new( query.state.context("Provider did not return state")?, ); let (_, (pkce_verifier, nonce, redirect, valid_until)) = verifier_tokens() .remove(state.secret()) .context("CSRF token invalid")?; if komodo_timestamp() > valid_until { return Err(anyhow!( "CSRF token invalid (Timed out). The token must be used within 2 minutes." )); } let reqwest_client = reqwest_client(); let token_response = client .exchange_code(AuthorizationCode::new(code)) .context("Failed to get Oauth token at exchange code")? .set_pkce_verifier(pkce_verifier) .request_async(reqwest_client) .await .context("Failed to get Oauth token")?; // Extract the ID token claims after verifying its authenticity and nonce. let id_token = token_response .id_token() .context("OIDC Server did not return an ID token")?; // Some providers attach additional audiences, they must be added here // so token verification succeeds. let verifier = client.id_token_verifier(); let additional_audiences = &core_config().oidc_additional_audiences; let verifier = if additional_audiences.is_empty() { verifier } else { verifier.set_other_audience_verifier_fn(|aud| { additional_audiences.contains(aud) }) }; let claims = id_token .claims(&verifier, &nonce) .context("Failed to verify token claims. This issue may be temporary (60 seconds max).")?; // Verify the access token hash to ensure that the access token hasn't been substituted for // another user's. if let Some(expected_access_token_hash) = claims.access_token_hash() { let actual_access_token_hash = AccessTokenHash::from_token( token_response.access_token(), id_token.signing_alg()?, id_token.signing_key(&verifier)?, )?; if actual_access_token_hash != *expected_access_token_hash { return Err(anyhow!("Invalid access token")); } } let user_id = claims.subject().as_str(); let db_client = db_client(); let user = db_client .users .find_one(doc! { "config.data.provider": &core_config().oidc_provider, "config.data.user_id": user_id }) .await .context("failed at find user query from database")?; let jwt = match user { Some(user) => jwt_client() .encode(user.id) .context("failed to generate jwt")?, None => { let ts = komodo_timestamp(); let no_users_exist = db_client.users.find_one(Document::new()).await?.is_none(); let core_config = core_config(); if !no_users_exist && core_config.disable_user_registration { return Err(anyhow!("User registration is disabled")); } // Fetch user info let user_info = client .user_info( token_response.access_token().clone(), claims.subject().clone().into(), ) .context("Invalid user info request")? .request_async::( reqwest_client, ) .await .context("Failed to fetch user info for new user")?; // Will use preferred_username, then email, then user_id if it isn't available. let mut username = user_info .preferred_username() .map(|username| username.to_string()) .unwrap_or_else(|| { let email = user_info .email() .map(|email| email.as_str()) .unwrap_or(user_id); if core_config.oidc_use_full_email { email } else { email .split_once('@') .map(|(username, _)| username) .unwrap_or(email) } .to_string() }); // Modify username if it already exists if db_client .users .find_one(doc! { "username": &username }) .await .context("Failed to query users collection")? .is_some() { username += "-"; username += &random_string(5); }; let user = User { id: Default::default(), username, enabled: no_users_exist || core_config.enable_new_users, admin: no_users_exist, super_admin: no_users_exist, create_server_permissions: no_users_exist, create_build_permissions: no_users_exist, updated_at: ts, last_update_view: 0, recents: Default::default(), all: Default::default(), config: UserConfig::Oidc { provider: core_config.oidc_provider.clone(), user_id: user_id.to_string(), }, }; let user_id = db_client .users .insert_one(user) .await .context("failed to create user on database")? .inserted_id .as_object_id() .context("inserted_id is not ObjectId")? .to_string(); jwt_client() .encode(user_id) .context("failed to generate jwt")? } }; let exchange_token = jwt_client().create_exchange_token(jwt).await; let redirect_url = if let Some(redirect) = redirect { let splitter = if redirect.contains('?') { '&' } else { '?' }; format!("{redirect}{splitter}token={exchange_token}") } else { format!("{}?token={exchange_token}", core_config().host) }; Ok(Redirect::to(&redirect_url)) } ================================================ FILE: bin/core/src/cloud/aws/ec2.rs ================================================ use std::time::Duration; use anyhow::{Context, anyhow}; use aws_config::{BehaviorVersion, Region}; use aws_sdk_ec2::{ Client, types::{ BlockDeviceMapping, EbsBlockDevice, InstanceNetworkInterfaceSpecification, InstanceStateChange, InstanceStateName, InstanceStatus, InstanceType, ResourceType, Tag, TagSpecification, }, }; use base64::Engine; use komodo_client::entities::{ ResourceTarget, alert::{Alert, AlertData, SeverityLevel}, builder::AwsBuilderConfig, komodo_timestamp, }; use crate::{alert::send_alerts, config::core_config}; const POLL_RATE_SECS: u64 = 2; const MAX_POLL_TRIES: usize = 30; pub struct Ec2Instance { pub instance_id: String, pub ip: String, } /// Provides credentials in the core config file to the AWS client #[derive(Debug)] struct CredentialsFromConfig; impl aws_credential_types::provider::ProvideCredentials for CredentialsFromConfig { fn provide_credentials<'a>( &'a self, ) -> aws_credential_types::provider::future::ProvideCredentials<'a> where Self: 'a, { aws_credential_types::provider::future::ProvideCredentials::new( async { let config = core_config(); Ok(aws_credential_types::Credentials::new( &config.aws.access_key_id, &config.aws.secret_access_key, None, None, "komodo-config", )) }, ) } } #[instrument] async fn create_ec2_client(region: String) -> Client { let region = Region::new(region); let config = aws_config::defaults(BehaviorVersion::latest()) .region(region) .credentials_provider(CredentialsFromConfig) .load() .await; Client::new(&config) } #[instrument] pub async fn launch_ec2_instance( name: &str, config: &AwsBuilderConfig, ) -> anyhow::Result { let AwsBuilderConfig { region, instance_type, volume_gb, ami_id, subnet_id, security_group_ids, key_pair_name, assign_public_ip, use_public_ip, user_data, port: _, use_https: _, git_providers: _, docker_registries: _, secrets: _, } = config; let instance_type = handle_unknown_instance_type( InstanceType::from(instance_type.as_str()), )?; let client = create_ec2_client(region.clone()).await; let req = client .run_instances() .image_id(ami_id) .instance_type(instance_type) .network_interfaces( InstanceNetworkInterfaceSpecification::builder() .subnet_id(subnet_id) .associate_public_ip_address(*assign_public_ip) .set_groups(security_group_ids.to_vec().into()) .device_index(0) .build(), ) .key_name(key_pair_name) .tag_specifications( TagSpecification::builder() .tags(Tag::builder().key("Name").value(name).build()) .resource_type(ResourceType::Instance) .build(), ) .block_device_mappings( BlockDeviceMapping::builder() .set_device_name("/dev/sda1".to_string().into()) .set_ebs( EbsBlockDevice::builder() .volume_size(*volume_gb) .build() .into(), ) .build(), ) .min_count(1) .max_count(1) .user_data( base64::engine::general_purpose::STANDARD_NO_PAD .encode(user_data), ); let res = req .send() .await .context("failed to start builder ec2 instance")?; let instance = res .instances() .first() .context("instances array is empty")?; let instance_id = instance .instance_id() .context("instance does not have instance_id")? .to_string(); for _ in 0..MAX_POLL_TRIES { let state_name = get_ec2_instance_state_name(&client, &instance_id).await?; if state_name == Some(InstanceStateName::Running) { let ip = if *use_public_ip { get_ec2_instance_public_ip(&client, &instance_id).await? } else { instance .private_ip_address() .ok_or(anyhow!("instance does not have private ip"))? .to_string() }; return Ok(Ec2Instance { instance_id, ip }); } tokio::time::sleep(Duration::from_secs(POLL_RATE_SECS)).await; } Err(anyhow!("instance not running after polling")) } const MAX_TERMINATION_TRIES: usize = 5; const TERMINATION_WAIT_SECS: u64 = 15; #[instrument] pub async fn terminate_ec2_instance_with_retry( region: String, instance_id: &str, ) -> anyhow::Result { let client = create_ec2_client(region).await; for i in 0..MAX_TERMINATION_TRIES { match terminate_ec2_instance_inner(&client, instance_id).await { Ok(res) => { info!("instance {instance_id} successfully terminated."); return Ok(res); } Err(e) => { if i == MAX_TERMINATION_TRIES - 1 { error!("failed to terminate aws instance {instance_id}."); let alert = Alert { id: Default::default(), ts: komodo_timestamp(), resolved: false, level: SeverityLevel::Critical, target: ResourceTarget::system(), data: AlertData::AwsBuilderTerminationFailed { instance_id: instance_id.to_string(), message: format!("{e:#}"), }, resolved_ts: None, }; send_alerts(&[alert]).await; return Err(e); } tokio::time::sleep(Duration::from_secs( TERMINATION_WAIT_SECS, )) .await; } } } unreachable!() } #[instrument(skip(client))] async fn terminate_ec2_instance_inner( client: &Client, instance_id: &str, ) -> anyhow::Result { let res = client .terminate_instances() .instance_ids(instance_id) .send() .await .context("failed to terminate instance from aws")? .terminating_instances() .first() .context("terminating instances is empty")? .to_owned(); Ok(res) } /// Automatically retries 5 times, waiting 2 sec in between #[instrument(level = "debug")] async fn get_ec2_instance_status( client: &Client, instance_id: &str, ) -> anyhow::Result> { let mut try_count = 1; loop { match async { anyhow::Ok( client .describe_instance_status() .instance_ids(instance_id) .send() .await .context("failed to describe instance status from aws")? .instance_statuses() .first() .cloned(), ) } .await { Ok(res) => return Ok(res), Err(e) if try_count > 4 => return Err(e), Err(_) => { tokio::time::sleep(Duration::from_secs(2)).await; try_count += 1; } } } } #[instrument(level = "debug")] async fn get_ec2_instance_state_name( client: &Client, instance_id: &str, ) -> anyhow::Result> { let status = get_ec2_instance_status(client, instance_id).await?; if status.is_none() { return Ok(None); } let state = status .unwrap() .instance_state() .ok_or(anyhow!("instance state is None"))? .name() .ok_or(anyhow!("instance state name is None"))? .to_owned(); Ok(Some(state)) } /// Automatically retries 5 times, waiting 2 sec in between #[instrument(level = "debug")] async fn get_ec2_instance_public_ip( client: &Client, instance_id: &str, ) -> anyhow::Result { let mut try_count = 1; loop { match async { anyhow::Ok( client .describe_instances() .instance_ids(instance_id) .send() .await .context("failed to describe instances from aws")? .reservations() .first() .context("instance reservations is empty")? .instances() .first() .context("instances is empty")? .public_ip_address() .context("instance has no public ip")? .to_string(), ) } .await { Ok(res) => return Ok(res), Err(e) if try_count > 4 => return Err(e), Err(_) => { tokio::time::sleep(Duration::from_secs(2)).await; try_count += 1; } } } } fn handle_unknown_instance_type( instance_type: InstanceType, ) -> anyhow::Result { match instance_type { InstanceType::A12xlarge | InstanceType::A14xlarge | InstanceType::A1Large | InstanceType::A1Medium | InstanceType::A1Metal | InstanceType::A1Xlarge | InstanceType::C1Medium | InstanceType::C1Xlarge | InstanceType::C32xlarge | InstanceType::C34xlarge | InstanceType::C38xlarge | InstanceType::C3Large | InstanceType::C3Xlarge | InstanceType::C42xlarge | InstanceType::C44xlarge | InstanceType::C48xlarge | InstanceType::C4Large | InstanceType::C4Xlarge | InstanceType::C512xlarge | InstanceType::C518xlarge | InstanceType::C524xlarge | InstanceType::C52xlarge | InstanceType::C54xlarge | InstanceType::C59xlarge | InstanceType::C5Large | InstanceType::C5Metal | InstanceType::C5Xlarge | InstanceType::C5a12xlarge | InstanceType::C5a16xlarge | InstanceType::C5a24xlarge | InstanceType::C5a2xlarge | InstanceType::C5a4xlarge | InstanceType::C5a8xlarge | InstanceType::C5aLarge | InstanceType::C5aXlarge | InstanceType::C5ad12xlarge | InstanceType::C5ad16xlarge | InstanceType::C5ad24xlarge | InstanceType::C5ad2xlarge | InstanceType::C5ad4xlarge | InstanceType::C5ad8xlarge | InstanceType::C5adLarge | InstanceType::C5adXlarge | InstanceType::C5d12xlarge | InstanceType::C5d18xlarge | InstanceType::C5d24xlarge | InstanceType::C5d2xlarge | InstanceType::C5d4xlarge | InstanceType::C5d9xlarge | InstanceType::C5dLarge | InstanceType::C5dMetal | InstanceType::C5dXlarge | InstanceType::C5n18xlarge | InstanceType::C5n2xlarge | InstanceType::C5n4xlarge | InstanceType::C5n9xlarge | InstanceType::C5nLarge | InstanceType::C5nMetal | InstanceType::C5nXlarge | InstanceType::C6a12xlarge | InstanceType::C6a16xlarge | InstanceType::C6a24xlarge | InstanceType::C6a2xlarge | InstanceType::C6a32xlarge | InstanceType::C6a48xlarge | InstanceType::C6a4xlarge | InstanceType::C6a8xlarge | InstanceType::C6aLarge | InstanceType::C6aMetal | InstanceType::C6aXlarge | InstanceType::C6g12xlarge | InstanceType::C6g16xlarge | InstanceType::C6g2xlarge | InstanceType::C6g4xlarge | InstanceType::C6g8xlarge | InstanceType::C6gLarge | InstanceType::C6gMedium | InstanceType::C6gMetal | InstanceType::C6gXlarge | InstanceType::C6gd12xlarge | InstanceType::C6gd16xlarge | InstanceType::C6gd2xlarge | InstanceType::C6gd4xlarge | InstanceType::C6gd8xlarge | InstanceType::C6gdLarge | InstanceType::C6gdMedium | InstanceType::C6gdMetal | InstanceType::C6gdXlarge | InstanceType::C6gn12xlarge | InstanceType::C6gn16xlarge | InstanceType::C6gn2xlarge | InstanceType::C6gn4xlarge | InstanceType::C6gn8xlarge | InstanceType::C6gnLarge | InstanceType::C6gnMedium | InstanceType::C6gnXlarge | InstanceType::C6i12xlarge | InstanceType::C6i16xlarge | InstanceType::C6i24xlarge | InstanceType::C6i2xlarge | InstanceType::C6i32xlarge | InstanceType::C6i4xlarge | InstanceType::C6i8xlarge | InstanceType::C6iLarge | InstanceType::C6iMetal | InstanceType::C6iXlarge | InstanceType::C6id12xlarge | InstanceType::C6id16xlarge | InstanceType::C6id24xlarge | InstanceType::C6id2xlarge | InstanceType::C6id32xlarge | InstanceType::C6id4xlarge | InstanceType::C6id8xlarge | InstanceType::C6idLarge | InstanceType::C6idMetal | InstanceType::C6idXlarge | InstanceType::C6in12xlarge | InstanceType::C6in16xlarge | InstanceType::C6in24xlarge | InstanceType::C6in2xlarge | InstanceType::C6in32xlarge | InstanceType::C6in4xlarge | InstanceType::C6in8xlarge | InstanceType::C6inLarge | InstanceType::C6inMetal | InstanceType::C6inXlarge | InstanceType::C7a12xlarge | InstanceType::C7a16xlarge | InstanceType::C7a24xlarge | InstanceType::C7a2xlarge | InstanceType::C7a32xlarge | InstanceType::C7a48xlarge | InstanceType::C7a4xlarge | InstanceType::C7a8xlarge | InstanceType::C7aLarge | InstanceType::C7aMedium | InstanceType::C7aMetal48xl | InstanceType::C7aXlarge | InstanceType::C7g12xlarge | InstanceType::C7g16xlarge | InstanceType::C7g2xlarge | InstanceType::C7g4xlarge | InstanceType::C7g8xlarge | InstanceType::C7gLarge | InstanceType::C7gMedium | InstanceType::C7gMetal | InstanceType::C7gXlarge | InstanceType::C7gd12xlarge | InstanceType::C7gd16xlarge | InstanceType::C7gd2xlarge | InstanceType::C7gd4xlarge | InstanceType::C7gd8xlarge | InstanceType::C7gdLarge | InstanceType::C7gdMedium | InstanceType::C7gdXlarge | InstanceType::C7gn12xlarge | InstanceType::C7gn16xlarge | InstanceType::C7gn2xlarge | InstanceType::C7gn4xlarge | InstanceType::C7gn8xlarge | InstanceType::C7gnLarge | InstanceType::C7gnMedium | InstanceType::C7gnXlarge | InstanceType::C7i12xlarge | InstanceType::C7i16xlarge | InstanceType::C7i24xlarge | InstanceType::C7i2xlarge | InstanceType::C7i48xlarge | InstanceType::C7i4xlarge | InstanceType::C7i8xlarge | InstanceType::C7iLarge | InstanceType::C7iMetal24xl | InstanceType::C7iMetal48xl | InstanceType::C7iXlarge | InstanceType::Cc14xlarge | InstanceType::Cc28xlarge | InstanceType::Cg14xlarge | InstanceType::Cr18xlarge | InstanceType::D22xlarge | InstanceType::D24xlarge | InstanceType::D28xlarge | InstanceType::D2Xlarge | InstanceType::D32xlarge | InstanceType::D34xlarge | InstanceType::D38xlarge | InstanceType::D3Xlarge | InstanceType::D3en12xlarge | InstanceType::D3en2xlarge | InstanceType::D3en4xlarge | InstanceType::D3en6xlarge | InstanceType::D3en8xlarge | InstanceType::D3enXlarge | InstanceType::Dl124xlarge | InstanceType::Dl2q24xlarge | InstanceType::F116xlarge | InstanceType::F12xlarge | InstanceType::F14xlarge | InstanceType::G22xlarge | InstanceType::G28xlarge | InstanceType::G316xlarge | InstanceType::G34xlarge | InstanceType::G38xlarge | InstanceType::G3sXlarge | InstanceType::G4ad16xlarge | InstanceType::G4ad2xlarge | InstanceType::G4ad4xlarge | InstanceType::G4ad8xlarge | InstanceType::G4adXlarge | InstanceType::G4dn12xlarge | InstanceType::G4dn16xlarge | InstanceType::G4dn2xlarge | InstanceType::G4dn4xlarge | InstanceType::G4dn8xlarge | InstanceType::G4dnMetal | InstanceType::G4dnXlarge | InstanceType::G512xlarge | InstanceType::G516xlarge | InstanceType::G524xlarge | InstanceType::G52xlarge | InstanceType::G548xlarge | InstanceType::G54xlarge | InstanceType::G58xlarge | InstanceType::G5Xlarge | InstanceType::G5g16xlarge | InstanceType::G5g2xlarge | InstanceType::G5g4xlarge | InstanceType::G5g8xlarge | InstanceType::G5gMetal | InstanceType::G5gXlarge | InstanceType::H116xlarge | InstanceType::H12xlarge | InstanceType::H14xlarge | InstanceType::H18xlarge | InstanceType::Hi14xlarge | InstanceType::Hpc6a48xlarge | InstanceType::Hpc6id32xlarge | InstanceType::Hpc7a12xlarge | InstanceType::Hpc7a24xlarge | InstanceType::Hpc7a48xlarge | InstanceType::Hpc7a96xlarge | InstanceType::Hpc7g16xlarge | InstanceType::Hpc7g4xlarge | InstanceType::Hpc7g8xlarge | InstanceType::Hs18xlarge | InstanceType::I22xlarge | InstanceType::I24xlarge | InstanceType::I28xlarge | InstanceType::I2Xlarge | InstanceType::I316xlarge | InstanceType::I32xlarge | InstanceType::I34xlarge | InstanceType::I38xlarge | InstanceType::I3Large | InstanceType::I3Metal | InstanceType::I3Xlarge | InstanceType::I3en12xlarge | InstanceType::I3en24xlarge | InstanceType::I3en2xlarge | InstanceType::I3en3xlarge | InstanceType::I3en6xlarge | InstanceType::I3enLarge | InstanceType::I3enMetal | InstanceType::I3enXlarge | InstanceType::I4g16xlarge | InstanceType::I4g2xlarge | InstanceType::I4g4xlarge | InstanceType::I4g8xlarge | InstanceType::I4gLarge | InstanceType::I4gXlarge | InstanceType::I4i12xlarge | InstanceType::I4i16xlarge | InstanceType::I4i24xlarge | InstanceType::I4i2xlarge | InstanceType::I4i32xlarge | InstanceType::I4i4xlarge | InstanceType::I4i8xlarge | InstanceType::I4iLarge | InstanceType::I4iMetal | InstanceType::I4iXlarge | InstanceType::Im4gn16xlarge | InstanceType::Im4gn2xlarge | InstanceType::Im4gn4xlarge | InstanceType::Im4gn8xlarge | InstanceType::Im4gnLarge | InstanceType::Im4gnXlarge | InstanceType::Inf124xlarge | InstanceType::Inf12xlarge | InstanceType::Inf16xlarge | InstanceType::Inf1Xlarge | InstanceType::Inf224xlarge | InstanceType::Inf248xlarge | InstanceType::Inf28xlarge | InstanceType::Inf2Xlarge | InstanceType::Is4gen2xlarge | InstanceType::Is4gen4xlarge | InstanceType::Is4gen8xlarge | InstanceType::Is4genLarge | InstanceType::Is4genMedium | InstanceType::Is4genXlarge | InstanceType::M1Large | InstanceType::M1Medium | InstanceType::M1Small | InstanceType::M1Xlarge | InstanceType::M22xlarge | InstanceType::M24xlarge | InstanceType::M2Xlarge | InstanceType::M32xlarge | InstanceType::M3Large | InstanceType::M3Medium | InstanceType::M3Xlarge | InstanceType::M410xlarge | InstanceType::M416xlarge | InstanceType::M42xlarge | InstanceType::M44xlarge | InstanceType::M4Large | InstanceType::M4Xlarge | InstanceType::M512xlarge | InstanceType::M516xlarge | InstanceType::M524xlarge | InstanceType::M52xlarge | InstanceType::M54xlarge | InstanceType::M58xlarge | InstanceType::M5Large | InstanceType::M5Metal | InstanceType::M5Xlarge | InstanceType::M5a12xlarge | InstanceType::M5a16xlarge | InstanceType::M5a24xlarge | InstanceType::M5a2xlarge | InstanceType::M5a4xlarge | InstanceType::M5a8xlarge | InstanceType::M5aLarge | InstanceType::M5aXlarge | InstanceType::M5ad12xlarge | InstanceType::M5ad16xlarge | InstanceType::M5ad24xlarge | InstanceType::M5ad2xlarge | InstanceType::M5ad4xlarge | InstanceType::M5ad8xlarge | InstanceType::M5adLarge | InstanceType::M5adXlarge | InstanceType::M5d12xlarge | InstanceType::M5d16xlarge | InstanceType::M5d24xlarge | InstanceType::M5d2xlarge | InstanceType::M5d4xlarge | InstanceType::M5d8xlarge | InstanceType::M5dLarge | InstanceType::M5dMetal | InstanceType::M5dXlarge | InstanceType::M5dn12xlarge | InstanceType::M5dn16xlarge | InstanceType::M5dn24xlarge | InstanceType::M5dn2xlarge | InstanceType::M5dn4xlarge | InstanceType::M5dn8xlarge | InstanceType::M5dnLarge | InstanceType::M5dnMetal | InstanceType::M5dnXlarge | InstanceType::M5n12xlarge | InstanceType::M5n16xlarge | InstanceType::M5n24xlarge | InstanceType::M5n2xlarge | InstanceType::M5n4xlarge | InstanceType::M5n8xlarge | InstanceType::M5nLarge | InstanceType::M5nMetal | InstanceType::M5nXlarge | InstanceType::M5zn12xlarge | InstanceType::M5zn2xlarge | InstanceType::M5zn3xlarge | InstanceType::M5zn6xlarge | InstanceType::M5znLarge | InstanceType::M5znMetal | InstanceType::M5znXlarge | InstanceType::M6a12xlarge | InstanceType::M6a16xlarge | InstanceType::M6a24xlarge | InstanceType::M6a2xlarge | InstanceType::M6a32xlarge | InstanceType::M6a48xlarge | InstanceType::M6a4xlarge | InstanceType::M6a8xlarge | InstanceType::M6aLarge | InstanceType::M6aMetal | InstanceType::M6aXlarge | InstanceType::M6g12xlarge | InstanceType::M6g16xlarge | InstanceType::M6g2xlarge | InstanceType::M6g4xlarge | InstanceType::M6g8xlarge | InstanceType::M6gLarge | InstanceType::M6gMedium | InstanceType::M6gMetal | InstanceType::M6gXlarge | InstanceType::M6gd12xlarge | InstanceType::M6gd16xlarge | InstanceType::M6gd2xlarge | InstanceType::M6gd4xlarge | InstanceType::M6gd8xlarge | InstanceType::M6gdLarge | InstanceType::M6gdMedium | InstanceType::M6gdMetal | InstanceType::M6gdXlarge | InstanceType::M6i12xlarge | InstanceType::M6i16xlarge | InstanceType::M6i24xlarge | InstanceType::M6i2xlarge | InstanceType::M6i32xlarge | InstanceType::M6i4xlarge | InstanceType::M6i8xlarge | InstanceType::M6iLarge | InstanceType::M6iMetal | InstanceType::M6iXlarge | InstanceType::M6id12xlarge | InstanceType::M6id16xlarge | InstanceType::M6id24xlarge | InstanceType::M6id2xlarge | InstanceType::M6id32xlarge | InstanceType::M6id4xlarge | InstanceType::M6id8xlarge | InstanceType::M6idLarge | InstanceType::M6idMetal | InstanceType::M6idXlarge | InstanceType::M6idn12xlarge | InstanceType::M6idn16xlarge | InstanceType::M6idn24xlarge | InstanceType::M6idn2xlarge | InstanceType::M6idn32xlarge | InstanceType::M6idn4xlarge | InstanceType::M6idn8xlarge | InstanceType::M6idnLarge | InstanceType::M6idnMetal | InstanceType::M6idnXlarge | InstanceType::M6in12xlarge | InstanceType::M6in16xlarge | InstanceType::M6in24xlarge | InstanceType::M6in2xlarge | InstanceType::M6in32xlarge | InstanceType::M6in4xlarge | InstanceType::M6in8xlarge | InstanceType::M6inLarge | InstanceType::M6inMetal | InstanceType::M6inXlarge | InstanceType::M7a12xlarge | InstanceType::M7a16xlarge | InstanceType::M7a24xlarge | InstanceType::M7a2xlarge | InstanceType::M7a32xlarge | InstanceType::M7a48xlarge | InstanceType::M7a4xlarge | InstanceType::M7a8xlarge | InstanceType::M7aLarge | InstanceType::M7aMedium | InstanceType::M7aMetal48xl | InstanceType::M7aXlarge | InstanceType::M7g12xlarge | InstanceType::M7g16xlarge | InstanceType::M7g2xlarge | InstanceType::M7g4xlarge | InstanceType::M7g8xlarge | InstanceType::M7gLarge | InstanceType::M7gMedium | InstanceType::M7gMetal | InstanceType::M7gXlarge | InstanceType::M7gd12xlarge | InstanceType::M7gd16xlarge | InstanceType::M7gd2xlarge | InstanceType::M7gd4xlarge | InstanceType::M7gd8xlarge | InstanceType::M7gdLarge | InstanceType::M7gdMedium | InstanceType::M7gdXlarge | InstanceType::M7iFlex2xlarge | InstanceType::M7iFlex4xlarge | InstanceType::M7iFlex8xlarge | InstanceType::M7iFlexLarge | InstanceType::M7iFlexXlarge | InstanceType::M7i12xlarge | InstanceType::M7i16xlarge | InstanceType::M7i24xlarge | InstanceType::M7i2xlarge | InstanceType::M7i48xlarge | InstanceType::M7i4xlarge | InstanceType::M7i8xlarge | InstanceType::M7iLarge | InstanceType::M7iMetal24xl | InstanceType::M7iMetal48xl | InstanceType::M7iXlarge | InstanceType::Mac1Metal | InstanceType::Mac2M2Metal | InstanceType::Mac2M2proMetal | InstanceType::Mac2Metal | InstanceType::P216xlarge | InstanceType::P28xlarge | InstanceType::P2Xlarge | InstanceType::P316xlarge | InstanceType::P32xlarge | InstanceType::P38xlarge | InstanceType::P3dn24xlarge | InstanceType::P4d24xlarge | InstanceType::P4de24xlarge | InstanceType::P548xlarge | InstanceType::R32xlarge | InstanceType::R34xlarge | InstanceType::R38xlarge | InstanceType::R3Large | InstanceType::R3Xlarge | InstanceType::R416xlarge | InstanceType::R42xlarge | InstanceType::R44xlarge | InstanceType::R48xlarge | InstanceType::R4Large | InstanceType::R4Xlarge | InstanceType::R512xlarge | InstanceType::R516xlarge | InstanceType::R524xlarge | InstanceType::R52xlarge | InstanceType::R54xlarge | InstanceType::R58xlarge | InstanceType::R5Large | InstanceType::R5Metal | InstanceType::R5Xlarge | InstanceType::R5a12xlarge | InstanceType::R5a16xlarge | InstanceType::R5a24xlarge | InstanceType::R5a2xlarge | InstanceType::R5a4xlarge | InstanceType::R5a8xlarge | InstanceType::R5aLarge | InstanceType::R5aXlarge | InstanceType::R5ad12xlarge | InstanceType::R5ad16xlarge | InstanceType::R5ad24xlarge | InstanceType::R5ad2xlarge | InstanceType::R5ad4xlarge | InstanceType::R5ad8xlarge | InstanceType::R5adLarge | InstanceType::R5adXlarge | InstanceType::R5b12xlarge | InstanceType::R5b16xlarge | InstanceType::R5b24xlarge | InstanceType::R5b2xlarge | InstanceType::R5b4xlarge | InstanceType::R5b8xlarge | InstanceType::R5bLarge | InstanceType::R5bMetal | InstanceType::R5bXlarge | InstanceType::R5d12xlarge | InstanceType::R5d16xlarge | InstanceType::R5d24xlarge | InstanceType::R5d2xlarge | InstanceType::R5d4xlarge | InstanceType::R5d8xlarge | InstanceType::R5dLarge | InstanceType::R5dMetal | InstanceType::R5dXlarge | InstanceType::R5dn12xlarge | InstanceType::R5dn16xlarge | InstanceType::R5dn24xlarge | InstanceType::R5dn2xlarge | InstanceType::R5dn4xlarge | InstanceType::R5dn8xlarge | InstanceType::R5dnLarge | InstanceType::R5dnMetal | InstanceType::R5dnXlarge | InstanceType::R5n12xlarge | InstanceType::R5n16xlarge | InstanceType::R5n24xlarge | InstanceType::R5n2xlarge | InstanceType::R5n4xlarge | InstanceType::R5n8xlarge | InstanceType::R5nLarge | InstanceType::R5nMetal | InstanceType::R5nXlarge | InstanceType::R6a12xlarge | InstanceType::R6a16xlarge | InstanceType::R6a24xlarge | InstanceType::R6a2xlarge | InstanceType::R6a32xlarge | InstanceType::R6a48xlarge | InstanceType::R6a4xlarge | InstanceType::R6a8xlarge | InstanceType::R6aLarge | InstanceType::R6aMetal | InstanceType::R6aXlarge | InstanceType::R6g12xlarge | InstanceType::R6g16xlarge | InstanceType::R6g2xlarge | InstanceType::R6g4xlarge | InstanceType::R6g8xlarge | InstanceType::R6gLarge | InstanceType::R6gMedium | InstanceType::R6gMetal | InstanceType::R6gXlarge | InstanceType::R6gd12xlarge | InstanceType::R6gd16xlarge | InstanceType::R6gd2xlarge | InstanceType::R6gd4xlarge | InstanceType::R6gd8xlarge | InstanceType::R6gdLarge | InstanceType::R6gdMedium | InstanceType::R6gdMetal | InstanceType::R6gdXlarge | InstanceType::R6i12xlarge | InstanceType::R6i16xlarge | InstanceType::R6i24xlarge | InstanceType::R6i2xlarge | InstanceType::R6i32xlarge | InstanceType::R6i4xlarge | InstanceType::R6i8xlarge | InstanceType::R6iLarge | InstanceType::R6iMetal | InstanceType::R6iXlarge | InstanceType::R6id12xlarge | InstanceType::R6id16xlarge | InstanceType::R6id24xlarge | InstanceType::R6id2xlarge | InstanceType::R6id32xlarge | InstanceType::R6id4xlarge | InstanceType::R6id8xlarge | InstanceType::R6idLarge | InstanceType::R6idMetal | InstanceType::R6idXlarge | InstanceType::R6idn12xlarge | InstanceType::R6idn16xlarge | InstanceType::R6idn24xlarge | InstanceType::R6idn2xlarge | InstanceType::R6idn32xlarge | InstanceType::R6idn4xlarge | InstanceType::R6idn8xlarge | InstanceType::R6idnLarge | InstanceType::R6idnMetal | InstanceType::R6idnXlarge | InstanceType::R6in12xlarge | InstanceType::R6in16xlarge | InstanceType::R6in24xlarge | InstanceType::R6in2xlarge | InstanceType::R6in32xlarge | InstanceType::R6in4xlarge | InstanceType::R6in8xlarge | InstanceType::R6inLarge | InstanceType::R6inMetal | InstanceType::R6inXlarge | InstanceType::R7a12xlarge | InstanceType::R7a16xlarge | InstanceType::R7a24xlarge | InstanceType::R7a2xlarge | InstanceType::R7a32xlarge | InstanceType::R7a48xlarge | InstanceType::R7a4xlarge | InstanceType::R7a8xlarge | InstanceType::R7aLarge | InstanceType::R7aMedium | InstanceType::R7aMetal48xl | InstanceType::R7aXlarge | InstanceType::R7g12xlarge | InstanceType::R7g16xlarge | InstanceType::R7g2xlarge | InstanceType::R7g4xlarge | InstanceType::R7g8xlarge | InstanceType::R7gLarge | InstanceType::R7gMedium | InstanceType::R7gMetal | InstanceType::R7gXlarge | InstanceType::R7gd12xlarge | InstanceType::R7gd16xlarge | InstanceType::R7gd2xlarge | InstanceType::R7gd4xlarge | InstanceType::R7gd8xlarge | InstanceType::R7gdLarge | InstanceType::R7gdMedium | InstanceType::R7gdXlarge | InstanceType::R7i12xlarge | InstanceType::R7i16xlarge | InstanceType::R7i24xlarge | InstanceType::R7i2xlarge | InstanceType::R7i48xlarge | InstanceType::R7i4xlarge | InstanceType::R7i8xlarge | InstanceType::R7iLarge | InstanceType::R7iMetal24xl | InstanceType::R7iMetal48xl | InstanceType::R7iXlarge | InstanceType::R7iz12xlarge | InstanceType::R7iz16xlarge | InstanceType::R7iz2xlarge | InstanceType::R7iz32xlarge | InstanceType::R7iz4xlarge | InstanceType::R7iz8xlarge | InstanceType::R7izLarge | InstanceType::R7izXlarge | InstanceType::T1Micro | InstanceType::T22xlarge | InstanceType::T2Large | InstanceType::T2Medium | InstanceType::T2Micro | InstanceType::T2Nano | InstanceType::T2Small | InstanceType::T2Xlarge | InstanceType::T32xlarge | InstanceType::T3Large | InstanceType::T3Medium | InstanceType::T3Micro | InstanceType::T3Nano | InstanceType::T3Small | InstanceType::T3Xlarge | InstanceType::T3a2xlarge | InstanceType::T3aLarge | InstanceType::T3aMedium | InstanceType::T3aMicro | InstanceType::T3aNano | InstanceType::T3aSmall | InstanceType::T3aXlarge | InstanceType::T4g2xlarge | InstanceType::T4gLarge | InstanceType::T4gMedium | InstanceType::T4gMicro | InstanceType::T4gNano | InstanceType::T4gSmall | InstanceType::T4gXlarge | InstanceType::Trn12xlarge | InstanceType::Trn132xlarge | InstanceType::Trn1n32xlarge | InstanceType::U12tb1112xlarge | InstanceType::U12tb1Metal | InstanceType::U18tb1112xlarge | InstanceType::U18tb1Metal | InstanceType::U24tb1112xlarge | InstanceType::U24tb1Metal | InstanceType::U3tb156xlarge | InstanceType::U6tb1112xlarge | InstanceType::U6tb156xlarge | InstanceType::U6tb1Metal | InstanceType::U9tb1112xlarge | InstanceType::U9tb1Metal | InstanceType::Vt124xlarge | InstanceType::Vt13xlarge | InstanceType::Vt16xlarge | InstanceType::X116xlarge | InstanceType::X132xlarge | InstanceType::X1e16xlarge | InstanceType::X1e2xlarge | InstanceType::X1e32xlarge | InstanceType::X1e4xlarge | InstanceType::X1e8xlarge | InstanceType::X1eXlarge | InstanceType::X2gd12xlarge | InstanceType::X2gd16xlarge | InstanceType::X2gd2xlarge | InstanceType::X2gd4xlarge | InstanceType::X2gd8xlarge | InstanceType::X2gdLarge | InstanceType::X2gdMedium | InstanceType::X2gdMetal | InstanceType::X2gdXlarge | InstanceType::X2idn16xlarge | InstanceType::X2idn24xlarge | InstanceType::X2idn32xlarge | InstanceType::X2idnMetal | InstanceType::X2iedn16xlarge | InstanceType::X2iedn24xlarge | InstanceType::X2iedn2xlarge | InstanceType::X2iedn32xlarge | InstanceType::X2iedn4xlarge | InstanceType::X2iedn8xlarge | InstanceType::X2iednMetal | InstanceType::X2iednXlarge | InstanceType::X2iezn12xlarge | InstanceType::X2iezn2xlarge | InstanceType::X2iezn4xlarge | InstanceType::X2iezn6xlarge | InstanceType::X2iezn8xlarge | InstanceType::X2ieznMetal | InstanceType::Z1d12xlarge | InstanceType::Z1d2xlarge | InstanceType::Z1d3xlarge | InstanceType::Z1d6xlarge | InstanceType::Z1dLarge | InstanceType::Z1dMetal | InstanceType::Z1dXlarge | InstanceType::C7gdMetal | InstanceType::C7gnMetal | InstanceType::C7iFlex2xlarge | InstanceType::C7iFlex4xlarge | InstanceType::C7iFlex8xlarge | InstanceType::C7iFlexLarge | InstanceType::C7iFlexXlarge | InstanceType::C8g12xlarge | InstanceType::C8g16xlarge | InstanceType::C8g24xlarge | InstanceType::C8g2xlarge | InstanceType::C8g48xlarge | InstanceType::C8g4xlarge | InstanceType::C8g8xlarge | InstanceType::C8gLarge | InstanceType::C8gMedium | InstanceType::C8gMetal24xl | InstanceType::C8gMetal48xl | InstanceType::C8gXlarge | InstanceType::G612xlarge | InstanceType::G616xlarge | InstanceType::G624xlarge | InstanceType::G62xlarge | InstanceType::G648xlarge | InstanceType::G64xlarge | InstanceType::G68xlarge | InstanceType::G6Xlarge | InstanceType::G6e12xlarge | InstanceType::G6e16xlarge | InstanceType::G6e24xlarge | InstanceType::G6e2xlarge | InstanceType::G6e48xlarge | InstanceType::G6e4xlarge | InstanceType::G6e8xlarge | InstanceType::G6eXlarge | InstanceType::Gr64xlarge | InstanceType::Gr68xlarge | InstanceType::M7gdMetal | InstanceType::M8g12xlarge | InstanceType::M8g16xlarge | InstanceType::M8g24xlarge | InstanceType::M8g2xlarge | InstanceType::M8g48xlarge | InstanceType::M8g4xlarge | InstanceType::M8g8xlarge | InstanceType::M8gLarge | InstanceType::M8gMedium | InstanceType::M8gMetal24xl | InstanceType::M8gMetal48xl | InstanceType::M8gXlarge | InstanceType::Mac2M1ultraMetal | InstanceType::R7gdMetal | InstanceType::R7izMetal16xl | InstanceType::R7izMetal32xl | InstanceType::R8g12xlarge | InstanceType::R8g16xlarge | InstanceType::R8g24xlarge | InstanceType::R8g2xlarge | InstanceType::R8g48xlarge | InstanceType::R8g4xlarge | InstanceType::R8g8xlarge | InstanceType::R8gLarge | InstanceType::R8gMedium | InstanceType::R8gMetal24xl | InstanceType::R8gMetal48xl | InstanceType::R8gXlarge | InstanceType::U7i12tb224xlarge | InstanceType::U7ib12tb224xlarge | InstanceType::U7in16tb224xlarge | InstanceType::U7in24tb224xlarge | InstanceType::U7in32tb224xlarge | InstanceType::X8g12xlarge | InstanceType::X8g16xlarge | InstanceType::X8g24xlarge | InstanceType::X8g2xlarge | InstanceType::X8g48xlarge | InstanceType::X8g4xlarge | InstanceType::X8g8xlarge | InstanceType::X8gLarge | InstanceType::X8gMedium | InstanceType::X8gMetal24xl | InstanceType::X8gMetal48xl | InstanceType::X8gXlarge => Ok(instance_type), other => Err(anyhow!("unknown InstanceType: {other:?}")), } } ================================================ FILE: bin/core/src/cloud/aws/mod.rs ================================================ pub mod ec2; ================================================ FILE: bin/core/src/cloud/mod.rs ================================================ pub mod aws; #[derive(Debug)] pub enum BuildCleanupData { /// Nothing to clean up Server, /// Clean up AWS instance Aws { instance_id: String, region: String }, } ================================================ FILE: bin/core/src/config.rs ================================================ use std::{path::PathBuf, sync::OnceLock}; use anyhow::Context; use colored::Colorize; use config::ConfigLoader; use environment_file::{ maybe_read_item_from_file, maybe_read_list_from_file, }; use komodo_client::entities::{ config::{ DatabaseConfig, core::{ AwsCredentials, CoreConfig, Env, GithubWebhookAppConfig, GithubWebhookAppInstallationConfig, OauthCredentials, }, }, logger::LogConfig, }; pub fn core_config() -> &'static CoreConfig { static CORE_CONFIG: OnceLock = OnceLock::new(); CORE_CONFIG.get_or_init(|| { let env: Env = match envy::from_env() .context("Failed to parse Komodo Core environment") { Ok(env) => env, Err(e) => { panic!("{e:?}"); } }; let config = if env.komodo_config_paths.is_empty() { println!( "{}: No config paths found, using default config", "INFO".green(), ); CoreConfig::default() } else { let config_keywords = env.komodo_config_keywords .iter() .map(String::as_str) .collect::>(); println!( "{}: {}: {config_keywords:?}", "INFO".green(), "Config File Keywords".dimmed(), ); (ConfigLoader { paths: &env.komodo_config_paths .iter() .map(PathBuf::as_path) .collect::>(), match_wildcards: &config_keywords, include_file_name: ".kcoreinclude", merge_nested: env.komodo_merge_nested_config, extend_array: env.komodo_extend_config_arrays, debug_print: env.komodo_config_debug, }).load::() .expect("Failed at parsing config from paths") }; let installations = match ( maybe_read_list_from_file( env.komodo_github_webhook_app_installations_ids_file, env.komodo_github_webhook_app_installations_ids ), env.komodo_github_webhook_app_installations_namespaces ) { (Some(ids), Some(namespaces)) => { if ids.len() != namespaces.len() { panic!("KOMODO_GITHUB_WEBHOOK_APP_INSTALLATIONS_IDS length and KOMODO_GITHUB_WEBHOOK_APP_INSTALLATIONS_NAMESPACES length mismatch. Got {ids:?} and {namespaces:?}") } ids .into_iter() .zip(namespaces) .map(|(id, namespace)| GithubWebhookAppInstallationConfig { id, namespace }) .collect() }, (Some(_), None) | (None, Some(_)) => { panic!("Got only one of KOMODO_GITHUB_WEBHOOK_APP_INSTALLATIONS_IDS or KOMODO_GITHUB_WEBHOOK_APP_INSTALLATIONS_NAMESPACES, both MUST be provided"); } (None, None) => { config.github_webhook_app.installations } }; // recreating CoreConfig here makes sure apply all env overrides applied. CoreConfig { // Secret things overridden with file jwt_secret: maybe_read_item_from_file(env.komodo_jwt_secret_file, env.komodo_jwt_secret).unwrap_or(config.jwt_secret), passkey: maybe_read_item_from_file(env.komodo_passkey_file, env.komodo_passkey) .unwrap_or(config.passkey), webhook_secret: maybe_read_item_from_file(env.komodo_webhook_secret_file, env.komodo_webhook_secret) .unwrap_or(config.webhook_secret), database: DatabaseConfig { uri: maybe_read_item_from_file(env.komodo_database_uri_file,env.komodo_database_uri).unwrap_or(config.database.uri), address: env.komodo_database_address.unwrap_or(config.database.address), username: maybe_read_item_from_file(env.komodo_database_username_file,env .komodo_database_username) .unwrap_or(config.database.username), password: maybe_read_item_from_file(env.komodo_database_password_file,env .komodo_database_password) .unwrap_or(config.database.password), app_name: env .komodo_database_app_name .unwrap_or(config.database.app_name), db_name: env .komodo_database_db_name .unwrap_or(config.database.db_name), }, init_admin_username: maybe_read_item_from_file( env.komodo_init_admin_username_file, env.komodo_init_admin_username ).or(config.init_admin_username), init_admin_password: maybe_read_item_from_file( env.komodo_init_admin_password_file, env.komodo_init_admin_password ).unwrap_or(config.init_admin_password), oidc_enabled: env.komodo_oidc_enabled.unwrap_or(config.oidc_enabled), oidc_provider: env.komodo_oidc_provider.unwrap_or(config.oidc_provider), oidc_redirect_host: env.komodo_oidc_redirect_host.unwrap_or(config.oidc_redirect_host), oidc_client_id: maybe_read_item_from_file(env.komodo_oidc_client_id_file,env .komodo_oidc_client_id) .unwrap_or(config.oidc_client_id), oidc_client_secret: maybe_read_item_from_file(env.komodo_oidc_client_secret_file,env .komodo_oidc_client_secret) .unwrap_or(config.oidc_client_secret), oidc_use_full_email: env.komodo_oidc_use_full_email .unwrap_or(config.oidc_use_full_email), oidc_additional_audiences: maybe_read_list_from_file(env.komodo_oidc_additional_audiences_file,env .komodo_oidc_additional_audiences) .unwrap_or(config.oidc_additional_audiences), google_oauth: OauthCredentials { enabled: env .komodo_google_oauth_enabled .unwrap_or(config.google_oauth.enabled), id: maybe_read_item_from_file(env.komodo_google_oauth_id_file,env .komodo_google_oauth_id) .unwrap_or(config.google_oauth.id), secret: maybe_read_item_from_file(env.komodo_google_oauth_secret_file,env .komodo_google_oauth_secret) .unwrap_or(config.google_oauth.secret), }, github_oauth: OauthCredentials { enabled: env .komodo_github_oauth_enabled .unwrap_or(config.github_oauth.enabled), id: maybe_read_item_from_file(env.komodo_github_oauth_id_file,env .komodo_github_oauth_id) .unwrap_or(config.github_oauth.id), secret: maybe_read_item_from_file(env.komodo_github_oauth_secret_file,env .komodo_github_oauth_secret) .unwrap_or(config.github_oauth.secret), }, aws: AwsCredentials { access_key_id: maybe_read_item_from_file(env.komodo_aws_access_key_id_file, env .komodo_aws_access_key_id) .unwrap_or(config.aws.access_key_id), secret_access_key: maybe_read_item_from_file(env.komodo_aws_secret_access_key_file, env .komodo_aws_secret_access_key) .unwrap_or(config.aws.secret_access_key), }, github_webhook_app: GithubWebhookAppConfig { app_id: maybe_read_item_from_file(env.komodo_github_webhook_app_app_id_file, env .komodo_github_webhook_app_app_id) .unwrap_or(config.github_webhook_app.app_id), pk_path: env .komodo_github_webhook_app_pk_path .unwrap_or(config.github_webhook_app.pk_path), installations, }, // Non secrets title: env.komodo_title.unwrap_or(config.title), host: env.komodo_host.unwrap_or(config.host), port: env.komodo_port.unwrap_or(config.port), bind_ip: env.komodo_bind_ip.unwrap_or(config.bind_ip), timezone: env.komodo_timezone.unwrap_or(config.timezone), first_server: env.komodo_first_server.or(config.first_server), first_server_name: env.komodo_first_server_name.unwrap_or(config.first_server_name), frontend_path: env.komodo_frontend_path.unwrap_or(config.frontend_path), jwt_ttl: env .komodo_jwt_ttl .unwrap_or(config.jwt_ttl), sync_directory: env .komodo_sync_directory .unwrap_or(config.sync_directory), repo_directory: env .komodo_repo_directory .unwrap_or(config.repo_directory), action_directory: env .komodo_action_directory .unwrap_or(config.action_directory), resource_poll_interval: env .komodo_resource_poll_interval .unwrap_or(config.resource_poll_interval), monitoring_interval: env .komodo_monitoring_interval .unwrap_or(config.monitoring_interval), keep_stats_for_days: env .komodo_keep_stats_for_days .unwrap_or(config.keep_stats_for_days), keep_alerts_for_days: env .komodo_keep_alerts_for_days .unwrap_or(config.keep_alerts_for_days), webhook_base_url: env .komodo_webhook_base_url .unwrap_or(config.webhook_base_url), transparent_mode: env .komodo_transparent_mode .unwrap_or(config.transparent_mode), ui_write_disabled: env .komodo_ui_write_disabled .unwrap_or(config.ui_write_disabled), disable_confirm_dialog: env.komodo_disable_confirm_dialog .unwrap_or(config.disable_confirm_dialog), disable_websocket_reconnect: env.komodo_disable_websocket_reconnect .unwrap_or(config.disable_websocket_reconnect), enable_new_users: env.komodo_enable_new_users .unwrap_or(config.enable_new_users), disable_user_registration: env.komodo_disable_user_registration .unwrap_or(config.disable_user_registration), disable_non_admin_create: env.komodo_disable_non_admin_create .unwrap_or(config.disable_non_admin_create), disable_init_resources: env.komodo_disable_init_resources .unwrap_or(config.disable_init_resources), enable_fancy_toml: env.komodo_enable_fancy_toml .unwrap_or(config.enable_fancy_toml), lock_login_credentials_for: env.komodo_lock_login_credentials_for .unwrap_or(config.lock_login_credentials_for), local_auth: env.komodo_local_auth .unwrap_or(config.local_auth), logging: LogConfig { level: env .komodo_logging_level .unwrap_or(config.logging.level), stdio: env .komodo_logging_stdio .unwrap_or(config.logging.stdio), pretty: env.komodo_logging_pretty .unwrap_or(config.logging.pretty), location: env.komodo_logging_location .unwrap_or(config.logging.location), otlp_endpoint: env .komodo_logging_otlp_endpoint .unwrap_or(config.logging.otlp_endpoint), opentelemetry_service_name: env .komodo_logging_opentelemetry_service_name .unwrap_or(config.logging.opentelemetry_service_name), }, pretty_startup_config: env.komodo_pretty_startup_config.unwrap_or(config.pretty_startup_config), unsafe_unsanitized_startup_config: env.komodo_unsafe_unsanitized_startup_config.unwrap_or(config.unsafe_unsanitized_startup_config), internet_interface: env.komodo_internet_interface.unwrap_or(config.internet_interface), ssl_enabled: env.komodo_ssl_enabled.unwrap_or(config.ssl_enabled), ssl_key_file: env.komodo_ssl_key_file.unwrap_or(config.ssl_key_file), ssl_cert_file: env.komodo_ssl_cert_file.unwrap_or(config.ssl_cert_file), // These can't be overridden on env secrets: config.secrets, git_providers: config.git_providers, docker_registries: config.docker_registries, } }) } ================================================ FILE: bin/core/src/helpers/action_state.rs ================================================ use std::sync::{Arc, Mutex}; use anyhow::anyhow; use komodo_client::{ busy::Busy, entities::{ action::ActionActionState, build::BuildActionState, deployment::DeploymentActionState, procedure::ProcedureActionState, repo::RepoActionState, server::ServerActionState, stack::StackActionState, sync::ResourceSyncActionState, }, }; use super::cache::Cache; #[derive(Default)] pub struct ActionStates { pub server: Cache>>, pub stack: Cache>>, pub deployment: Cache>>, pub build: Cache>>, pub repo: Cache>>, pub procedure: Cache>>, pub action: Cache>>, pub sync: Cache>>, } /// Need to be able to check "busy" with write lock acquired. #[derive(Default)] pub struct ActionState( Mutex, ); impl ActionState { pub fn get(&self) -> anyhow::Result { Ok( *self .0 .lock() .map_err(|e| anyhow!("action state lock poisoned | {e:?}"))?, ) } pub fn busy(&self) -> anyhow::Result { Ok( self .0 .lock() .map_err(|e| anyhow!("action state lock poisoned | {e:?}"))? .busy(), ) } /// Will acquire lock, check busy, and if not will /// run the provided update function on the states. /// Returns a guard that returns the states to default (not busy) when dropped. pub fn update( &self, update_fn: impl Fn(&mut States), ) -> anyhow::Result> { self.update_custom( update_fn, |states| *states = Default::default(), true, ) } /// Will acquire lock, optionally check busy, and if not will /// run the provided update function on the states. /// Returns a guard that calls the provided return_fn when dropped. pub fn update_custom( &self, update_fn: impl Fn(&mut States), return_fn: impl Fn(&mut States) + Send + 'static, busy_check: bool, ) -> anyhow::Result> { let mut lock = self .0 .lock() .map_err(|e| anyhow!("Action state lock poisoned | {e:?}"))?; if busy_check && lock.busy() { return Err(anyhow!("Resource is busy")); } update_fn(&mut *lock); Ok(UpdateGuard(&self.0, Box::new(return_fn))) } } /// When dropped will return the inner state to default. /// The inner mutex guard must already be dropped BEFORE this is dropped, /// which is guaranteed as the inner guard is dropped by all public methods before /// user could drop UpdateGuard. pub struct UpdateGuard<'a, States: Default + Send + 'static>( &'a Mutex, Box, ); impl Drop for UpdateGuard<'_, States> { fn drop(&mut self) { let mut lock = match self.0.lock() { Ok(lock) => lock, Err(e) => { error!("CRITICAL: an action state lock is poisoned | {e:?}"); return; } }; self.1(&mut *lock); } } ================================================ FILE: bin/core/src/helpers/all_resources.rs ================================================ use std::collections::HashMap; use komodo_client::entities::{ action::Action, alerter::Alerter, build::Build, builder::Builder, deployment::Deployment, procedure::Procedure, repo::Repo, server::Server, stack::Stack, sync::ResourceSync, }; #[derive(Debug, Default)] pub struct AllResourcesById { pub servers: HashMap, pub deployments: HashMap, pub stacks: HashMap, pub builds: HashMap, pub repos: HashMap, pub procedures: HashMap, pub actions: HashMap, pub builders: HashMap, pub alerters: HashMap, pub syncs: HashMap, } impl AllResourcesById { /// Use `match_tags` to filter resources by tag. pub async fn load() -> anyhow::Result { let map = HashMap::new(); let id_to_tags = ↦ let match_tags = &[]; Ok(Self { servers: crate::resource::get_id_to_resource_map::( id_to_tags, match_tags, ) .await?, deployments: crate::resource::get_id_to_resource_map::< Deployment, >(id_to_tags, match_tags) .await?, builds: crate::resource::get_id_to_resource_map::( id_to_tags, match_tags, ) .await?, repos: crate::resource::get_id_to_resource_map::( id_to_tags, match_tags, ) .await?, procedures: crate::resource::get_id_to_resource_map::( id_to_tags, match_tags, ) .await?, actions: crate::resource::get_id_to_resource_map::( id_to_tags, match_tags, ) .await?, builders: crate::resource::get_id_to_resource_map::( id_to_tags, match_tags, ) .await?, alerters: crate::resource::get_id_to_resource_map::( id_to_tags, match_tags, ) .await?, syncs: crate::resource::get_id_to_resource_map::( id_to_tags, match_tags, ) .await?, stacks: crate::resource::get_id_to_resource_map::( id_to_tags, match_tags, ) .await?, }) } } ================================================ FILE: bin/core/src/helpers/builder.rs ================================================ use std::time::Duration; use anyhow::{Context, anyhow}; use formatting::muted; use komodo_client::entities::{ Version, builder::{AwsBuilderConfig, Builder, BuilderConfig}, komodo_timestamp, server::Server, update::{Log, Update}, }; use periphery_client::{ PeripheryClient, api::{self, GetVersionResponse}, }; use crate::{ cloud::{ BuildCleanupData, aws::ec2::{ Ec2Instance, launch_ec2_instance, terminate_ec2_instance_with_retry, }, }, config::core_config, helpers::update::update_update, resource, }; use super::periphery_client; const BUILDER_POLL_RATE_SECS: u64 = 2; const BUILDER_POLL_MAX_TRIES: usize = 60; #[instrument(skip_all, fields(builder_id = builder.id, update_id = update.id))] pub async fn get_builder_periphery( // build: &Build, resource_name: String, version: Option, builder: Builder, update: &mut Update, ) -> anyhow::Result<(PeripheryClient, BuildCleanupData)> { match builder.config { BuilderConfig::Url(config) => { if config.address.is_empty() { return Err(anyhow!( "Builder has not yet configured an address" )); } let periphery = PeripheryClient::new( config.address, if config.passkey.is_empty() { core_config().passkey.clone() } else { config.passkey }, Duration::from_secs(3), ); periphery .health_check() .await .context("Url Builder failed health check")?; Ok((periphery, BuildCleanupData::Server)) } BuilderConfig::Server(config) => { if config.server_id.is_empty() { return Err(anyhow!("Builder has not configured a server")); } let server = resource::get::(&config.server_id).await?; let periphery = periphery_client(&server)?; Ok((periphery, BuildCleanupData::Server)) } BuilderConfig::Aws(config) => { get_aws_builder(&resource_name, version, config, update).await } } } #[instrument(skip_all, fields(resource_name, update_id = update.id))] async fn get_aws_builder( resource_name: &str, version: Option, config: AwsBuilderConfig, update: &mut Update, ) -> anyhow::Result<(PeripheryClient, BuildCleanupData)> { let start_create_ts = komodo_timestamp(); let version = version.map(|v| format!("-v{v}")).unwrap_or_default(); let instance_name = format!("BUILDER-{resource_name}{version}"); let Ec2Instance { instance_id, ip } = launch_ec2_instance(&instance_name, &config).await?; info!("ec2 instance launched"); let log = Log { stage: "start build instance".to_string(), success: true, stdout: start_aws_builder_log(&instance_id, &ip, &config), start_ts: start_create_ts, end_ts: komodo_timestamp(), ..Default::default() }; update.logs.push(log); update_update(update.clone()).await?; let protocol = if config.use_https { "https" } else { "http" }; let periphery_address = format!("{protocol}://{ip}:{}", config.port); let periphery = PeripheryClient::new( &periphery_address, &core_config().passkey, Duration::from_secs(3), ); let start_connect_ts = komodo_timestamp(); let mut res = Ok(GetVersionResponse { version: String::new(), }); for _ in 0..BUILDER_POLL_MAX_TRIES { let version = periphery .request(api::GetVersion {}) .await .context("failed to reach periphery client on builder"); if let Ok(GetVersionResponse { version }) = &version { let connect_log = Log { stage: "build instance connected".to_string(), success: true, stdout: format!( "established contact with periphery on builder\nperiphery version: v{version}" ), start_ts: start_connect_ts, end_ts: komodo_timestamp(), ..Default::default() }; update.logs.push(connect_log); update_update(update.clone()).await?; return Ok(( periphery, BuildCleanupData::Aws { instance_id, region: config.region, }, )); } res = version; tokio::time::sleep(Duration::from_secs(BUILDER_POLL_RATE_SECS)) .await; } // Spawn terminate task in failure case (if loop is passed without return) tokio::spawn(async move { let _ = terminate_ec2_instance_with_retry(config.region, &instance_id) .await; }); // Unwrap is safe, only way to get here is after check Ok / early return, so it must be err Err( res.err().unwrap().context( "failed to start usable builder. terminating instance.", ), ) } #[instrument(skip(update))] pub async fn cleanup_builder_instance( cleanup_data: BuildCleanupData, update: &mut Update, ) { match cleanup_data { BuildCleanupData::Server => { // Nothing to clean up } BuildCleanupData::Aws { instance_id, region, } => { let _instance_id = instance_id.clone(); tokio::spawn(async move { let _ = terminate_ec2_instance_with_retry(region, &_instance_id) .await; }); update.push_simple_log( "terminate instance", format!("termination queued for instance id {instance_id}"), ); } } } pub fn start_aws_builder_log( instance_id: &str, ip: &str, config: &AwsBuilderConfig, ) -> String { let AwsBuilderConfig { ami_id, instance_type, volume_gb, subnet_id, assign_public_ip, security_group_ids, use_public_ip, use_https, .. } = config; let readable_sec_group_ids = security_group_ids.join(", "); [ format!("{}: {instance_id}", muted("instance id")), format!("{}: {ip}", muted("ip")), format!("{}: {ami_id}", muted("ami id")), format!("{}: {instance_type}", muted("instance type")), format!("{}: {volume_gb} GB", muted("volume size")), format!("{}: {subnet_id}", muted("subnet id")), format!("{}: {readable_sec_group_ids}", muted("security groups")), format!("{}: {assign_public_ip}", muted("assign public ip")), format!("{}: {use_public_ip}", muted("use public ip")), format!("{}: {use_https}", muted("use https")), ] .join("\n") } ================================================ FILE: bin/core/src/helpers/cache.rs ================================================ use std::{collections::HashMap, hash::Hash}; use tokio::sync::RwLock; #[derive(Default)] pub struct Cache { cache: RwLock>, } impl< K: PartialEq + Eq + Hash + std::fmt::Debug + Clone, T: Clone + Default, > Cache { #[instrument(level = "debug", skip(self))] pub async fn get(&self, key: &K) -> Option { self.cache.read().await.get(key).cloned() } #[instrument(level = "debug", skip(self))] pub async fn get_or_insert_default(&self, key: &K) -> T { let mut lock = self.cache.write().await; match lock.get(key).cloned() { Some(item) => item, None => { let item: T = Default::default(); lock.insert(key.clone(), item.clone()); item } } } #[instrument(level = "debug", skip(self))] pub async fn get_list(&self) -> Vec { let cache = self.cache.read().await; cache.values().cloned().collect() } #[instrument(level = "debug", skip(self))] pub async fn insert(&self, key: Key, val: T) where T: std::fmt::Debug, Key: Into + std::fmt::Debug, { self.cache.write().await.insert(key.into(), val); } // #[instrument(level = "debug", skip(self, handler))] // pub async fn update_entry( // &self, // key: Key, // handler: impl Fn(&mut T), // ) where // Key: Into + std::fmt::Debug, // { // let mut cache = self.cache.write().await; // handler(cache.entry(key.into()).or_default()); // } // #[instrument(level = "debug", skip(self))] // pub async fn clear(&self) { // self.cache.write().await.clear(); // } #[instrument(level = "debug", skip(self))] pub async fn remove(&self, key: &K) { self.cache.write().await.remove(key); } } // impl< // K: PartialEq + Eq + Hash + std::fmt::Debug + Clone, // T: Clone + Default + Busy, // > Cache // { // #[instrument(level = "debug", skip(self))] // pub async fn busy(&self, id: &K) -> bool { // match self.get(id).await { // Some(state) => state.busy(), // None => false, // } // } // } ================================================ FILE: bin/core/src/helpers/channel.rs ================================================ use std::sync::OnceLock; use komodo_client::entities::update::{Update, UpdateListItem}; use tokio::sync::{Mutex, broadcast}; /// A channel sending (build_id, update_id) pub fn build_cancel_channel() -> &'static BroadcastChannel<(String, Update)> { static BUILD_CANCEL_CHANNEL: OnceLock< BroadcastChannel<(String, Update)>, > = OnceLock::new(); BUILD_CANCEL_CHANNEL.get_or_init(|| BroadcastChannel::new(100)) } /// A channel sending (repo_id, update_id) pub fn repo_cancel_channel() -> &'static BroadcastChannel<(String, Update)> { static REPO_CANCEL_CHANNEL: OnceLock< BroadcastChannel<(String, Update)>, > = OnceLock::new(); REPO_CANCEL_CHANNEL.get_or_init(|| BroadcastChannel::new(100)) } pub fn update_channel() -> &'static BroadcastChannel { static UPDATE_CHANNEL: OnceLock> = OnceLock::new(); UPDATE_CHANNEL.get_or_init(|| BroadcastChannel::new(100)) } pub struct BroadcastChannel { pub sender: Mutex>, pub receiver: broadcast::Receiver, } impl BroadcastChannel { pub fn new(capacity: usize) -> BroadcastChannel { let (sender, receiver) = broadcast::channel(capacity); BroadcastChannel { sender: sender.into(), receiver, } } } ================================================ FILE: bin/core/src/helpers/maintenance.rs ================================================ use std::str::FromStr; use anyhow::Context; use chrono::{Datelike, Local}; use komodo_client::entities::{ DayOfWeek, MaintenanceScheduleType, MaintenanceWindow, }; use crate::config::core_config; /// Check if a timestamp is currently in a maintenance window, given a list of windows. pub fn is_in_maintenance( windows: &[MaintenanceWindow], timestamp: i64, ) -> bool { windows .iter() .any(|window| is_maintenance_window_active(window, timestamp)) } /// Check if the current timestamp falls within this maintenance window pub fn is_maintenance_window_active( window: &MaintenanceWindow, timestamp: i64, ) -> bool { if !window.enabled { return false; } let dt = chrono::DateTime::from_timestamp(timestamp / 1000, 0) .unwrap_or_else(chrono::Utc::now); let (local_time, local_weekday, local_date) = match (window.timezone.as_str(), core_config().timezone.as_str()) { ("", "") => { let local_dt = dt.with_timezone(&Local); (local_dt.time(), local_dt.weekday(), local_dt.date_naive()) } ("", timezone) | (timezone, _) => { let tz: chrono_tz::Tz = match timezone .parse() .context("Failed to parse timezone") { Ok(tz) => tz, Err(e) => { warn!( "Failed to parse maintenance window timezone: {e:#}" ); return false; } }; let local_dt = dt.with_timezone(&tz); (local_dt.time(), local_dt.weekday(), local_dt.date_naive()) } }; match window.schedule_type { MaintenanceScheduleType::Daily => { is_time_in_window(window, local_time) } MaintenanceScheduleType::Weekly => { let day_of_week = DayOfWeek::from_str(&window.day_of_week).unwrap_or_default(); convert_day_of_week(local_weekday) == day_of_week && is_time_in_window(window, local_time) } MaintenanceScheduleType::OneTime => { // Parse the date string and check if it matches current date if let Ok(maintenance_date) = chrono::NaiveDate::parse_from_str(&window.date, "%Y-%m-%d") { local_date == maintenance_date && is_time_in_window(window, local_time) } else { false } } } } fn is_time_in_window( window: &MaintenanceWindow, current_time: chrono::NaiveTime, ) -> bool { let start_time = chrono::NaiveTime::from_hms_opt( window.hour as u32, window.minute as u32, 0, ) .unwrap_or(chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap()); let end_time = start_time + chrono::Duration::minutes(window.duration_minutes as i64); // Handle case where maintenance window crosses midnight if end_time < start_time { current_time >= start_time || current_time <= end_time } else { current_time >= start_time && current_time <= end_time } } fn convert_day_of_week(value: chrono::Weekday) -> DayOfWeek { match value { chrono::Weekday::Mon => DayOfWeek::Monday, chrono::Weekday::Tue => DayOfWeek::Tuesday, chrono::Weekday::Wed => DayOfWeek::Wednesday, chrono::Weekday::Thu => DayOfWeek::Thursday, chrono::Weekday::Fri => DayOfWeek::Friday, chrono::Weekday::Sat => DayOfWeek::Saturday, chrono::Weekday::Sun => DayOfWeek::Sunday, } } ================================================ FILE: bin/core/src/helpers/matcher.rs ================================================ use anyhow::Context; pub enum Matcher<'a> { Wildcard(wildcard::Wildcard<'a>), Regex(regex::Regex), } impl<'a> Matcher<'a> { pub fn new(pattern: &'a str) -> anyhow::Result { if pattern.starts_with('\\') && pattern.ends_with('\\') { let inner = &pattern[1..(pattern.len() - 1)]; let regex = regex::Regex::new(inner) .with_context(|| format!("invalid regex. got: {inner}"))?; Ok(Self::Regex(regex)) } else { let wildcard = wildcard::Wildcard::new(pattern.as_bytes()) .with_context(|| { format!("invalid wildcard. got: {pattern}") })?; Ok(Self::Wildcard(wildcard)) } } pub fn is_match(&self, source: &str) -> bool { match self { Matcher::Wildcard(wildcard) => { wildcard.is_match(source.as_bytes()) } Matcher::Regex(regex) => regex.is_match(source), } } } ================================================ FILE: bin/core/src/helpers/mod.rs ================================================ use std::{fmt::Write, time::Duration}; use anyhow::{Context, anyhow}; use database::mongo_indexed::Document; use database::mungos::mongodb::bson::{Bson, doc}; use indexmap::IndexSet; use komodo_client::entities::{ ResourceTarget, build::Build, permission::{ Permission, PermissionLevel, SpecificPermission, UserTarget, }, repo::Repo, server::Server, stack::Stack, user::User, }; use periphery_client::PeripheryClient; use rand::Rng; use crate::{config::core_config, state::db_client}; pub mod action_state; pub mod all_resources; pub mod builder; pub mod cache; pub mod channel; pub mod maintenance; pub mod matcher; pub mod procedure; pub mod prune; pub mod query; pub mod update; // pub mod resource; pub fn empty_or_only_spaces(word: &str) -> bool { if word.is_empty() { return true; } for char in word.chars() { if char != ' ' { return false; } } true } pub fn random_string(length: usize) -> String { rand::rng() .sample_iter(&rand::distr::Alphanumeric) .take(length) .map(char::from) .collect() } /// First checks db for token, then checks core config. /// Only errors if db call errors. /// Returns (token, use_https) pub async fn git_token( provider_domain: &str, account_username: &str, mut on_https_found: impl FnMut(bool), ) -> anyhow::Result> { if provider_domain.is_empty() || account_username.is_empty() { return Ok(None); } let db_provider = db_client() .git_accounts .find_one(doc! { "domain": provider_domain, "username": account_username }) .await .context("failed to query db for git provider accounts")?; if let Some(provider) = db_provider { on_https_found(provider.https); return Ok(Some(provider.token)); } Ok( core_config() .git_providers .iter() .find(|provider| provider.domain == provider_domain) .and_then(|provider| { on_https_found(provider.https); provider .accounts .iter() .find(|account| account.username == account_username) .map(|account| account.token.clone()) }), ) } pub async fn stack_git_token( stack: &mut Stack, repo: Option<&mut Repo>, ) -> anyhow::Result> { if let Some(repo) = repo { return git_token( &repo.config.git_provider, &repo.config.git_account, |https| repo.config.git_https = https, ) .await .with_context(|| { format!( "Failed to get git token. Stopping run. | {} | {}", repo.config.git_provider, repo.config.git_account ) }); } git_token( &stack.config.git_provider, &stack.config.git_account, |https| stack.config.git_https = https, ) .await .with_context(|| { format!( "Failed to get git token. Stopping run. | {} | {}", stack.config.git_provider, stack.config.git_account ) }) } pub async fn build_git_token( build: &mut Build, repo: Option<&mut Repo>, ) -> anyhow::Result> { if let Some(repo) = repo { return git_token( &repo.config.git_provider, &repo.config.git_account, |https| repo.config.git_https = https, ) .await .with_context(|| { format!( "Failed to get git token. Stopping run. | {} | {}", repo.config.git_provider, repo.config.git_account ) }); } git_token( &build.config.git_provider, &build.config.git_account, |https| build.config.git_https = https, ) .await .with_context(|| { format!( "Failed to get git token. Stopping run. | {} | {}", build.config.git_provider, build.config.git_account ) }) } /// First checks db for token, then checks core config. /// Only errors if db call errors. pub async fn registry_token( provider_domain: &str, account_username: &str, ) -> anyhow::Result> { let provider = db_client() .registry_accounts .find_one(doc! { "domain": provider_domain, "username": account_username }) .await .context("failed to query db for docker registry accounts")?; if let Some(provider) = provider { return Ok(Some(provider.token)); } Ok( core_config() .docker_registries .iter() .find(|provider| provider.domain == provider_domain) .and_then(|provider| { provider .accounts .iter() .find(|account| account.username == account_username) .map(|account| account.token.clone()) }), ) } // pub fn periphery_client( server: &Server, ) -> anyhow::Result { if !server.config.enabled { return Err(anyhow!("server not enabled")); } let client = PeripheryClient::new( &server.config.address, if server.config.passkey.is_empty() { &core_config().passkey } else { &server.config.passkey }, Duration::from_secs(server.config.timeout_seconds as u64), ); Ok(client) } #[instrument] pub async fn create_permission( user: &User, target: T, level: PermissionLevel, specific: IndexSet, ) where T: Into + std::fmt::Debug, { // No need to actually create permissions for admins if user.admin { return; } let target: ResourceTarget = target.into(); if let Err(e) = db_client() .permissions .insert_one(Permission { id: Default::default(), user_target: UserTarget::User(user.id.clone()), resource_target: target.clone(), level, specific, }) .await { error!("failed to create permission for {target:?} | {e:#}"); }; } /// Flattens a document only one level deep /// /// eg `{ config: { label: "yes", thing: { field1: "ok", field2: "ok" } } }` -> /// `{ "config.label": "yes", "config.thing": { field1: "ok", field2: "ok" } }` pub fn flatten_document(doc: Document) -> Document { let mut target = Document::new(); for (outer_field, bson) in doc { if let Bson::Document(doc) = bson { for (inner_field, bson) in doc { target.insert(format!("{outer_field}.{inner_field}"), bson); } } else { target.insert(outer_field, bson); } } target } pub fn repo_link( provider: &str, repo: &str, branch: &str, https: bool, ) -> String { let mut res = format!( "http{}://{provider}/{repo}", if https { "s" } else { "" } ); // Each provider uses a different link format to get to branches. // At least can support github for branch aware link. if provider == "github.com" { let _ = write!(&mut res, "/tree/{branch}"); } res } ================================================ FILE: bin/core/src/helpers/procedure.rs ================================================ use std::time::{Duration, Instant}; use anyhow::{Context, anyhow}; use database::mungos::by_id::find_one_by_id; use formatting::{Color, bold, colored, format_serror, muted}; use futures::future::join_all; use komodo_client::{ api::execute::*, entities::{ action::Action, build::Build, deployment::Deployment, permission::PermissionLevel, procedure::Procedure, repo::Repo, stack::Stack, update::{Log, Update}, user::procedure_user, }, }; use resolver_api::Resolve; use tokio::sync::Mutex; use crate::{ api::{ execute::{ExecuteArgs, ExecuteRequest}, write::WriteArgs, }, resource::{KomodoResource, list_full_for_user_using_pattern}, state::db_client, }; use super::update::{init_execution_update, update_update}; #[instrument(skip_all)] pub async fn execute_procedure( procedure: &Procedure, update: &Mutex, ) -> anyhow::Result<()> { for stage in &procedure.config.stages { if !stage.enabled { continue; } add_line_to_update( update, &format!( "{}: Executing stage: '{}'", muted("INFO"), bold(&stage.name) ), ) .await; let timer = Instant::now(); execute_stage( stage .executions .iter() .filter(|item| item.enabled) .map(|item| item.execution.clone()) .collect(), &procedure.id, &procedure.name, update, ) .await .with_context(|| { format!( "Failed stage '{}' execution after {:?}", bold(&stage.name), timer.elapsed(), ) })?; add_line_to_update( update, &format!( "{}: {} stage '{}' execution in {:?}", muted("INFO"), colored("Finished", Color::Green), bold(&stage.name), timer.elapsed() ), ) .await; } Ok(()) } #[allow(dependency_on_unit_never_type_fallback)] #[instrument(skip(update))] async fn execute_stage( _executions: Vec, parent_id: &str, parent_name: &str, update: &Mutex, ) -> anyhow::Result<()> { let mut executions = Vec::with_capacity(_executions.capacity()); for execution in _executions { match execution { Execution::BatchRunAction(exec) => { extend_batch_exection::( &exec.pattern, &mut executions, ) .await?; } Execution::BatchRunProcedure(exec) => { extend_batch_exection::( &exec.pattern, &mut executions, ) .await?; } Execution::BatchRunBuild(exec) => { extend_batch_exection::( &exec.pattern, &mut executions, ) .await?; } Execution::BatchCloneRepo(exec) => { extend_batch_exection::( &exec.pattern, &mut executions, ) .await?; } Execution::BatchPullRepo(exec) => { extend_batch_exection::( &exec.pattern, &mut executions, ) .await?; } Execution::BatchBuildRepo(exec) => { extend_batch_exection::( &exec.pattern, &mut executions, ) .await?; } Execution::BatchDeploy(exec) => { extend_batch_exection::( &exec.pattern, &mut executions, ) .await?; } Execution::BatchDestroyDeployment(exec) => { extend_batch_exection::( &exec.pattern, &mut executions, ) .await?; } Execution::BatchDeployStack(exec) => { extend_batch_exection::( &exec.pattern, &mut executions, ) .await?; } Execution::BatchDeployStackIfChanged(exec) => { extend_batch_exection::( &exec.pattern, &mut executions, ) .await?; } Execution::BatchPullStack(exec) => { extend_batch_exection::( &exec.pattern, &mut executions, ) .await?; } Execution::BatchDestroyStack(exec) => { extend_batch_exection::( &exec.pattern, &mut executions, ) .await?; } execution => executions.push(execution), } } let futures = executions.into_iter().map(|execution| async move { let now = Instant::now(); add_line_to_update( update, &format!("{}: Executing: {execution:?}", muted("INFO")), ) .await; let fail_log = format!( "{}: Failed on {execution:?}", colored("ERROR", Color::Red) ); let res = execute_execution(execution.clone(), parent_id, parent_name) .await .context(fail_log); add_line_to_update( update, &format!( "{}: {} execution in {:?}: {execution:?}", muted("INFO"), colored("Finished", Color::Green), now.elapsed() ), ) .await; res }); join_all(futures) .await .into_iter() .collect::>>()?; Ok(()) } async fn execute_execution( execution: Execution, // used to prevent recursive procedure parent_id: &str, parent_name: &str, ) -> anyhow::Result<()> { let user = procedure_user().to_owned(); let update = match execution { Execution::None(_) => return Ok(()), Execution::RunProcedure(req) => { if req.procedure == parent_id || req.procedure == parent_name { return Err(anyhow!("Self referential procedure detected")); } let req = ExecuteRequest::RunProcedure(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::RunProcedure(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at RunProcedure"), &update_id, ) .await? } Execution::BatchRunProcedure(_) => { // All batch executions must be expanded in `execute_stage` return Err(anyhow!( "Batch method BatchRunProcedure not implemented correctly" )); } Execution::RunAction(req) => { let req = ExecuteRequest::RunAction(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::RunAction(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at RunAction"), &update_id, ) .await? } Execution::BatchRunAction(_) => { // All batch executions must be expanded in `execute_stage` return Err(anyhow!( "Batch method BatchRunAction not implemented correctly" )); } Execution::RunBuild(req) => { let req = ExecuteRequest::RunBuild(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::RunBuild(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at RunBuild"), &update_id, ) .await? } Execution::BatchRunBuild(_) => { // All batch executions must be expanded in `execute_stage` return Err(anyhow!( "Batch method BatchRunBuild not implemented correctly" )); } Execution::CancelBuild(req) => { let req = ExecuteRequest::CancelBuild(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::CancelBuild(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at CancelBuild"), &update_id, ) .await? } Execution::Deploy(req) => { let req = ExecuteRequest::Deploy(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::Deploy(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at Deploy"), &update_id, ) .await? } Execution::BatchDeploy(_) => { // All batch executions must be expanded in `execute_stage` return Err(anyhow!( "Batch method BatchDeploy not implemented correctly" )); } Execution::PullDeployment(req) => { let req = ExecuteRequest::PullDeployment(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::PullDeployment(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at PullDeployment"), &update_id, ) .await? } Execution::StartDeployment(req) => { let req = ExecuteRequest::StartDeployment(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::StartDeployment(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at StartDeployment"), &update_id, ) .await? } Execution::RestartDeployment(req) => { let req = ExecuteRequest::RestartDeployment(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::RestartDeployment(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at RestartDeployment"), &update_id, ) .await? } Execution::PauseDeployment(req) => { let req = ExecuteRequest::PauseDeployment(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::PauseDeployment(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at PauseDeployment"), &update_id, ) .await? } Execution::UnpauseDeployment(req) => { let req = ExecuteRequest::UnpauseDeployment(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::UnpauseDeployment(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at UnpauseDeployment"), &update_id, ) .await? } Execution::StopDeployment(req) => { let req = ExecuteRequest::StopDeployment(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::StopDeployment(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at StopDeployment"), &update_id, ) .await? } Execution::DestroyDeployment(req) => { let req = ExecuteRequest::DestroyDeployment(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::DestroyDeployment(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at RemoveDeployment"), &update_id, ) .await? } Execution::BatchDestroyDeployment(_) => { // All batch executions must be expanded in `execute_stage` return Err(anyhow!( "Batch method BatchDestroyDeployment not implemented correctly" )); } Execution::CloneRepo(req) => { let req = ExecuteRequest::CloneRepo(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::CloneRepo(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at CloneRepo"), &update_id, ) .await? } Execution::BatchCloneRepo(_) => { // All batch executions must be expanded in `execute_stage` return Err(anyhow!( "Batch method BatchCloneRepo not implemented correctly" )); } Execution::PullRepo(req) => { let req = ExecuteRequest::PullRepo(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::PullRepo(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at PullRepo"), &update_id, ) .await? } Execution::BatchPullRepo(_) => { // All batch executions must be expanded in `execute_stage` return Err(anyhow!( "Batch method BatchPullRepo not implemented correctly" )); } Execution::BuildRepo(req) => { let req = ExecuteRequest::BuildRepo(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::BuildRepo(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at BuildRepo"), &update_id, ) .await? } Execution::BatchBuildRepo(_) => { // All batch executions must be expanded in `execute_stage` return Err(anyhow!( "Batch method BatchBuildRepo not implemented correctly" )); } Execution::CancelRepoBuild(req) => { let req = ExecuteRequest::CancelRepoBuild(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::CancelRepoBuild(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at CancelRepoBuild"), &update_id, ) .await? } Execution::StartContainer(req) => { let req = ExecuteRequest::StartContainer(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::StartContainer(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at StartContainer"), &update_id, ) .await? } Execution::RestartContainer(req) => { let req = ExecuteRequest::RestartContainer(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::RestartContainer(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at RestartContainer"), &update_id, ) .await? } Execution::PauseContainer(req) => { let req = ExecuteRequest::PauseContainer(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::PauseContainer(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at PauseContainer"), &update_id, ) .await? } Execution::UnpauseContainer(req) => { let req = ExecuteRequest::UnpauseContainer(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::UnpauseContainer(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at UnpauseContainer"), &update_id, ) .await? } Execution::StopContainer(req) => { let req = ExecuteRequest::StopContainer(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::StopContainer(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at StopContainer"), &update_id, ) .await? } Execution::DestroyContainer(req) => { let req = ExecuteRequest::DestroyContainer(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::DestroyContainer(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at RemoveContainer"), &update_id, ) .await? } Execution::StartAllContainers(req) => { let req = ExecuteRequest::StartAllContainers(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::StartAllContainers(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at StartAllContainers"), &update_id, ) .await? } Execution::RestartAllContainers(req) => { let req = ExecuteRequest::RestartAllContainers(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::RestartAllContainers(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at RestartAllContainers"), &update_id, ) .await? } Execution::PauseAllContainers(req) => { let req = ExecuteRequest::PauseAllContainers(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::PauseAllContainers(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at PauseAllContainers"), &update_id, ) .await? } Execution::UnpauseAllContainers(req) => { let req = ExecuteRequest::UnpauseAllContainers(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::UnpauseAllContainers(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at UnpauseAllContainers"), &update_id, ) .await? } Execution::StopAllContainers(req) => { let req = ExecuteRequest::StopAllContainers(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::StopAllContainers(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at StopAllContainers"), &update_id, ) .await? } Execution::PruneContainers(req) => { let req = ExecuteRequest::PruneContainers(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::PruneContainers(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at PruneContainers"), &update_id, ) .await? } Execution::DeleteNetwork(req) => { let req = ExecuteRequest::DeleteNetwork(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::DeleteNetwork(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at DeleteNetwork"), &update_id, ) .await? } Execution::PruneNetworks(req) => { let req = ExecuteRequest::PruneNetworks(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::PruneNetworks(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at PruneNetworks"), &update_id, ) .await? } Execution::DeleteImage(req) => { let req = ExecuteRequest::DeleteImage(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::DeleteImage(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at DeleteImage"), &update_id, ) .await? } Execution::PruneImages(req) => { let req = ExecuteRequest::PruneImages(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::PruneImages(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at PruneImages"), &update_id, ) .await? } Execution::DeleteVolume(req) => { let req = ExecuteRequest::DeleteVolume(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::DeleteVolume(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at DeleteVolume"), &update_id, ) .await? } Execution::PruneVolumes(req) => { let req = ExecuteRequest::PruneVolumes(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::PruneVolumes(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at PruneVolumes"), &update_id, ) .await? } Execution::PruneDockerBuilders(req) => { let req = ExecuteRequest::PruneDockerBuilders(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::PruneDockerBuilders(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at PruneDockerBuilders"), &update_id, ) .await? } Execution::PruneBuildx(req) => { let req = ExecuteRequest::PruneBuildx(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::PruneBuildx(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at PruneBuildx"), &update_id, ) .await? } Execution::PruneSystem(req) => { let req = ExecuteRequest::PruneSystem(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::PruneSystem(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at PruneSystem"), &update_id, ) .await? } Execution::RunSync(req) => { let req = ExecuteRequest::RunSync(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::RunSync(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at RunSync"), &update_id, ) .await? } // Exception: This is a write operation. Execution::CommitSync(req) => req .resolve(&WriteArgs { user }) .await .map_err(|e| e.error) .context("Failed at CommitSync")?, Execution::DeployStack(req) => { let req = ExecuteRequest::DeployStack(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::DeployStack(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at DeployStack"), &update_id, ) .await? } Execution::BatchDeployStack(_) => { // All batch executions must be expanded in `execute_stage` return Err(anyhow!( "Batch method BatchDeployStack not implemented correctly" )); } Execution::DeployStackIfChanged(req) => { let req = ExecuteRequest::DeployStackIfChanged(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::DeployStackIfChanged(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at DeployStackIfChanged"), &update_id, ) .await? } Execution::BatchDeployStackIfChanged(_) => { // All batch executions must be expanded in `execute_stage` return Err(anyhow!( "Batch method BatchDeployStackIfChanged not implemented correctly" )); } Execution::PullStack(req) => { let req = ExecuteRequest::PullStack(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::PullStack(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at PullStack"), &update_id, ) .await? } Execution::BatchPullStack(_) => { // All batch executions must be expanded in `execute_stage` return Err(anyhow!( "Batch method BatchPullStack not implemented correctly" )); } Execution::StartStack(req) => { let req = ExecuteRequest::StartStack(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::StartStack(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at StartStack"), &update_id, ) .await? } Execution::RestartStack(req) => { let req = ExecuteRequest::RestartStack(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::RestartStack(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at RestartStack"), &update_id, ) .await? } Execution::PauseStack(req) => { let req = ExecuteRequest::PauseStack(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::PauseStack(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at PauseStack"), &update_id, ) .await? } Execution::UnpauseStack(req) => { let req = ExecuteRequest::UnpauseStack(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::UnpauseStack(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at UnpauseStack"), &update_id, ) .await? } Execution::StopStack(req) => { let req = ExecuteRequest::StopStack(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::StopStack(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at StopStack"), &update_id, ) .await? } Execution::DestroyStack(req) => { let req = ExecuteRequest::DestroyStack(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::DestroyStack(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at DestroyStack"), &update_id, ) .await? } Execution::RunStackService(req) => { let req = ExecuteRequest::RunStackService(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::RunStackService(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at RunStackService"), &update_id, ) .await? } Execution::BatchDestroyStack(_) => { // All batch executions must be expanded in `execute_stage` return Err(anyhow!( "Batch method BatchDestroyStack not implemented correctly" )); } Execution::TestAlerter(req) => { let req = ExecuteRequest::TestAlerter(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::TestAlerter(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at TestAlerter"), &update_id, ) .await? } Execution::SendAlert(req) => { let req = ExecuteRequest::SendAlert(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::SendAlert(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at SendAlert"), &update_id, ) .await? } Execution::ClearRepoCache(req) => { let req = ExecuteRequest::ClearRepoCache(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::ClearRepoCache(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at ClearRepoCache"), &update_id, ) .await? } Execution::BackupCoreDatabase(req) => { let req = ExecuteRequest::BackupCoreDatabase(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::BackupCoreDatabase(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at BackupCoreDatabase"), &update_id, ) .await? } Execution::GlobalAutoUpdate(req) => { let req = ExecuteRequest::GlobalAutoUpdate(req); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::GlobalAutoUpdate(req) = req else { unreachable!() }; let update_id = update.id.clone(); handle_resolve_result( req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error) .context("Failed at GlobalAutoUpdate"), &update_id, ) .await? } Execution::Sleep(req) => { let duration = Duration::from_millis(req.duration_ms as u64); tokio::time::sleep(duration).await; Update { success: true, ..Default::default() } } }; if update.success { Ok(()) } else { Err(anyhow!( "{}: execution not successful. see update '{}'", colored("ERROR", Color::Red), bold(&update.id), )) } } /// If the call to .resolve returns Err, the update may not be closed. /// This will ensure it is closed with error log attached. async fn handle_resolve_result( res: anyhow::Result, update_id: &str, ) -> anyhow::Result { match res { Ok(res) => Ok(res), Err(e) => { let log = Log::error("execution error", format_serror(&e.into())); let mut update = find_one_by_id(&db_client().updates, update_id) .await .context("Failed to query to db")? .context("no update exists with given id")?; update.logs.push(log); update.finalize(); update_update(update.clone()).await?; Ok(update) } } } /// ASSUMES FIRST LOG IS ALREADY CREATED #[instrument(level = "debug")] async fn add_line_to_update(update: &Mutex, line: &str) { let mut lock = update.lock().await; let log = &mut lock.logs[0]; log.stdout.push('\n'); log.stdout.push_str(line); let update = lock.clone(); drop(lock); if let Err(e) = update_update(update).await { error!("Failed to update an update during procedure | {e:#}"); }; } async fn extend_batch_exection( pattern: &str, executions: &mut Vec, ) -> anyhow::Result<()> { let more = list_full_for_user_using_pattern::( pattern, Default::default(), procedure_user(), PermissionLevel::Read.into(), &[], ) .await? .into_iter() .map(|resource| E::single_execution(resource.name)); executions.extend(more); Ok(()) } trait ExtendBatch { type Resource: KomodoResource; fn single_execution(name: String) -> Execution; } impl ExtendBatch for BatchRunProcedure { type Resource = Procedure; fn single_execution(procedure: String) -> Execution { Execution::RunProcedure(RunProcedure { procedure }) } } impl ExtendBatch for BatchRunAction { type Resource = Action; fn single_execution(action: String) -> Execution { Execution::RunAction(RunAction { action, args: Default::default(), }) } } impl ExtendBatch for BatchRunBuild { type Resource = Build; fn single_execution(build: String) -> Execution { Execution::RunBuild(RunBuild { build }) } } impl ExtendBatch for BatchCloneRepo { type Resource = Repo; fn single_execution(repo: String) -> Execution { Execution::CloneRepo(CloneRepo { repo }) } } impl ExtendBatch for BatchPullRepo { type Resource = Repo; fn single_execution(repo: String) -> Execution { Execution::PullRepo(PullRepo { repo }) } } impl ExtendBatch for BatchBuildRepo { type Resource = Repo; fn single_execution(repo: String) -> Execution { Execution::BuildRepo(BuildRepo { repo }) } } impl ExtendBatch for BatchDeploy { type Resource = Deployment; fn single_execution(deployment: String) -> Execution { Execution::Deploy(Deploy { deployment, stop_signal: None, stop_time: None, }) } } impl ExtendBatch for BatchDestroyDeployment { type Resource = Deployment; fn single_execution(deployment: String) -> Execution { Execution::DestroyDeployment(DestroyDeployment { deployment, signal: None, time: None, }) } } impl ExtendBatch for BatchDeployStack { type Resource = Stack; fn single_execution(stack: String) -> Execution { Execution::DeployStack(DeployStack { stack, services: Vec::new(), stop_time: None, }) } } impl ExtendBatch for BatchDeployStackIfChanged { type Resource = Stack; fn single_execution(stack: String) -> Execution { Execution::DeployStackIfChanged(DeployStackIfChanged { stack, stop_time: None, }) } } impl ExtendBatch for BatchPullStack { type Resource = Stack; fn single_execution(stack: String) -> Execution { Execution::PullStack(PullStack { stack, services: Vec::new(), }) } } impl ExtendBatch for BatchDestroyStack { type Resource = Stack; fn single_execution(stack: String) -> Execution { Execution::DestroyStack(DestroyStack { stack, services: Vec::new(), remove_orphans: false, stop_time: None, }) } } ================================================ FILE: bin/core/src/helpers/prune.rs ================================================ use anyhow::Context; use async_timing_util::{ ONE_DAY_MS, Timelength, unix_timestamp_ms, wait_until_timelength, }; use database::mungos::{find::find_collect, mongodb::bson::doc}; use futures::{StreamExt, stream::FuturesUnordered}; use periphery_client::api::image::PruneImages; use crate::{config::core_config, state::db_client}; use super::periphery_client; pub fn spawn_prune_loop() { tokio::spawn(async move { loop { wait_until_timelength(Timelength::OneDay, 5000).await; let (images_res, stats_res, alerts_res) = tokio::join!(prune_images(), prune_stats(), prune_alerts()); if let Err(e) = images_res { error!("error in pruning images | {e:#}"); } if let Err(e) = stats_res { error!("error in pruning stats | {e:#}"); } if let Err(e) = alerts_res { error!("error in pruning alerts | {e:#}"); } } }); } async fn prune_images() -> anyhow::Result<()> { let mut futures = find_collect( &db_client().servers, doc! { "config.enabled": true, "config.auto_prune": true }, None, ) .await .context("failed to get servers from db")? .into_iter() .map(|server| async move { ( async { periphery_client(&server)?.request(PruneImages {}).await } .await, server, ) }) .collect::>(); while let Some((res, server)) = futures.next().await { if let Err(e) = res { error!( "failed to prune images on server {} ({}) | {e:#}", server.name, server.id ) } } Ok(()) } async fn prune_stats() -> anyhow::Result<()> { if core_config().keep_stats_for_days == 0 { return Ok(()); } let delete_before_ts = (unix_timestamp_ms() - core_config().keep_stats_for_days as u128 * ONE_DAY_MS) as i64; let res = db_client() .stats .delete_many(doc! { "ts": { "$lt": delete_before_ts } }) .await?; if res.deleted_count > 0 { info!("deleted {} stats from db", res.deleted_count); } Ok(()) } async fn prune_alerts() -> anyhow::Result<()> { if core_config().keep_alerts_for_days == 0 { return Ok(()); } let delete_before_ts = (unix_timestamp_ms() - core_config().keep_alerts_for_days as u128 * ONE_DAY_MS) as i64; let res = db_client() .alerts .delete_many(doc! { "ts": { "$lt": delete_before_ts } }) .await?; if res.deleted_count > 0 { info!("deleted {} alerts from db", res.deleted_count); } Ok(()) } ================================================ FILE: bin/core/src/helpers/query.rs ================================================ use std::{ collections::HashMap, str::FromStr, sync::{Arc, OnceLock}, }; use anyhow::{Context, anyhow}; use async_timing_util::{ONE_MIN_MS, unix_timestamp_ms}; use database::mungos::{ find::find_collect, mongodb::{ bson::{Document, doc, oid::ObjectId}, options::FindOneOptions, }, }; use komodo_client::{ busy::Busy, entities::{ Operation, ResourceTarget, ResourceTargetVariant, action::{Action, ActionState}, alerter::Alerter, build::Build, builder::Builder, deployment::{Deployment, DeploymentState}, docker::container::{ ContainerListItem, ContainerStateStatusEnum, }, permission::{PermissionLevel, PermissionLevelAndSpecifics}, procedure::{Procedure, ProcedureState}, repo::Repo, server::{Server, ServerState}, stack::{Stack, StackServiceNames, StackState}, stats::SystemInformation, sync::ResourceSync, tag::Tag, update::Update, user::{User, admin_service_user}, user_group::UserGroup, variable::Variable, }, }; use periphery_client::api::stats; use tokio::sync::Mutex; use crate::{ config::core_config, permission::get_user_permission_on_resource, resource::{self, KomodoResource}, stack::compose_container_match_regex, state::{ action_state_cache, action_states, db_client, deployment_status_cache, procedure_state_cache, stack_status_cache, }, }; use super::periphery_client; // user: Id or username #[instrument(level = "debug")] pub async fn get_user(user: &str) -> anyhow::Result { if let Some(user) = admin_service_user(user) { return Ok(user); } db_client() .users .find_one(id_or_username_filter(user)) .await .context("failed to query mongo for user")? .with_context(|| format!("no user found with {user}")) } #[instrument(level = "debug")] pub async fn get_server_with_state( server_id_or_name: &str, ) -> anyhow::Result<(Server, ServerState)> { let server = resource::get::(server_id_or_name).await?; let state = get_server_state(&server).await; Ok((server, state)) } #[instrument(level = "debug")] pub async fn get_server_state(server: &Server) -> ServerState { if !server.config.enabled { return ServerState::Disabled; } // Unwrap ok: Server disabled check above match super::periphery_client(server) .unwrap() .request(periphery_client::api::GetHealth {}) .await { Ok(_) => ServerState::Ok, Err(_) => ServerState::NotOk, } } #[instrument(level = "debug")] pub async fn get_deployment_state( id: &String, ) -> anyhow::Result { if action_states() .deployment .get(id) .await .map(|s| s.get().map(|s| s.deploying)) .transpose() .ok() .flatten() .unwrap_or_default() { return Ok(DeploymentState::Deploying); } let state = deployment_status_cache() .get(id) .await .unwrap_or_default() .curr .state; Ok(state) } /// Can pass all the containers from the same server pub fn get_stack_state_from_containers( ignore_services: &[String], services: &[StackServiceNames], containers: &[ContainerListItem], ) -> StackState { // first filter the containers to only ones which match the service let services = services .iter() .filter(|service| { !ignore_services.contains(&service.service_name) }) .collect::>(); let containers = containers.iter().filter(|container| { services.iter().any(|StackServiceNames { service_name, container_name, .. }| { match compose_container_match_regex(container_name) .with_context(|| format!("failed to construct container name matching regex for service {service_name}")) { Ok(regex) => regex, Err(e) => { warn!("{e:#}"); return false } }.is_match(&container.name) }) }).collect::>(); if containers.is_empty() { return StackState::Down; } if services.len() > containers.len() { return StackState::Unhealthy; } let running = containers.iter().all(|container| { container.state == ContainerStateStatusEnum::Running }); if running { return StackState::Running; } let paused = containers.iter().all(|container| { container.state == ContainerStateStatusEnum::Paused }); if paused { return StackState::Paused; } let stopped = containers.iter().all(|container| { container.state == ContainerStateStatusEnum::Exited }); if stopped { return StackState::Stopped; } let restarting = containers.iter().all(|container| { container.state == ContainerStateStatusEnum::Restarting }); if restarting { return StackState::Restarting; } let dead = containers.iter().all(|container| { container.state == ContainerStateStatusEnum::Dead }); if dead { return StackState::Dead; } let removing = containers.iter().all(|container| { container.state == ContainerStateStatusEnum::Removing }); if removing { return StackState::Removing; } StackState::Unhealthy } #[instrument(level = "debug")] pub async fn get_stack_state( stack: &Stack, ) -> anyhow::Result { if stack.config.server_id.is_empty() { return Ok(StackState::Down); } let state = stack_status_cache() .get(&stack.id) .await .unwrap_or_default() .curr .state; Ok(state) } #[instrument(level = "debug")] pub async fn get_tag(id_or_name: &str) -> anyhow::Result { let query = match ObjectId::from_str(id_or_name) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": id_or_name }, }; db_client() .tags .find_one(query) .await .context("failed to query mongo for tag")? .with_context(|| format!("no tag found matching {id_or_name}")) } #[instrument(level = "debug")] pub async fn get_tag_check_owner( id_or_name: &str, user: &User, ) -> anyhow::Result { let tag = get_tag(id_or_name).await?; if user.admin || tag.owner == user.id { return Ok(tag); } Err(anyhow!("user must be tag owner or admin")) } pub async fn get_all_tags( filter: impl Into>, ) -> anyhow::Result> { find_collect(&db_client().tags, filter, None) .await .context("failed to query db for tags") } pub async fn get_id_to_tags( filter: impl Into>, ) -> anyhow::Result> { let res = find_collect(&db_client().tags, filter, None) .await .context("failed to query db for tags")? .into_iter() .map(|tag| (tag.id.clone(), tag)) .collect(); Ok(res) } #[instrument(level = "debug")] pub async fn get_user_user_groups( user_id: &str, ) -> anyhow::Result> { find_collect( &db_client().user_groups, doc! { "$or": [ { "everyone": true }, { "users": user_id }, ] }, None, ) .await .context("failed to query db for user groups") } #[instrument(level = "debug")] pub async fn get_user_user_group_ids( user_id: &str, ) -> anyhow::Result> { let res = get_user_user_groups(user_id) .await? .into_iter() .map(|ug| ug.id) .collect(); Ok(res) } pub fn user_target_query( user_id: &str, user_groups: &[UserGroup], ) -> anyhow::Result> { let mut user_target_query = vec![ doc! { "user_target.type": "User", "user_target.id": user_id }, ]; let user_groups = user_groups.iter().map(|ug| { doc! { "user_target.type": "UserGroup", "user_target.id": &ug.id, } }); user_target_query.extend(user_groups); Ok(user_target_query) } pub async fn get_user_permission_on_target( user: &User, target: &ResourceTarget, ) -> anyhow::Result { match target { ResourceTarget::System(_) => Ok(PermissionLevel::None.into()), ResourceTarget::Build(id) => { get_user_permission_on_resource::(user, id).await } ResourceTarget::Builder(id) => { get_user_permission_on_resource::(user, id).await } ResourceTarget::Deployment(id) => { get_user_permission_on_resource::(user, id).await } ResourceTarget::Server(id) => { get_user_permission_on_resource::(user, id).await } ResourceTarget::Repo(id) => { get_user_permission_on_resource::(user, id).await } ResourceTarget::Alerter(id) => { get_user_permission_on_resource::(user, id).await } ResourceTarget::Procedure(id) => { get_user_permission_on_resource::(user, id).await } ResourceTarget::Action(id) => { get_user_permission_on_resource::(user, id).await } ResourceTarget::ResourceSync(id) => { get_user_permission_on_resource::(user, id).await } ResourceTarget::Stack(id) => { get_user_permission_on_resource::(user, id).await } } } pub fn id_or_name_filter(id_or_name: &str) -> Document { match ObjectId::from_str(id_or_name) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "name": id_or_name }, } } pub fn id_or_username_filter(id_or_username: &str) -> Document { match ObjectId::from_str(id_or_username) { Ok(id) => doc! { "_id": id }, Err(_) => doc! { "username": id_or_username }, } } pub async fn get_variable(name: &str) -> anyhow::Result { db_client() .variables .find_one(doc! { "name": &name }) .await .context("failed at call to db")? .with_context(|| { format!("no variable found with given name: {name}") }) } pub async fn get_latest_update( resource_type: ResourceTargetVariant, id: &str, operation: Operation, ) -> anyhow::Result> { db_client() .updates .find_one(doc! { "target.type": resource_type.as_ref(), "target.id": id, "operation": operation.as_ref() }) .with_options( FindOneOptions::builder() .sort(doc! { "start_ts": -1 }) .build(), ) .await .context("failed to query db for latest update") } pub struct VariablesAndSecrets { pub variables: HashMap, pub secrets: HashMap, } pub async fn get_variables_and_secrets() -> anyhow::Result { let variables = find_collect(&db_client().variables, None, None) .await .context("failed to get all variables from db")?; let mut secrets = core_config().secrets.clone(); // extend secrets with secret variables secrets.extend( variables.iter().filter(|variable| variable.is_secret).map( |variable| (variable.name.clone(), variable.value.clone()), ), ); // collect non secret variables let variables = variables .into_iter() .filter(|variable| !variable.is_secret) .map(|variable| (variable.name, variable.value)) .collect(); Ok(VariablesAndSecrets { variables, secrets }) } // This protects the peripheries from spam requests const SYSTEM_INFO_EXPIRY: u128 = ONE_MIN_MS; type SystemInfoCache = Mutex>>; fn system_info_cache() -> &'static SystemInfoCache { static SYSTEM_INFO_CACHE: OnceLock = OnceLock::new(); SYSTEM_INFO_CACHE.get_or_init(Default::default) } pub async fn get_system_info( server: &Server, ) -> anyhow::Result { let mut lock = system_info_cache().lock().await; let res = match lock.get(&server.id) { Some(cached) if cached.1 > unix_timestamp_ms() => { cached.0.clone() } _ => { let stats = periphery_client(server)? .request(stats::GetSystemInformation {}) .await?; lock.insert( server.id.clone(), (stats.clone(), unix_timestamp_ms() + SYSTEM_INFO_EXPIRY) .into(), ); stats } }; Ok(res) } /// Get last time procedure / action was run using Update query. /// Ignored whether run was successful. pub async fn get_last_run_at( id: &String, ) -> anyhow::Result> { let resource_type = R::resource_type(); let res = db_client() .updates .find_one(doc! { "target.type": resource_type.as_ref(), "target.id": id, "operation": format!("Run{resource_type}"), "status": "Complete" }) .sort(doc! { "start_ts": -1 }) .await .context("Failed to query updates collection for last run time")? .map(|u| u.start_ts); Ok(res) } pub async fn get_action_state(id: &String) -> ActionState { if action_states() .action .get(id) .await .map(|s| s.get().map(|s| s.busy())) .transpose() .ok() .flatten() .unwrap_or_default() { return ActionState::Running; } action_state_cache().get(id).await.unwrap_or_default() } pub async fn get_procedure_state(id: &String) -> ProcedureState { if action_states() .procedure .get(id) .await .map(|s| s.get().map(|s| s.busy())) .transpose() .ok() .flatten() .unwrap_or_default() { return ProcedureState::Running; } procedure_state_cache().get(id).await.unwrap_or_default() } ================================================ FILE: bin/core/src/helpers/update.rs ================================================ use anyhow::Context; use database::mungos::{ by_id::{find_one_by_id, update_one_by_id}, mongodb::bson::to_document, }; use komodo_client::entities::{ Operation, ResourceTarget, action::Action, alerter::Alerter, build::Build, deployment::Deployment, komodo_timestamp, procedure::Procedure, repo::Repo, server::Server, stack::Stack, sync::ResourceSync, update::{Update, UpdateListItem}, user::User, }; use crate::{ api::execute::ExecuteRequest, resource, state::db_client, }; use super::channel::update_channel; pub fn make_update( target: impl Into, operation: Operation, user: &User, ) -> Update { Update { start_ts: komodo_timestamp(), target: target.into(), operation, operator: user.id.clone(), success: true, ..Default::default() } } #[instrument(level = "debug")] pub async fn add_update( mut update: Update, ) -> anyhow::Result { update.id = db_client() .updates .insert_one(&update) .await .context("failed to insert update into db")? .inserted_id .as_object_id() .context("inserted_id is not object id")? .to_string(); let id = update.id.clone(); let update = update_list_item(update).await?; let _ = send_update(update).await; Ok(id) } #[instrument(level = "debug")] pub async fn add_update_without_send( update: &Update, ) -> anyhow::Result { let id = db_client() .updates .insert_one(update) .await .context("failed to insert update into db")? .inserted_id .as_object_id() .context("inserted_id is not object id")? .to_string(); Ok(id) } #[instrument(level = "debug")] pub async fn update_update(update: Update) -> anyhow::Result<()> { update_one_by_id(&db_client().updates, &update.id, database::mungos::update::Update::Set(to_document(&update)?), None) .await .context("failed to update the update on db. the update build process was deleted")?; let update = update_list_item(update).await?; let _ = send_update(update).await; Ok(()) } #[instrument(level = "debug")] async fn update_list_item( update: Update, ) -> anyhow::Result { let username = if User::is_service_user(&update.operator) { update.operator.clone() } else { find_one_by_id(&db_client().users, &update.operator) .await .context("failed to query mongo for user")? .with_context(|| { format!("no user found with id {}", update.operator) })? .username }; let update = UpdateListItem { id: update.id, operation: update.operation, start_ts: update.start_ts, success: update.success, operator: update.operator, target: update.target, status: update.status, version: update.version, other_data: update.other_data, username, }; Ok(update) } #[instrument(level = "debug")] async fn send_update(update: UpdateListItem) -> anyhow::Result<()> { update_channel().sender.lock().await.send(update)?; Ok(()) } pub async fn init_execution_update( request: &ExecuteRequest, user: &User, ) -> anyhow::Result { let (operation, target) = match &request { // Server ExecuteRequest::StartContainer(data) => ( Operation::StartContainer, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::RestartContainer(data) => ( Operation::RestartContainer, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::PauseContainer(data) => ( Operation::PauseContainer, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::UnpauseContainer(data) => ( Operation::UnpauseContainer, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::StopContainer(data) => ( Operation::StopContainer, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::DestroyContainer(data) => ( Operation::DestroyContainer, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::StartAllContainers(data) => ( Operation::StartAllContainers, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::RestartAllContainers(data) => ( Operation::RestartAllContainers, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::PauseAllContainers(data) => ( Operation::PauseAllContainers, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::UnpauseAllContainers(data) => ( Operation::UnpauseAllContainers, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::StopAllContainers(data) => ( Operation::StopAllContainers, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::PruneContainers(data) => ( Operation::PruneContainers, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::DeleteNetwork(data) => ( Operation::DeleteNetwork, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::PruneNetworks(data) => ( Operation::PruneNetworks, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::DeleteImage(data) => ( Operation::DeleteImage, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::PruneImages(data) => ( Operation::PruneImages, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::DeleteVolume(data) => ( Operation::DeleteVolume, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::PruneVolumes(data) => ( Operation::PruneVolumes, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::PruneDockerBuilders(data) => ( Operation::PruneDockerBuilders, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::PruneBuildx(data) => ( Operation::PruneBuildx, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), ExecuteRequest::PruneSystem(data) => ( Operation::PruneSystem, ResourceTarget::Server( resource::get::(&data.server).await?.id, ), ), // Deployment ExecuteRequest::Deploy(data) => ( Operation::Deploy, ResourceTarget::Deployment( resource::get::(&data.deployment).await?.id, ), ), ExecuteRequest::BatchDeploy(_data) => { return Ok(Default::default()); } ExecuteRequest::PullDeployment(data) => ( Operation::PullDeployment, ResourceTarget::Deployment( resource::get::(&data.deployment).await?.id, ), ), ExecuteRequest::StartDeployment(data) => ( Operation::StartDeployment, ResourceTarget::Deployment( resource::get::(&data.deployment).await?.id, ), ), ExecuteRequest::RestartDeployment(data) => ( Operation::RestartDeployment, ResourceTarget::Deployment( resource::get::(&data.deployment).await?.id, ), ), ExecuteRequest::PauseDeployment(data) => ( Operation::PauseDeployment, ResourceTarget::Deployment( resource::get::(&data.deployment).await?.id, ), ), ExecuteRequest::UnpauseDeployment(data) => ( Operation::UnpauseDeployment, ResourceTarget::Deployment( resource::get::(&data.deployment).await?.id, ), ), ExecuteRequest::StopDeployment(data) => ( Operation::StopDeployment, ResourceTarget::Deployment( resource::get::(&data.deployment).await?.id, ), ), ExecuteRequest::DestroyDeployment(data) => ( Operation::DestroyDeployment, ResourceTarget::Deployment( resource::get::(&data.deployment).await?.id, ), ), ExecuteRequest::BatchDestroyDeployment(_data) => { return Ok(Default::default()); } // Build ExecuteRequest::RunBuild(data) => ( Operation::RunBuild, ResourceTarget::Build( resource::get::(&data.build).await?.id, ), ), ExecuteRequest::BatchRunBuild(_data) => { return Ok(Default::default()); } ExecuteRequest::CancelBuild(data) => ( Operation::CancelBuild, ResourceTarget::Build( resource::get::(&data.build).await?.id, ), ), // Repo ExecuteRequest::CloneRepo(data) => ( Operation::CloneRepo, ResourceTarget::Repo( resource::get::(&data.repo).await?.id, ), ), ExecuteRequest::BatchCloneRepo(_data) => { return Ok(Default::default()); } ExecuteRequest::PullRepo(data) => ( Operation::PullRepo, ResourceTarget::Repo( resource::get::(&data.repo).await?.id, ), ), ExecuteRequest::BatchPullRepo(_data) => { return Ok(Default::default()); } ExecuteRequest::BuildRepo(data) => ( Operation::BuildRepo, ResourceTarget::Repo( resource::get::(&data.repo).await?.id, ), ), ExecuteRequest::BatchBuildRepo(_data) => { return Ok(Default::default()); } ExecuteRequest::CancelRepoBuild(data) => ( Operation::CancelRepoBuild, ResourceTarget::Repo( resource::get::(&data.repo).await?.id, ), ), // Procedure ExecuteRequest::RunProcedure(data) => ( Operation::RunProcedure, ResourceTarget::Procedure( resource::get::(&data.procedure).await?.id, ), ), ExecuteRequest::BatchRunProcedure(_) => { return Ok(Default::default()); } // Action ExecuteRequest::RunAction(data) => ( Operation::RunAction, ResourceTarget::Action( resource::get::(&data.action).await?.id, ), ), ExecuteRequest::BatchRunAction(_) => { return Ok(Default::default()); } // Resource Sync ExecuteRequest::RunSync(data) => ( Operation::RunSync, ResourceTarget::ResourceSync( resource::get::(&data.sync).await?.id, ), ), // Stack ExecuteRequest::DeployStack(data) => ( if !data.services.is_empty() { Operation::DeployStackService } else { Operation::DeployStack }, ResourceTarget::Stack( resource::get::(&data.stack).await?.id, ), ), ExecuteRequest::BatchDeployStack(_data) => { return Ok(Default::default()); } ExecuteRequest::DeployStackIfChanged(data) => ( Operation::DeployStack, ResourceTarget::Stack( resource::get::(&data.stack).await?.id, ), ), ExecuteRequest::BatchDeployStackIfChanged(_data) => { return Ok(Default::default()); } ExecuteRequest::StartStack(data) => ( if !data.services.is_empty() { Operation::StartStackService } else { Operation::StartStack }, ResourceTarget::Stack( resource::get::(&data.stack).await?.id, ), ), ExecuteRequest::PullStack(data) => ( if !data.services.is_empty() { Operation::PullStackService } else { Operation::PullStack }, ResourceTarget::Stack( resource::get::(&data.stack).await?.id, ), ), ExecuteRequest::BatchPullStack(_data) => { return Ok(Default::default()); } ExecuteRequest::RestartStack(data) => ( if !data.services.is_empty() { Operation::RestartStackService } else { Operation::RestartStack }, ResourceTarget::Stack( resource::get::(&data.stack).await?.id, ), ), ExecuteRequest::PauseStack(data) => ( if !data.services.is_empty() { Operation::PauseStackService } else { Operation::PauseStack }, ResourceTarget::Stack( resource::get::(&data.stack).await?.id, ), ), ExecuteRequest::UnpauseStack(data) => ( if !data.services.is_empty() { Operation::UnpauseStackService } else { Operation::UnpauseStack }, ResourceTarget::Stack( resource::get::(&data.stack).await?.id, ), ), ExecuteRequest::StopStack(data) => ( if !data.services.is_empty() { Operation::StopStackService } else { Operation::StopStack }, ResourceTarget::Stack( resource::get::(&data.stack).await?.id, ), ), ExecuteRequest::DestroyStack(data) => ( if !data.services.is_empty() { Operation::DestroyStackService } else { Operation::DestroyStack }, ResourceTarget::Stack( resource::get::(&data.stack).await?.id, ), ), ExecuteRequest::BatchDestroyStack(_data) => { return Ok(Default::default()); } ExecuteRequest::RunStackService(data) => ( Operation::RunStackService, ResourceTarget::Stack( resource::get::(&data.stack).await?.id, ), ), // Alerter ExecuteRequest::TestAlerter(data) => ( Operation::TestAlerter, ResourceTarget::Alerter( resource::get::(&data.alerter).await?.id, ), ), ExecuteRequest::SendAlert(_) => { (Operation::SendAlert, ResourceTarget::system()) } // Maintenance ExecuteRequest::ClearRepoCache(_data) => { (Operation::ClearRepoCache, ResourceTarget::system()) } ExecuteRequest::BackupCoreDatabase(_data) => { (Operation::BackupCoreDatabase, ResourceTarget::system()) } ExecuteRequest::GlobalAutoUpdate(_data) => { (Operation::GlobalAutoUpdate, ResourceTarget::system()) } }; let mut update = make_update(target, operation, user); update.in_progress(); // Hold off on even adding update for DeployStackIfChanged if !matches!(&request, ExecuteRequest::DeployStackIfChanged(_)) { // Don't actually send it here, let the handlers send it after they can set action state. update.id = add_update_without_send(&update).await?; } Ok(update) } ================================================ FILE: bin/core/src/listener/integrations/github.rs ================================================ use anyhow::{Context, anyhow}; use axum::http::HeaderMap; use hex::ToHex; use hmac::{Hmac, Mac}; use serde::Deserialize; use sha2::Sha256; use crate::{ config::core_config, listener::{ExtractBranch, VerifySecret}, }; type HmacSha256 = Hmac; /// Listener implementation for Github type API, including Gitea pub struct Github; impl VerifySecret for Github { #[instrument("VerifyGithubSecret", skip_all)] fn verify_secret( headers: HeaderMap, body: &str, custom_secret: &str, ) -> anyhow::Result<()> { let signature = headers .get("x-hub-signature-256") .context("No github signature in headers")?; let signature = signature .to_str() .context("Failed to get signature as string")?; let signature = signature.strip_prefix("sha256=").unwrap_or(signature); let secret_bytes = if custom_secret.is_empty() { core_config().webhook_secret.as_bytes() } else { custom_secret.as_bytes() }; let mut mac = HmacSha256::new_from_slice(secret_bytes) .context("Failed to create hmac sha256 from secret")?; mac.update(body.as_bytes()); let expected = mac.finalize().into_bytes().encode_hex::(); if signature == expected { Ok(()) } else { Err(anyhow!("Signature does not equal expected")) } } } #[derive(Deserialize)] struct GithubWebhookBody { #[serde(rename = "ref")] branch: String, } impl ExtractBranch for Github { fn extract_branch(body: &str) -> anyhow::Result { let branch = serde_json::from_str::(body) .context("Failed to parse github request body")? .branch .replace("refs/heads/", ""); Ok(branch) } } ================================================ FILE: bin/core/src/listener/integrations/gitlab.rs ================================================ use anyhow::{Context, anyhow}; use serde::Deserialize; use crate::{ config::core_config, listener::{ExtractBranch, VerifySecret}, }; /// Listener implementation for Gitlab type API pub struct Gitlab; impl VerifySecret for Gitlab { #[instrument("VerifyGitlabSecret", skip_all)] fn verify_secret( headers: axum::http::HeaderMap, _body: &str, custom_secret: &str, ) -> anyhow::Result<()> { let token = headers .get("x-gitlab-token") .context("No gitlab token in headers")?; let token = token.to_str().context("Failed to get token as string")?; let secret = if custom_secret.is_empty() { core_config().webhook_secret.as_str() } else { custom_secret }; if token == secret { Ok(()) } else { Err(anyhow!("Webhook secret does not match expected.")) } } } #[derive(Deserialize)] struct GitlabWebhookBody { #[serde(rename = "ref")] branch: String, } impl ExtractBranch for Gitlab { fn extract_branch(body: &str) -> anyhow::Result { let branch = serde_json::from_str::(body) .context("Failed to parse gitlab request body")? .branch .replace("refs/heads/", ""); Ok(branch) } } ================================================ FILE: bin/core/src/listener/integrations/mod.rs ================================================ pub mod github; pub mod gitlab; ================================================ FILE: bin/core/src/listener/mod.rs ================================================ use std::sync::Arc; use anyhow::anyhow; use axum::{Router, http::HeaderMap}; use komodo_client::entities::resource::Resource; use tokio::sync::Mutex; use crate::{helpers::cache::Cache, resource::KomodoResource}; mod integrations; mod resources; mod router; use integrations::*; pub fn router() -> Router { Router::new() .nest("/github", router::router::()) .nest("/gitlab", router::router::()) } type ListenerLockCache = Cache>>; /// Implemented for all resources which can recieve webhook. trait CustomSecret: KomodoResource { fn custom_secret( resource: &Resource, ) -> &str; } /// Implemented on the integration struct, eg [integrations::github::Github] trait VerifySecret { fn verify_secret( headers: HeaderMap, body: &str, custom_secret: &str, ) -> anyhow::Result<()>; } /// Implemented on the integration struct, eg [integrations::github::Github] trait ExtractBranch { fn extract_branch(body: &str) -> anyhow::Result; fn verify_branch(body: &str, expected: &str) -> anyhow::Result<()> { let branch = Self::extract_branch(body)?; if branch == expected { Ok(()) } else { Err(anyhow!("request branch does not match expected")) } } } /// For Procedures and Actions, incoming webhook /// can be triggered by any branch by using `__ANY__` /// as the branch in the webhook URL. const ANY_BRANCH: &str = "__ANY__"; ================================================ FILE: bin/core/src/listener/resources.rs ================================================ use std::{str::FromStr, sync::OnceLock}; use anyhow::{Context, anyhow}; use komodo_client::{ api::{ execute::*, write::{RefreshResourceSyncPending, RefreshStackCache}, }, entities::{ action::Action, build::Build, procedure::Procedure, repo::Repo, stack::Stack, sync::ResourceSync, user::git_webhook_user, }, }; use resolver_api::Resolve; use serde::Deserialize; use serde_json::json; use crate::{ api::{ execute::{ExecuteArgs, ExecuteRequest}, write::WriteArgs, }, helpers::update::init_execution_update, }; use super::{ANY_BRANCH, ListenerLockCache}; // ======= // BUILD // ======= impl super::CustomSecret for Build { fn custom_secret(resource: &Self) -> &str { &resource.config.webhook_secret } } fn build_locks() -> &'static ListenerLockCache { static BUILD_LOCKS: OnceLock = OnceLock::new(); BUILD_LOCKS.get_or_init(Default::default) } pub async fn handle_build_webhook( build: Build, body: String, ) -> anyhow::Result<()> { if !build.config.webhook_enabled { return Ok(()); } // Acquire and hold lock to make a task queue for // subsequent listener calls on same resource. // It would fail if we let it go through from action state busy. let lock = build_locks().get_or_insert_default(&build.id).await; let _lock = lock.lock().await; B::verify_branch(&body, &build.config.branch)?; let user = git_webhook_user().to_owned(); let req = ExecuteRequest::RunBuild(RunBuild { build: build.id }); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::RunBuild(req) = req else { unreachable!() }; req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error)?; Ok(()) } // ====== // REPO // ====== impl super::CustomSecret for Repo { fn custom_secret(resource: &Self) -> &str { &resource.config.webhook_secret } } fn repo_locks() -> &'static ListenerLockCache { static REPO_LOCKS: OnceLock = OnceLock::new(); REPO_LOCKS.get_or_init(Default::default) } pub trait RepoExecution { async fn resolve(repo: Repo) -> anyhow::Result<()>; } impl RepoExecution for CloneRepo { async fn resolve(repo: Repo) -> anyhow::Result<()> { let user = git_webhook_user().to_owned(); let req = crate::api::execute::ExecuteRequest::CloneRepo(CloneRepo { repo: repo.id, }); let update = init_execution_update(&req, &user).await?; let crate::api::execute::ExecuteRequest::CloneRepo(req) = req else { unreachable!() }; req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error)?; Ok(()) } } impl RepoExecution for PullRepo { async fn resolve(repo: Repo) -> anyhow::Result<()> { let user = git_webhook_user().to_owned(); let req = crate::api::execute::ExecuteRequest::PullRepo(PullRepo { repo: repo.id, }); let update = init_execution_update(&req, &user).await?; let crate::api::execute::ExecuteRequest::PullRepo(req) = req else { unreachable!() }; req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error)?; Ok(()) } } impl RepoExecution for BuildRepo { async fn resolve(repo: Repo) -> anyhow::Result<()> { let user = git_webhook_user().to_owned(); let req = crate::api::execute::ExecuteRequest::BuildRepo(BuildRepo { repo: repo.id, }); let update = init_execution_update(&req, &user).await?; let crate::api::execute::ExecuteRequest::BuildRepo(req) = req else { unreachable!() }; req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error)?; Ok(()) } } #[derive(Deserialize)] #[serde(rename_all = "lowercase")] pub enum RepoWebhookOption { Clone, Pull, Build, } pub async fn handle_repo_webhook( option: RepoWebhookOption, repo: Repo, body: String, ) -> anyhow::Result<()> { match option { RepoWebhookOption::Clone => { handle_repo_webhook_inner::(repo, body).await } RepoWebhookOption::Pull => { handle_repo_webhook_inner::(repo, body).await } RepoWebhookOption::Build => { handle_repo_webhook_inner::(repo, body).await } } } async fn handle_repo_webhook_inner< B: super::ExtractBranch, E: RepoExecution, >( repo: Repo, body: String, ) -> anyhow::Result<()> { if !repo.config.webhook_enabled { return Ok(()); } // Acquire and hold lock to make a task queue for // subsequent listener calls on same resource. // It would fail if we let it go through from action state busy. let lock = repo_locks().get_or_insert_default(&repo.id).await; let _lock = lock.lock().await; B::verify_branch(&body, &repo.config.branch)?; E::resolve(repo).await } // ======= // STACK // ======= impl super::CustomSecret for Stack { fn custom_secret(resource: &Self) -> &str { &resource.config.webhook_secret } } fn stack_locks() -> &'static ListenerLockCache { static STACK_LOCKS: OnceLock = OnceLock::new(); STACK_LOCKS.get_or_init(Default::default) } pub trait StackExecution { async fn resolve(stack: Stack) -> serror::Result<()>; } impl StackExecution for RefreshStackCache { async fn resolve(stack: Stack) -> serror::Result<()> { RefreshStackCache { stack: stack.id } .resolve(&WriteArgs { user: git_webhook_user().to_owned(), }) .await?; Ok(()) } } impl StackExecution for DeployStack { async fn resolve(stack: Stack) -> serror::Result<()> { let user = git_webhook_user().to_owned(); if stack.config.webhook_force_deploy { let req = ExecuteRequest::DeployStack(DeployStack { stack: stack.id, services: Vec::new(), stop_time: None, }); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::DeployStack(req) = req else { unreachable!() }; req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error)?; } else { let req = ExecuteRequest::DeployStackIfChanged(DeployStackIfChanged { stack: stack.id, stop_time: None, }); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::DeployStackIfChanged(req) = req else { unreachable!() }; req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error)?; } Ok(()) } } #[derive(Deserialize)] #[serde(rename_all = "lowercase")] pub enum StackWebhookOption { Refresh, Deploy, } pub async fn handle_stack_webhook( option: StackWebhookOption, stack: Stack, body: String, ) -> anyhow::Result<()> { match option { StackWebhookOption::Refresh => { handle_stack_webhook_inner::(stack, body) .await } StackWebhookOption::Deploy => { handle_stack_webhook_inner::(stack, body).await } } } pub async fn handle_stack_webhook_inner< B: super::ExtractBranch, E: StackExecution, >( stack: Stack, body: String, ) -> anyhow::Result<()> { if !stack.config.webhook_enabled { return Ok(()); } // Acquire and hold lock to make a task queue for // subsequent listener calls on same resource. // It would fail if we let it go through, from "action state busy". let lock = stack_locks().get_or_insert_default(&stack.id).await; let _lock = lock.lock().await; B::verify_branch(&body, &stack.config.branch)?; E::resolve(stack).await.map_err(|e| e.error) } // ====== // SYNC // ====== impl super::CustomSecret for ResourceSync { fn custom_secret(resource: &Self) -> &str { &resource.config.webhook_secret } } fn sync_locks() -> &'static ListenerLockCache { static SYNC_LOCKS: OnceLock = OnceLock::new(); SYNC_LOCKS.get_or_init(Default::default) } pub trait SyncExecution { async fn resolve(sync: ResourceSync) -> anyhow::Result<()>; } impl SyncExecution for RefreshResourceSyncPending { async fn resolve(sync: ResourceSync) -> anyhow::Result<()> { RefreshResourceSyncPending { sync: sync.id } .resolve(&WriteArgs { user: git_webhook_user().to_owned(), }) .await .map_err(|e| e.error)?; Ok(()) } } impl SyncExecution for RunSync { async fn resolve(sync: ResourceSync) -> anyhow::Result<()> { let user = git_webhook_user().to_owned(); let req = ExecuteRequest::RunSync(RunSync { sync: sync.id, resource_type: None, resources: None, }); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::RunSync(req) = req else { unreachable!() }; req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error)?; Ok(()) } } #[derive(Deserialize)] #[serde(rename_all = "lowercase")] pub enum SyncWebhookOption { Refresh, Sync, } pub async fn handle_sync_webhook( option: SyncWebhookOption, sync: ResourceSync, body: String, ) -> anyhow::Result<()> { match option { SyncWebhookOption::Refresh => { handle_sync_webhook_inner::( sync, body, ) .await } SyncWebhookOption::Sync => { handle_sync_webhook_inner::(sync, body).await } } } async fn handle_sync_webhook_inner< B: super::ExtractBranch, E: SyncExecution, >( sync: ResourceSync, body: String, ) -> anyhow::Result<()> { if !sync.config.webhook_enabled { return Ok(()); } // Acquire and hold lock to make a task queue for // subsequent listener calls on same resource. // It would fail if we let it go through from action state busy. let lock = sync_locks().get_or_insert_default(&sync.id).await; let _lock = lock.lock().await; B::verify_branch(&body, &sync.config.branch)?; E::resolve(sync).await } // =========== // PROCEDURE // =========== impl super::CustomSecret for Procedure { fn custom_secret(resource: &Self) -> &str { &resource.config.webhook_secret } } fn procedure_locks() -> &'static ListenerLockCache { static PROCEDURE_LOCKS: OnceLock = OnceLock::new(); PROCEDURE_LOCKS.get_or_init(Default::default) } pub async fn handle_procedure_webhook( procedure: Procedure, target_branch: &str, body: String, ) -> anyhow::Result<()> { if !procedure.config.webhook_enabled { return Ok(()); } // Acquire and hold lock to make a task queue for // subsequent listener calls on same resource. // It would fail if we let it go through from action state busy. let lock = procedure_locks().get_or_insert_default(&procedure.id).await; let _lock = lock.lock().await; if target_branch != ANY_BRANCH { B::verify_branch(&body, target_branch)?; } let user = git_webhook_user().to_owned(); let req = ExecuteRequest::RunProcedure(RunProcedure { procedure: procedure.id, }); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::RunProcedure(req) = req else { unreachable!() }; req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error)?; Ok(()) } // ======== // ACTION // ======== impl super::CustomSecret for Action { fn custom_secret(resource: &Self) -> &str { &resource.config.webhook_secret } } fn action_locks() -> &'static ListenerLockCache { static ACTION_LOCKS: OnceLock = OnceLock::new(); ACTION_LOCKS.get_or_init(Default::default) } pub async fn handle_action_webhook( action: Action, target_branch: &str, body: String, ) -> anyhow::Result<()> { if !action.config.webhook_enabled { return Ok(()); } // Acquire and hold lock to make a task queue for // subsequent listener calls on same resource. // It would fail if we let it go through from action state busy. let lock = action_locks().get_or_insert_default(&action.id).await; let _lock = lock.lock().await; let branch = B::extract_branch(&body)?; if target_branch != ANY_BRANCH && branch != target_branch { return Err(anyhow!("request branch does not match expected")); } let user = git_webhook_user().to_owned(); let body = serde_json::Value::from_str(&body) .context("Failed to deserialize webhook body")?; let serde_json::Value::Object(args) = json!({ "WEBHOOK_BRANCH": branch, "WEBHOOK_BODY": body, }) else { return Err(anyhow!("Something is wrong with serde_json...")); }; let req = ExecuteRequest::RunAction(RunAction { action: action.id, args: args.into(), }); let update = init_execution_update(&req, &user).await?; let ExecuteRequest::RunAction(req) = req else { unreachable!() }; req .resolve(&ExecuteArgs { user, update }) .await .map_err(|e| e.error)?; Ok(()) } ================================================ FILE: bin/core/src/listener/router.rs ================================================ use axum::{Router, extract::Path, http::HeaderMap, routing::post}; use komodo_client::entities::{ action::Action, build::Build, procedure::Procedure, repo::Repo, resource::Resource, stack::Stack, sync::ResourceSync, }; use reqwest::StatusCode; use serde::Deserialize; use serror::AddStatusCode; use tracing::Instrument; use crate::resource::KomodoResource; use super::{ CustomSecret, ExtractBranch, VerifySecret, resources::{ RepoWebhookOption, StackWebhookOption, SyncWebhookOption, handle_action_webhook, handle_build_webhook, handle_procedure_webhook, handle_repo_webhook, handle_stack_webhook, handle_sync_webhook, }, }; #[derive(Deserialize)] struct Id { id: String, } #[derive(Deserialize)] struct IdAndOption { id: String, option: T, } #[derive(Deserialize)] struct IdAndBranch { id: String, #[serde(default = "default_branch")] branch: String, } fn default_branch() -> String { String::from("main") } pub fn router() -> Router { Router::new() .route( "/build/{id}", post( |Path(Id { id }), headers: HeaderMap, body: String| async move { let build = auth_webhook::(&id, headers, &body).await?; tokio::spawn(async move { let span = info_span!("BuildWebhook", id); async { let res = handle_build_webhook::

( build, body, ) .await; if let Err(e) = res { warn!( "Failed at running webhook for build {id} | {e:#}" ); } } .instrument(span) .await }); serror::Result::Ok(()) }, ), ) .route( "/repo/{id}/{option}", post( |Path(IdAndOption:: { id, option }), headers: HeaderMap, body: String| async move { let repo = auth_webhook::(&id, headers, &body).await?; tokio::spawn(async move { let span = info_span!("RepoWebhook", id); async { let res = handle_repo_webhook::

( option, repo, body, ) .await; if let Err(e) = res { warn!( "Failed at running webhook for repo {id} | {e:#}" ); } } .instrument(span) .await }); serror::Result::Ok(()) }, ), ) .route( "/stack/{id}/{option}", post( |Path(IdAndOption:: { id, option }), headers: HeaderMap, body: String| async move { let stack = auth_webhook::(&id, headers, &body).await?; tokio::spawn(async move { let span = info_span!("StackWebhook", id); async { let res = handle_stack_webhook::

( option, stack, body, ) .await; if let Err(e) = res { warn!( "Failed at running webhook for stack {id} | {e:#}" ); } } .instrument(span) .await }); serror::Result::Ok(()) }, ), ) .route( "/sync/{id}/{option}", post( |Path(IdAndOption:: { id, option }), headers: HeaderMap, body: String| async move { let sync = auth_webhook::(&id, headers, &body).await?; tokio::spawn(async move { let span = info_span!("ResourceSyncWebhook", id); async { let res = handle_sync_webhook::

( option, sync, body, ) .await; if let Err(e) = res { warn!( "Failed at running webhook for resource sync {id} | {e:#}" ); } } .instrument(span) .await }); serror::Result::Ok(()) }, ), ) .route( "/procedure/{id}/{branch}", post( |Path(IdAndBranch { id, branch }), headers: HeaderMap, body: String| async move { let procedure = auth_webhook::(&id, headers, &body).await?; tokio::spawn(async move { let span = info_span!("ProcedureWebhook", id); async { let res = handle_procedure_webhook::

( procedure, &branch, body, ) .await; if let Err(e) = res { warn!( "Failed at running webhook for procedure {id} | target branch: {branch} | {e:#}" ); } } .instrument(span) .await }); serror::Result::Ok(()) }, ), ) .route( "/action/{id}/{branch}", post( |Path(IdAndBranch { id, branch }), headers: HeaderMap, body: String| async move { let action = auth_webhook::(&id, headers, &body).await?; tokio::spawn(async move { let span = info_span!("ActionWebhook", id); async { let res = handle_action_webhook::

( action, &branch, body, ) .await; if let Err(e) = res { warn!( "Failed at running webhook for action {id} | target branch: {branch} | {e:#}" ); } } .instrument(span) .await }); serror::Result::Ok(()) }, ), ) } async fn auth_webhook( id: &str, headers: HeaderMap, body: &str, ) -> serror::Result> where P: VerifySecret, R: KomodoResource + CustomSecret, { let resource = crate::resource::get::(id) .await .status_code(StatusCode::BAD_REQUEST)?; P::verify_secret(headers, body, R::custom_secret(&resource)) .status_code(StatusCode::UNAUTHORIZED)?; Ok(resource) } ================================================ FILE: bin/core/src/main.rs ================================================ #[macro_use] extern crate tracing; use std::{net::SocketAddr, str::FromStr}; use anyhow::Context; use axum::Router; use axum_server::{Handle, tls_rustls::RustlsConfig}; use tower_http::{ cors::{Any, CorsLayer}, services::{ServeDir, ServeFile}, }; use crate::config::core_config; mod alert; mod api; mod auth; mod cloud; mod config; mod helpers; mod listener; mod monitor; mod network; mod permission; mod resource; mod schedule; mod stack; mod startup; mod state; mod sync; mod ts_client; mod ws; async fn app() -> anyhow::Result<()> { dotenvy::dotenv().ok(); let config = core_config(); logger::init(&config.logging)?; if let Err(e) = rustls::crypto::aws_lc_rs::default_provider().install_default() { error!("Failed to install default crypto provider | {e:?}"); std::process::exit(1); }; info!("Komodo Core version: v{}", env!("CARGO_PKG_VERSION")); match ( config.pretty_startup_config, config.unsafe_unsanitized_startup_config, ) { (true, true) => info!("{:#?}", config), (true, false) => info!("{:#?}", config.sanitized()), (false, true) => info!("{:?}", config), (false, false) => info!("{:?}", config.sanitized()), } // Init jwt client to crash on failure state::jwt_client(); tokio::join!( // Init db_client check to crash on db init failure state::init_db_client(), // Manage OIDC client (defined in config / env vars / compose secret file) auth::oidc::client::spawn_oidc_client_management() ); // Run after db connection. startup::on_startup().await; // Spawn background tasks monitor::spawn_monitor_loop(); resource::spawn_resource_refresh_loop(); resource::spawn_all_resources_cache_refresh_loop(); resource::spawn_build_state_refresh_loop(); resource::spawn_repo_state_refresh_loop(); resource::spawn_procedure_state_refresh_loop(); resource::spawn_action_state_refresh_loop(); schedule::spawn_schedule_executor(); helpers::prune::spawn_prune_loop(); // Setup static frontend services let frontend_path = &config.frontend_path; let frontend_index = ServeFile::new(format!("{frontend_path}/index.html")); let serve_frontend = ServeDir::new(frontend_path) .not_found_service(frontend_index.clone()); let app = Router::new() .nest("/auth", api::auth::router()) .nest("/user", api::user::router()) .nest("/read", api::read::router()) .nest("/write", api::write::router()) .nest("/execute", api::execute::router()) .nest("/terminal", api::terminal::router()) .nest("/listener", listener::router()) .nest("/ws", ws::router()) .nest("/client", ts_client::router()) .fallback_service(serve_frontend) .layer( CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any), ) .into_make_service(); let addr = format!("{}:{}", core_config().bind_ip, core_config().port); let socket_addr = SocketAddr::from_str(&addr) .context("failed to parse listen address")?; let handle = Handle::new(); tokio::spawn({ // Cannot run actions until the server is available. // We can use a handle for the server, and wait until // the handle is listening before running actions let handle = handle.clone(); async move { handle.listening().await; startup::run_startup_actions().await; } }); if config.ssl_enabled { info!("🔒 Core SSL Enabled"); rustls::crypto::ring::default_provider() .install_default() .expect("failed to install default rustls CryptoProvider"); info!("Komodo Core starting on https://{socket_addr}"); let ssl_config = RustlsConfig::from_pem_file( &config.ssl_cert_file, &config.ssl_key_file, ) .await .context("Invalid ssl cert / key")?; axum_server::bind_rustls(socket_addr, ssl_config) .handle(handle) .serve(app) .await .context("failed to start https server") } else { info!("🔓 Core SSL Disabled"); info!("Komodo Core starting on http://{socket_addr}"); axum_server::bind(socket_addr) .handle(handle) .serve(app) .await .context("failed to start http server") } } #[tokio::main] async fn main() -> anyhow::Result<()> { let mut term_signal = tokio::signal::unix::signal( tokio::signal::unix::SignalKind::terminate(), )?; tokio::select! { res = tokio::spawn(app()) => res?, _ = term_signal.recv() => Ok(()), } } ================================================ FILE: bin/core/src/monitor/alert/deployment.rs ================================================ use std::collections::HashMap; use komodo_client::entities::{ ResourceTarget, alert::{Alert, AlertData, SeverityLevel}, deployment::{Deployment, DeploymentState}, }; use crate::{ alert::send_alerts, monitor::deployment_status_cache, resource, state::{action_states, db_client}, }; #[instrument(level = "debug")] pub async fn alert_deployments( ts: i64, server_names: &HashMap, ) { let mut alerts = Vec::::new(); let action_states = action_states(); for status in deployment_status_cache().get_list().await { // Don't alert if prev None let Some(prev) = status.prev else { continue; }; // Don't alert if either prev or curr is Unknown. // This will happen if server is unreachable, so this would be redundant. if status.curr.state == DeploymentState::Unknown || prev == DeploymentState::Unknown { continue; } // Don't alert if deploying if action_states .deployment .get(&status.curr.id) .await .map(|s| s.get().map(|s| s.deploying)) .transpose() .ok() .flatten() .unwrap_or_default() { continue; } if status.curr.state != prev { // send alert let Ok(deployment) = resource::get::(&status.curr.id) .await .inspect_err(|e| { error!("failed to get deployment from db | {e:#?}") }) else { continue; }; if !deployment.config.send_alerts { continue; } let target: ResourceTarget = (&deployment).into(); let data = AlertData::ContainerStateChange { id: status.curr.id.clone(), name: deployment.name, server_name: server_names .get(&deployment.config.server_id) .cloned() .unwrap_or(String::from("unknown")), server_id: deployment.config.server_id, from: prev, to: status.curr.state, }; let alert = Alert { id: Default::default(), level: SeverityLevel::Warning, resolved: true, resolved_ts: ts.into(), target, data, ts, }; alerts.push(alert); } } if alerts.is_empty() { return; } send_alerts(&alerts).await; let res = db_client().alerts.insert_many(alerts).await; if let Err(e) = res { error!("failed to record deployment status alerts to db | {e:#}"); } } ================================================ FILE: bin/core/src/monitor/alert/mod.rs ================================================ use std::collections::HashMap; use anyhow::Context; use komodo_client::entities::{ permission::PermissionLevel, resource::ResourceQuery, server::Server, user::User, }; use crate::resource; mod deployment; mod server; mod stack; // called after cache update #[instrument(level = "debug")] pub async fn check_alerts(ts: i64) { let (servers, server_names) = match get_all_servers_map().await { Ok(res) => res, Err(e) => { error!("{e:#?}"); return; } }; tokio::join!( server::alert_servers(ts, servers), deployment::alert_deployments(ts, &server_names), stack::alert_stacks(ts, &server_names) ); } #[instrument(level = "debug")] async fn get_all_servers_map() -> anyhow::Result<(HashMap, HashMap)> { let servers = resource::list_full_for_user::( ResourceQuery::default(), &User { admin: true, ..Default::default() }, PermissionLevel::Read.into(), &[], ) .await .context("failed to get servers from db (in alert_servers)")?; let servers = servers .into_iter() .map(|server| (server.id.clone(), server)) .collect::>(); let server_names = servers .iter() .map(|(id, server)| (id.clone(), server.name.clone())) .collect::>(); Ok((servers, server_names)) } ================================================ FILE: bin/core/src/monitor/alert/server.rs ================================================ use std::{ collections::HashMap, path::PathBuf, str::FromStr, sync::{Mutex, OnceLock}, }; use anyhow::Context; use database::mongo_indexed::Indexed; use database::mungos::{ bulk_update::{self, BulkUpdate}, find::find_collect, mongodb::bson::{doc, oid::ObjectId, to_bson}, }; use derive_variants::ExtractVariant; use komodo_client::entities::{ ResourceTarget, alert::{Alert, AlertData, AlertDataVariant, SeverityLevel}, komodo_timestamp, optional_string, server::{Server, ServerState}, }; use crate::{ alert::send_alerts, helpers::maintenance::is_in_maintenance, state::{db_client, server_status_cache}, }; type SendAlerts = bool; type OpenAlertMap = HashMap>; type OpenDiskAlertMap = OpenAlertMap; /// Alert buffer to prevent immediate alerts on transient issues struct AlertBuffer { buffer: Mutex>, } impl AlertBuffer { fn new() -> Self { Self { buffer: Mutex::new(HashMap::new()), } } /// Check if alert should be opened. Requires two consecutive calls to return true. fn ready_to_open( &self, server_id: String, variant: AlertDataVariant, ) -> bool { let mut lock = self.buffer.lock().unwrap(); let ready = lock.entry((server_id, variant)).or_default(); if *ready { *ready = false; true } else { *ready = true; false } } /// Reset buffer state for a specific server/alert combination fn reset(&self, server_id: String, variant: AlertDataVariant) { let mut lock = self.buffer.lock().unwrap(); lock.remove(&(server_id, variant)); } } /// Global alert buffer instance fn alert_buffer() -> &'static AlertBuffer { static BUFFER: OnceLock = OnceLock::new(); BUFFER.get_or_init(AlertBuffer::new) } #[instrument(level = "debug")] pub async fn alert_servers( ts: i64, mut servers: HashMap, ) { let server_statuses = server_status_cache().get_list().await; let (open_alerts, open_disk_alerts) = match get_open_alerts().await { Ok(alerts) => alerts, Err(e) => { error!("{e:#}"); return; } }; let mut alerts_to_open = Vec::<(Alert, SendAlerts)>::new(); let mut alerts_to_update = Vec::<(Alert, SendAlerts)>::new(); let mut alert_ids_to_close = Vec::<(Alert, SendAlerts)>::new(); let buffer = alert_buffer(); for server_status in server_statuses { let Some(server) = servers.remove(&server_status.id) else { continue; }; let server_alerts = open_alerts .get(&ResourceTarget::Server(server_status.id.clone())); // Check if server is in maintenance mode let in_maintenance = is_in_maintenance(&server.config.maintenance_windows, ts); // =================== // SERVER HEALTH // =================== let health_alert = server_alerts.as_ref().and_then(|alerts| { alerts.get(&AlertDataVariant::ServerUnreachable) }); match (server_status.state, health_alert) { (ServerState::NotOk, None) => { // Only open unreachable alert if not in maintenance and buffer is ready if !in_maintenance && buffer.ready_to_open( server_status.id.clone(), AlertDataVariant::ServerUnreachable, ) { let alert = Alert { id: Default::default(), ts, resolved: false, resolved_ts: None, level: SeverityLevel::Critical, target: ResourceTarget::Server(server_status.id.clone()), data: AlertData::ServerUnreachable { id: server_status.id.clone(), name: server.name.clone(), region: optional_string(&server.config.region), err: server_status.err.clone(), }, }; alerts_to_open .push((alert, server.config.send_unreachable_alerts)) } } (ServerState::NotOk, Some(alert)) => { // update alert err let mut alert = alert.clone(); let (id, name, region) = match alert.data { AlertData::ServerUnreachable { id, name, region, .. } => (id, name, region), data => { error!( "got incorrect alert data in ServerStatus handler. got {data:?}" ); continue; } }; alert.data = AlertData::ServerUnreachable { id, name, region, err: server_status.err.clone(), }; // Never send this alert, severity is always 'Critical' alerts_to_update.push((alert, false)); } // Close an open alert (ServerState::Ok | ServerState::Disabled, Some(alert)) => { alert_ids_to_close.push(( alert.clone(), server.config.send_unreachable_alerts, )); } (ServerState::Ok | ServerState::Disabled, None) => buffer .reset( server_status.id.clone(), AlertDataVariant::ServerUnreachable, ), } // =================== // SERVER VERSION MISMATCH // =================== let core_version = env!("CARGO_PKG_VERSION"); let has_version_mismatch = server_status.state == ServerState::Ok && !server_status.version.is_empty() && server_status.version != "Unknown" && server_status.version != core_version; let version_alert = server_alerts.as_ref().and_then(|alerts| { alerts.get(&AlertDataVariant::ServerVersionMismatch) }); match (has_version_mismatch, version_alert) { (true, None) => { // Only open version mismatch alert if not in maintenance and buffer is ready if !in_maintenance && buffer.ready_to_open( server_status.id.clone(), AlertDataVariant::ServerVersionMismatch, ) { let alert = Alert { id: Default::default(), ts, resolved: false, resolved_ts: None, level: SeverityLevel::Warning, target: ResourceTarget::Server(server_status.id.clone()), data: AlertData::ServerVersionMismatch { id: server_status.id.clone(), name: server.name.clone(), region: optional_string(&server.config.region), server_version: server_status.version.clone(), core_version: core_version.to_string(), }, }; // Use send_unreachable_alerts as a proxy for general server alerts alerts_to_open .push((alert, server.config.send_version_mismatch_alerts)) } } (true, Some(alert)) => { // Update existing alert with current version info let mut alert = alert.clone(); alert.data = AlertData::ServerVersionMismatch { id: server_status.id.clone(), name: server.name.clone(), region: optional_string(&server.config.region), server_version: server_status.version.clone(), core_version: core_version.to_string(), }; // Don't send notification for updates alerts_to_update.push((alert, false)); } (false, Some(alert)) => { // Version is now correct, close the alert alert_ids_to_close.push(( alert.clone(), server.config.send_version_mismatch_alerts, )); } (false, None) => { // Reset buffer state when no mismatch and no alert buffer.reset( server_status.id.clone(), AlertDataVariant::ServerVersionMismatch, ) } } let Some(health) = &server_status.health else { continue; }; // =================== // SERVER CPU // =================== let cpu_alert = server_alerts .as_ref() .and_then(|alerts| alerts.get(&AlertDataVariant::ServerCpu)) .cloned(); match (health.cpu.level, cpu_alert, health.cpu.should_close_alert) { (SeverityLevel::Warning | SeverityLevel::Critical, None, _) => { // Only open CPU alert if not in maintenance and buffer is ready if !in_maintenance && buffer.ready_to_open( server_status.id.clone(), AlertDataVariant::ServerCpu, ) { let alert = Alert { id: Default::default(), ts, resolved: false, resolved_ts: None, level: health.cpu.level, target: ResourceTarget::Server(server_status.id.clone()), data: AlertData::ServerCpu { id: server_status.id.clone(), name: server.name.clone(), region: optional_string(&server.config.region), percentage: server_status .stats .as_ref() .map(|s| s.cpu_perc as f64) .unwrap_or(0.0), }, }; alerts_to_open.push((alert, server.config.send_cpu_alerts)); } } ( SeverityLevel::Warning | SeverityLevel::Critical, Some(mut alert), _, ) => { // modify alert level only if it has increased and not in maintenance if !in_maintenance && alert.level < health.cpu.level { alert.level = health.cpu.level; alert.data = AlertData::ServerCpu { id: server_status.id.clone(), name: server.name.clone(), region: optional_string(&server.config.region), percentage: server_status .stats .as_ref() .map(|s| s.cpu_perc as f64) .unwrap_or(0.0), }; alerts_to_update .push((alert, server.config.send_cpu_alerts)); } } (SeverityLevel::Ok, Some(alert), true) => { let mut alert = alert.clone(); alert.data = AlertData::ServerCpu { id: server_status.id.clone(), name: server.name.clone(), region: optional_string(&server.config.region), percentage: server_status .stats .as_ref() .map(|s| s.cpu_perc as f64) .unwrap_or(0.0), }; alert_ids_to_close .push((alert, server.config.send_cpu_alerts)) } (SeverityLevel::Ok, _, _) => buffer .reset(server_status.id.clone(), AlertDataVariant::ServerCpu), } // =================== // SERVER MEM // =================== let mem_alert = server_alerts .as_ref() .and_then(|alerts| alerts.get(&AlertDataVariant::ServerMem)) .cloned(); match (health.mem.level, mem_alert, health.mem.should_close_alert) { (SeverityLevel::Warning | SeverityLevel::Critical, None, _) => { // Only open memory alert if not in maintenance and buffer is ready if !in_maintenance && buffer.ready_to_open( server_status.id.clone(), AlertDataVariant::ServerMem, ) { let alert = Alert { id: Default::default(), ts, resolved: false, resolved_ts: None, level: health.mem.level, target: ResourceTarget::Server(server_status.id.clone()), data: AlertData::ServerMem { id: server_status.id.clone(), name: server.name.clone(), region: optional_string(&server.config.region), total_gb: server_status .stats .as_ref() .map(|s| s.mem_total_gb) .unwrap_or(0.0), used_gb: server_status .stats .as_ref() .map(|s| s.mem_used_gb) .unwrap_or(0.0), }, }; alerts_to_open.push((alert, server.config.send_mem_alerts)); } } ( SeverityLevel::Warning | SeverityLevel::Critical, Some(mut alert), _, ) => { // modify alert level only if it has increased and not in maintenance if !in_maintenance && alert.level < health.mem.level { alert.level = health.mem.level; alert.data = AlertData::ServerMem { id: server_status.id.clone(), name: server.name.clone(), region: optional_string(&server.config.region), total_gb: server_status .stats .as_ref() .map(|s| s.mem_total_gb) .unwrap_or(0.0), used_gb: server_status .stats .as_ref() .map(|s| s.mem_used_gb) .unwrap_or(0.0), }; alerts_to_update .push((alert, server.config.send_mem_alerts)); } } (SeverityLevel::Ok, Some(alert), true) => { let mut alert = alert.clone(); alert.data = AlertData::ServerMem { id: server_status.id.clone(), name: server.name.clone(), region: optional_string(&server.config.region), total_gb: server_status .stats .as_ref() .map(|s| s.mem_total_gb) .unwrap_or(0.0), used_gb: server_status .stats .as_ref() .map(|s| s.mem_used_gb) .unwrap_or(0.0), }; alert_ids_to_close .push((alert, server.config.send_mem_alerts)) } (SeverityLevel::Ok, _, _) => buffer .reset(server_status.id.clone(), AlertDataVariant::ServerMem), } // =================== // SERVER DISK // =================== let server_disk_alerts = open_disk_alerts .get(&ResourceTarget::Server(server_status.id.clone())); for (path, health) in &health.disks { let disk_alert = server_disk_alerts .as_ref() .and_then(|alerts| alerts.get(path)) .cloned(); match (health.level, disk_alert, health.should_close_alert) { ( SeverityLevel::Warning | SeverityLevel::Critical, None, _, ) => { // Only open disk alert if not in maintenance and buffer is ready if !in_maintenance && buffer.ready_to_open( server_status.id.clone(), AlertDataVariant::ServerDisk, ) { let disk = server_status.stats.as_ref().and_then(|stats| { stats.disks.iter().find(|disk| disk.mount == *path) }); let alert = Alert { id: Default::default(), ts, resolved: false, resolved_ts: None, level: health.level, target: ResourceTarget::Server( server_status.id.clone(), ), data: AlertData::ServerDisk { id: server_status.id.clone(), name: server.name.clone(), region: optional_string(&server.config.region), path: path.to_owned(), total_gb: disk .map(|d| d.total_gb) .unwrap_or_default(), used_gb: disk.map(|d| d.used_gb).unwrap_or_default(), }, }; alerts_to_open .push((alert, server.config.send_disk_alerts)); } } ( SeverityLevel::Warning | SeverityLevel::Critical, Some(mut alert), _, ) => { // modify alert level only if it has increased and not in maintenance if !in_maintenance && health.level < alert.level { let disk = server_status.stats.as_ref().and_then(|stats| { stats.disks.iter().find(|disk| disk.mount == *path) }); alert.level = health.level; alert.data = AlertData::ServerDisk { id: server_status.id.clone(), name: server.name.clone(), region: optional_string(&server.config.region), path: path.to_owned(), total_gb: disk.map(|d| d.total_gb).unwrap_or_default(), used_gb: disk.map(|d| d.used_gb).unwrap_or_default(), }; alerts_to_update .push((alert, server.config.send_disk_alerts)); } } (SeverityLevel::Ok, Some(alert), true) => { let mut alert = alert.clone(); let disk = server_status.stats.as_ref().and_then(|stats| { stats.disks.iter().find(|disk| disk.mount == *path) }); alert.level = health.level; alert.data = AlertData::ServerDisk { id: server_status.id.clone(), name: server.name.clone(), region: optional_string(&server.config.region), path: path.to_owned(), total_gb: disk.map(|d| d.total_gb).unwrap_or_default(), used_gb: disk.map(|d| d.used_gb).unwrap_or_default(), }; alert_ids_to_close .push((alert, server.config.send_disk_alerts)) } (SeverityLevel::Ok, _, _) => buffer.reset( server_status.id.clone(), AlertDataVariant::ServerDisk, ), } } // Need to close any open ones on disks no longer reported if let Some(disk_alerts) = server_disk_alerts { for (path, alert) in disk_alerts { if !health.disks.contains_key(path) { let mut alert = alert.clone(); alert.level = SeverityLevel::Ok; alert_ids_to_close .push((alert, server.config.send_disk_alerts)); } } } } tokio::join!( open_new_alerts(&alerts_to_open), update_alerts(&alerts_to_update), resolve_alerts(&alert_ids_to_close), ); } #[instrument(level = "debug")] async fn open_new_alerts(alerts: &[(Alert, SendAlerts)]) { if alerts.is_empty() { return; } let db = db_client(); let open = || async { let ids = db .alerts .insert_many(alerts.iter().map(|(alert, _)| alert)) .await? .inserted_ids .into_iter() .filter_map(|(index, id)| { alerts.get(index)?.1.then(|| id.as_object_id()) }) .flatten() .collect::>(); anyhow::Ok(ids) }; let ids_to_send = match open().await { Ok(ids) => ids, Err(e) => { error!("failed to open alerts on db | {e:?}"); return; } }; let alerts = match find_collect( &db.alerts, doc! { "_id": { "$in": ids_to_send } }, None, ) .await { Ok(alerts) => alerts, Err(e) => { error!("failed to pull created alerts from mongo | {e:?}"); return; } }; send_alerts(&alerts).await } #[instrument(level = "debug")] async fn update_alerts(alerts: &[(Alert, SendAlerts)]) { if alerts.is_empty() { return; } let open = || async { let updates = alerts.iter().map(|(alert, _)| { let update = BulkUpdate { query: doc! { "_id": ObjectId::from_str(&alert.id).context("failed to convert alert id to ObjectId")? }, update: doc! { "$set": to_bson(alert).context("failed to convert alert to bson")? } }; anyhow::Ok(update) }) .filter_map(|update| match update { Ok(update) => Some(update), Err(e) => { warn!("failed to generate bulk update for alert | {e:#}"); None } }).collect::>(); bulk_update::bulk_update( &db_client().db, Alert::default_collection_name(), &updates, false, ) .await .context("failed to bulk update alerts")?; anyhow::Ok(()) }; let alerts = alerts .iter() .filter(|(_, send)| *send) .map(|(alert, _)| alert) .cloned() .collect::>(); let (res, _) = tokio::join!(open(), send_alerts(&alerts)); if let Err(e) = res { error!("failed to create alerts on db | {e:#}"); } } #[instrument(level = "debug")] async fn resolve_alerts(alerts: &[(Alert, SendAlerts)]) { if alerts.is_empty() { return; } let close = || async move { let alert_ids = alerts .iter() .map(|(alert, _)| { ObjectId::from_str(&alert.id) .context("failed to convert alert id to ObjectId") }) .collect::>>()?; db_client() .alerts .update_many( doc! { "_id": { "$in": &alert_ids } }, doc! { "$set": { "resolved": true, "resolved_ts": komodo_timestamp() } }, ) .await .context("failed to resolve alerts on db") .inspect_err(|e| warn!("{e:#}")) .ok(); let ts = komodo_timestamp(); let closed = alerts .iter() .filter(|(_, send)| *send) .map(|(alert, _)| { let mut alert = alert.clone(); alert.resolved = true; alert.resolved_ts = Some(ts); alert.level = SeverityLevel::Ok; alert }) .collect::>(); send_alerts(&closed).await; anyhow::Ok(()) }; if let Err(e) = close().await { error!("failed to resolve alerts | {e:#?}"); } } #[instrument(level = "debug")] async fn get_open_alerts() -> anyhow::Result<(OpenAlertMap, OpenDiskAlertMap)> { let alerts = find_collect( &db_client().alerts, doc! { "resolved": false }, None, ) .await .context("failed to get open alerts from db")?; let mut map = OpenAlertMap::new(); let mut disk_map = OpenDiskAlertMap::new(); for alert in alerts { match &alert.data { AlertData::ServerDisk { path, .. } => { let inner = disk_map.entry(alert.target.clone()).or_default(); inner.insert(path.to_owned(), alert); } _ => { let inner = map.entry(alert.target.clone()).or_default(); inner.insert(alert.data.extract_variant(), alert); } } } Ok((map, disk_map)) } ================================================ FILE: bin/core/src/monitor/alert/stack.rs ================================================ use std::collections::HashMap; use komodo_client::entities::{ ResourceTarget, alert::{Alert, AlertData, SeverityLevel}, stack::{Stack, StackState}, }; use crate::{ alert::send_alerts, resource, state::{action_states, db_client, stack_status_cache}, }; #[instrument(level = "debug")] pub async fn alert_stacks( ts: i64, server_names: &HashMap, ) { let action_states = action_states(); let mut alerts = Vec::::new(); for status in stack_status_cache().get_list().await { // Don't alert if prev None let Some(prev) = status.prev else { continue; }; // Don't alert if either prev or curr is Unknown. // This will happen if server is unreachable, so this would be redundant. if status.curr.state == StackState::Unknown || prev == StackState::Unknown { continue; } // Don't alert if deploying if action_states .stack .get(&status.curr.id) .await .map(|s| s.get().map(|s| s.deploying)) .transpose() .ok() .flatten() .unwrap_or_default() { continue; } if status.curr.state != prev { // send alert let Ok(stack) = resource::get::(&status.curr.id).await.inspect_err( |e| error!("failed to get stack from db | {e:#?}"), ) else { continue; }; if !stack.config.send_alerts { continue; } let target: ResourceTarget = (&stack).into(); let data = AlertData::StackStateChange { id: status.curr.id.clone(), name: stack.name, server_name: server_names .get(&stack.config.server_id) .cloned() .unwrap_or(String::from("unknown")), server_id: stack.config.server_id, from: prev, to: status.curr.state, }; let alert = Alert { id: Default::default(), level: SeverityLevel::Warning, resolved: true, resolved_ts: ts.into(), target, data, ts, }; alerts.push(alert); } } if alerts.is_empty() { return; } send_alerts(&alerts).await; let res = db_client().alerts.insert_many(alerts).await; if let Err(e) = res { error!("failed to record stack status alerts to db | {e:#}"); } } ================================================ FILE: bin/core/src/monitor/helpers.rs ================================================ use komodo_client::entities::{ alert::SeverityLevel, deployment::{Deployment, DeploymentState}, docker::{ container::ContainerListItem, image::ImageListItem, network::NetworkListItem, volume::VolumeListItem, }, repo::Repo, server::{ Server, ServerConfig, ServerHealth, ServerHealthState, ServerState, }, stack::{ComposeProject, Stack, StackState}, stats::{SingleDiskUsage, SystemStats}, }; use serror::Serror; use crate::state::{ deployment_status_cache, repo_status_cache, server_status_cache, stack_status_cache, }; use super::{ CachedDeploymentStatus, CachedRepoStatus, CachedServerStatus, CachedStackStatus, History, }; #[instrument(level = "debug", skip_all)] pub async fn insert_deployments_status_unknown( deployments: Vec, ) { let status_cache = deployment_status_cache(); for deployment in deployments { let prev = status_cache.get(&deployment.id).await.map(|s| s.curr.state); status_cache .insert( deployment.id.clone(), History { curr: CachedDeploymentStatus { id: deployment.id, state: DeploymentState::Unknown, container: None, update_available: false, }, prev, } .into(), ) .await; } } #[instrument(level = "debug", skip_all)] pub async fn insert_repos_status_unknown(repos: Vec) { let status_cache = repo_status_cache(); for repo in repos { status_cache .insert( repo.id.clone(), CachedRepoStatus { latest_hash: None, latest_message: None, } .into(), ) .await; } } #[instrument(level = "debug", skip_all)] pub async fn insert_stacks_status_unknown(stacks: Vec) { let status_cache = stack_status_cache(); for stack in stacks { let prev = status_cache.get(&stack.id).await.map(|s| s.curr.state); status_cache .insert( stack.id.clone(), History { curr: CachedStackStatus { id: stack.id, state: StackState::Unknown, services: Vec::new(), }, prev, } .into(), ) .await; } } type DockerLists = ( Option>, Option>, Option>, Option>, Option>, ); #[instrument(level = "debug", skip_all)] pub async fn insert_server_status( server: &Server, state: ServerState, version: String, stats: Option, (containers, networks, images, volumes, projects): DockerLists, err: impl Into>, ) { let health = stats.as_ref().map(|s| get_server_health(server, s)); server_status_cache() .insert( server.id.clone(), CachedServerStatus { id: server.id.clone(), state, version, stats, health, containers, networks, images, volumes, projects, err: err.into(), } .into(), ) .await; } const ALERT_PERCENTAGE_THRESHOLD: f32 = 5.0; fn get_server_health( server: &Server, SystemStats { cpu_perc, mem_used_gb, mem_total_gb, disks, .. }: &SystemStats, ) -> ServerHealth { let ServerConfig { cpu_warning, cpu_critical, mem_warning, mem_critical, disk_warning, disk_critical, .. } = &server.config; let mut health = ServerHealth::default(); if cpu_perc >= cpu_critical { health.cpu.level = SeverityLevel::Critical; } else if cpu_perc >= cpu_warning { health.cpu.level = SeverityLevel::Warning } else if *cpu_perc < cpu_warning - ALERT_PERCENTAGE_THRESHOLD { health.cpu.should_close_alert = true } let mem_perc = 100.0 * mem_used_gb / mem_total_gb; if mem_perc >= *mem_critical { health.mem.level = SeverityLevel::Critical } else if mem_perc >= *mem_warning { health.mem.level = SeverityLevel::Warning } else if mem_perc < mem_warning - (ALERT_PERCENTAGE_THRESHOLD as f64) { health.mem.should_close_alert = true } for SingleDiskUsage { mount, used_gb, total_gb, .. } in disks { let perc = 100.0 * used_gb / total_gb; let mut state = ServerHealthState::default(); if perc >= *disk_critical { state.level = SeverityLevel::Critical; } else if perc >= *disk_warning { state.level = SeverityLevel::Warning; } else if perc < disk_warning - (ALERT_PERCENTAGE_THRESHOLD as f64) { state.should_close_alert = true; }; health.disks.insert(mount.clone(), state); } health } ================================================ FILE: bin/core/src/monitor/lists.rs ================================================ use komodo_client::entities::{ docker::{ container::ContainerListItem, image::ImageListItem, network::NetworkListItem, volume::VolumeListItem, }, stack::ComposeProject, }; use periphery_client::{ PeripheryClient, api::{GetDockerLists, GetDockerListsResponse}, }; pub async fn get_docker_lists( periphery: &PeripheryClient, ) -> anyhow::Result<( Vec, Vec, Vec, Vec, Vec, )> { let GetDockerListsResponse { containers, networks, images, volumes, projects, } = periphery.request(GetDockerLists {}).await?; // TODO: handle the errors let ( mut containers, mut networks, mut images, mut volumes, mut projects, ) = ( containers.unwrap_or_default(), networks.unwrap_or_default(), images.unwrap_or_default(), volumes.unwrap_or_default(), projects.unwrap_or_default(), ); containers.sort_by(|a, b| a.name.cmp(&b.name)); networks.sort_by(|a, b| a.name.cmp(&b.name)); images.sort_by(|a, b| a.name.cmp(&b.name)); volumes.sort_by(|a, b| a.name.cmp(&b.name)); projects.sort_by(|a, b| a.name.cmp(&b.name)); Ok((containers, networks, images, volumes, projects)) } ================================================ FILE: bin/core/src/monitor/mod.rs ================================================ use std::sync::{Arc, OnceLock}; use async_timing_util::wait_until_timelength; use database::mungos::{find::find_collect, mongodb::bson::doc}; use futures::future::join_all; use helpers::insert_stacks_status_unknown; use komodo_client::entities::{ deployment::DeploymentState, docker::{ container::ContainerListItem, image::ImageListItem, network::NetworkListItem, volume::VolumeListItem, }, komodo_timestamp, optional_string, server::{Server, ServerHealth, ServerState}, stack::{ComposeProject, StackService, StackState}, stats::SystemStats, }; use periphery_client::api::{self, git::GetLatestCommit}; use serror::Serror; use tokio::sync::Mutex; use crate::{ config::core_config, helpers::{cache::Cache, periphery_client}, monitor::{alert::check_alerts, record::record_server_stats}, state::{db_client, deployment_status_cache, repo_status_cache}, }; use self::helpers::{ insert_deployments_status_unknown, insert_repos_status_unknown, insert_server_status, }; mod alert; mod helpers; mod lists; mod record; mod resources; #[derive(Default, Debug)] pub struct History { pub curr: Curr, pub prev: Option, } #[derive(Default, Clone, Debug)] pub struct CachedServerStatus { pub id: String, pub state: ServerState, pub version: String, pub stats: Option, pub health: Option, pub containers: Option>, pub networks: Option>, pub images: Option>, pub volumes: Option>, pub projects: Option>, /// Store the error in reaching periphery pub err: Option, } #[derive(Default, Clone, Debug)] pub struct CachedDeploymentStatus { /// The deployment id pub id: String, pub state: DeploymentState, pub container: Option, pub update_available: bool, } #[derive(Default, Clone, Debug)] pub struct CachedRepoStatus { pub latest_hash: Option, pub latest_message: Option, } #[derive(Default, Clone, Debug)] pub struct CachedStackStatus { /// The stack id pub id: String, /// The stack state pub state: StackState, /// The services connected to the stack pub services: Vec, } const ADDITIONAL_MS: u128 = 500; pub fn spawn_monitor_loop() { let interval: async_timing_util::Timelength = core_config() .monitoring_interval .try_into() .expect("Invalid monitoring interval"); tokio::spawn(async move { refresh_server_cache(komodo_timestamp()).await; loop { let ts = (wait_until_timelength(interval, ADDITIONAL_MS).await - ADDITIONAL_MS) as i64; refresh_server_cache(ts).await; } }); } async fn refresh_server_cache(ts: i64) { let servers = match find_collect(&db_client().servers, None, None).await { Ok(servers) => servers, Err(e) => { error!( "failed to get server list (manage status cache) | {e:#}" ); return; } }; let futures = servers.into_iter().map(|server| async move { update_cache_for_server(&server, false).await; }); join_all(futures).await; tokio::join!(check_alerts(ts), record_server_stats(ts)); } /// Makes sure cache for server doesn't update too frequently / simultaneously. /// If forced, will still block against simultaneous update. fn update_cache_for_server_controller() -> &'static Cache>> { static CACHE: OnceLock>>> = OnceLock::new(); CACHE.get_or_init(Default::default) } /// The background loop will call this with force: false, /// which exits early if the lock is busy or it was completed too recently. /// If force is true, it will wait on simultaneous calls, and will /// ignore the restriction on being completed too recently. #[instrument(level = "debug")] pub async fn update_cache_for_server(server: &Server, force: bool) { // Concurrency controller to ensure it isn't done too often // when it happens in other contexts. let controller = update_cache_for_server_controller() .get_or_insert_default(&server.id) .await; let mut lock = match controller.try_lock() { Ok(lock) => lock, Err(_) if force => controller.lock().await, Err(_) => return, }; let now = komodo_timestamp(); // early return if called again sooner than 1s. if !force && *lock > now - 1_000 { return; } *lock = now; let (deployments, builds, repos, stacks) = tokio::join!( find_collect( &db_client().deployments, doc! { "config.server_id": &server.id }, None, ), find_collect(&db_client().builds, doc! {}, None,), find_collect( &db_client().repos, doc! { "config.server_id": &server.id }, None, ), find_collect( &db_client().stacks, doc! { "config.server_id": &server.id }, None, ) ); let deployments = deployments.inspect_err(|e| error!("failed to get deployments list from db (update status cache) | server : {} | {e:#}", server.name)).unwrap_or_default(); let builds = builds.inspect_err(|e| error!("failed to get builds list from db (update status cache) | server : {} | {e:#}", server.name)).unwrap_or_default(); let repos = repos.inspect_err(|e| error!("failed to get repos list from db (update status cache) | server: {} | {e:#}", server.name)).unwrap_or_default(); let stacks = stacks.inspect_err(|e| error!("failed to get stacks list from db (update status cache) | server: {} | {e:#}", server.name)).unwrap_or_default(); // Handle server disabled if !server.config.enabled { insert_deployments_status_unknown(deployments).await; insert_stacks_status_unknown(stacks).await; insert_repos_status_unknown(repos).await; insert_server_status( server, ServerState::Disabled, String::from("unknown"), None, (None, None, None, None, None), None, ) .await; return; } let Ok(periphery) = periphery_client(server) else { error!( "somehow periphery not ok to create. should not be reached." ); return; }; let version = match periphery.request(api::GetVersion {}).await { Ok(version) => version.version, Err(e) => { insert_deployments_status_unknown(deployments).await; insert_stacks_status_unknown(stacks).await; insert_repos_status_unknown(repos).await; insert_server_status( server, ServerState::NotOk, String::from("Unknown"), None, (None, None, None, None, None), Serror::from(&e), ) .await; return; } }; let stats = if server.config.stats_monitoring { match periphery.request(api::stats::GetSystemStats {}).await { Ok(stats) => Some(filter_volumes(server, stats)), Err(e) => { insert_deployments_status_unknown(deployments).await; insert_stacks_status_unknown(stacks).await; insert_repos_status_unknown(repos).await; insert_server_status( server, ServerState::NotOk, String::from("unknown"), None, (None, None, None, None, None), Serror::from(&e), ) .await; return; } } } else { None }; match lists::get_docker_lists(&periphery).await { Ok((mut containers, networks, images, volumes, projects)) => { containers.iter_mut().for_each(|container| { container.server_id = Some(server.id.clone()) }); tokio::join!( resources::update_deployment_cache( server.name.clone(), deployments, &containers, &images, &builds, ), resources::update_stack_cache( server.name.clone(), stacks, &containers, &images ), ); insert_server_status( server, ServerState::Ok, version, stats, ( Some(containers.clone()), Some(networks), Some(images), Some(volumes), Some(projects), ), None, ) .await; } Err(e) => { insert_deployments_status_unknown(deployments).await; insert_stacks_status_unknown(stacks).await; insert_server_status( server, ServerState::Ok, version, stats, (None, None, None, None, None), Some(e.into()), ) .await; } } let status_cache = repo_status_cache(); for repo in repos { let (latest_hash, latest_message) = periphery .request(GetLatestCommit { name: repo.name.clone(), path: optional_string(&repo.config.path), }) .await .ok() .flatten() .map(|c| (c.hash, c.message)) .unzip(); status_cache .insert( repo.id, CachedRepoStatus { latest_hash, latest_message, } .into(), ) .await; } } fn filter_volumes( server: &Server, mut stats: SystemStats, ) -> SystemStats { stats.disks.retain(|disk| { // Always filter out volume mounts !disk.mount.starts_with("/var/lib/docker/volumes") // Filter out any that were declared to ignore in server config && !server .config .ignore_mounts .iter() .any(|mount| disk.mount.starts_with(mount)) }); stats } ================================================ FILE: bin/core/src/monitor/record.rs ================================================ use komodo_client::entities::stats::{ SystemStatsRecord, TotalDiskUsage, sum_disk_usage, }; use crate::state::{db_client, server_status_cache}; #[instrument(level = "debug")] pub async fn record_server_stats(ts: i64) { let status = server_status_cache().get_list().await; let records = status .into_iter() .filter_map(|status| { let stats = status.stats.as_ref()?; let TotalDiskUsage { used_gb: disk_used_gb, total_gb: disk_total_gb, } = sum_disk_usage(&stats.disks); Some(SystemStatsRecord { ts, sid: status.id.clone(), cpu_perc: stats.cpu_perc, load_average: stats.load_average.clone(), mem_total_gb: stats.mem_total_gb, mem_used_gb: stats.mem_used_gb, disk_total_gb, disk_used_gb, disks: stats.disks.clone(), network_ingress_bytes: stats.network_ingress_bytes, network_egress_bytes: stats.network_egress_bytes, }) }) .collect::>(); if !records.is_empty() { let res = db_client().stats.insert_many(records).await; if let Err(e) = res { error!("failed to record server stats | {e:#}"); } } } ================================================ FILE: bin/core/src/monitor/resources.rs ================================================ use std::{ collections::HashSet, sync::{Mutex, OnceLock}, }; use anyhow::Context; use komodo_client::{ api::execute::{Deploy, DeployStack}, entities::{ ResourceTarget, alert::{Alert, AlertData, SeverityLevel}, build::Build, deployment::{Deployment, DeploymentImage, DeploymentState}, docker::{ container::{ContainerListItem, ContainerStateStatusEnum}, image::ImageListItem, }, komodo_timestamp, stack::{Stack, StackService, StackServiceNames, StackState}, user::auto_redeploy_user, }, }; use crate::{ alert::send_alerts, api::execute::{self, ExecuteRequest}, helpers::query::get_stack_state_from_containers, stack::{ compose_container_match_regex, services::extract_services_from_stack, }, state::{ action_states, db_client, deployment_status_cache, stack_status_cache, }, }; use super::{CachedDeploymentStatus, CachedStackStatus, History}; fn deployment_alert_sent_cache() -> &'static Mutex> { static CACHE: OnceLock>> = OnceLock::new(); CACHE.get_or_init(Default::default) } pub async fn update_deployment_cache( server_name: String, deployments: Vec, containers: &[ContainerListItem], images: &[ImageListItem], builds: &[Build], ) { let deployment_status_cache = deployment_status_cache(); for deployment in deployments { let container = containers .iter() .find(|container| container.name == deployment.name) .cloned(); let prev = deployment_status_cache .get(&deployment.id) .await .map(|s| s.curr.state); let state = container .as_ref() .map(|c| c.state.into()) .unwrap_or(DeploymentState::NotDeployed); let image = match deployment.config.image { DeploymentImage::Build { build_id, version } => { let (build_name, build_version) = builds .iter() .find(|build| build.id == build_id) .map(|b| (b.name.as_ref(), b.config.version)) .unwrap_or(("Unknown", Default::default())); let version = if version.is_none() { build_version.to_string() } else { version.to_string() }; format!("{build_name}:{version}") } DeploymentImage::Image { image } => { // If image already has tag, leave it, // otherwise default the tag to latest if image.contains(':') { image.to_string() } else { format!("{image}:latest") } } }; let update_available = if let Some(ContainerListItem { image_id: Some(curr_image_id), .. }) = &container { // Docker will automatically strip `docker.io` from incoming image names re #468. // Need to strip it in order to match by image name and find available updates. let image = image.strip_prefix("docker.io/").unwrap_or(&image); images .iter() .find(|i| i.name == image) .map(|i| &i.id != curr_image_id) .unwrap_or_default() } else { false }; if update_available { if deployment.config.auto_update { if state == DeploymentState::Running && !action_states() .deployment .get_or_insert_default(&deployment.id) .await .busy() .unwrap_or(true) { let id = deployment.id.clone(); let server_name = server_name.clone(); tokio::spawn(async move { match execute::inner_handler( ExecuteRequest::Deploy(Deploy { deployment: deployment.name.clone(), stop_time: None, stop_signal: None, }), auto_redeploy_user().to_owned(), ) .await { Ok(_) => { let ts = komodo_timestamp(); let alert = Alert { id: Default::default(), ts, resolved: true, resolved_ts: ts.into(), level: SeverityLevel::Ok, target: ResourceTarget::Deployment(id.clone()), data: AlertData::DeploymentAutoUpdated { id, name: deployment.name, server_name, server_id: deployment.config.server_id, image, }, }; let res = db_client().alerts.insert_one(&alert).await; if let Err(e) = res { error!( "Failed to record DeploymentAutoUpdated to db | {e:#}" ); } send_alerts(&[alert]).await; } Err(e) => { warn!( "Failed to auto update Deployment {} | {e:#}", deployment.name ) } } }); } } else if state == DeploymentState::Running && deployment.config.send_alerts && !deployment_alert_sent_cache() .lock() .unwrap() .contains(&deployment.id) { // Add that it is already sent to the cache, so another alert won't be sent. deployment_alert_sent_cache() .lock() .unwrap() .insert(deployment.id.clone()); let ts = komodo_timestamp(); let alert = Alert { id: Default::default(), ts, resolved: true, resolved_ts: ts.into(), level: SeverityLevel::Ok, target: ResourceTarget::Deployment(deployment.id.clone()), data: AlertData::DeploymentImageUpdateAvailable { id: deployment.id.clone(), name: deployment.name, server_name: server_name.clone(), server_id: deployment.config.server_id, image, }, }; let res = db_client().alerts.insert_one(&alert).await; if let Err(e) = res { error!( "Failed to record DeploymentImageUpdateAvailable to db | {e:#}" ); } send_alerts(&[alert]).await; } } else { // If it sees there is no longer update available, remove // from the sent cache, so on next `update_available = true` // the cache is empty and a fresh alert will be sent. deployment_alert_sent_cache() .lock() .unwrap() .remove(&deployment.id); } deployment_status_cache .insert( deployment.id.clone(), History { curr: CachedDeploymentStatus { id: deployment.id, state, container, update_available, }, prev, } .into(), ) .await; } } /// (StackId, Service) fn stack_alert_sent_cache() -> &'static Mutex> { static CACHE: OnceLock>> = OnceLock::new(); CACHE.get_or_init(Default::default) } pub async fn update_stack_cache( server_name: String, stacks: Vec, containers: &[ContainerListItem], images: &[ImageListItem], ) { let stack_status_cache = stack_status_cache(); for stack in stacks { let services = extract_services_from_stack(&stack); let mut services_with_containers = services.iter().map(|StackServiceNames { service_name, container_name, image }| { let container = containers.iter().find(|container| { match compose_container_match_regex(container_name) .with_context(|| format!("failed to construct container name matching regex for service {service_name}")) { Ok(regex) => regex, Err(e) => { warn!("{e:#}"); return false } }.is_match(&container.name) }).cloned(); let image = if image.contains(':') { image.to_string() } else { format!("{image}:latest") }; let update_available = if let Some(ContainerListItem { image_id: Some(curr_image_id), .. }) = &container { // Docker will automatically strip `docker.io` from incoming image names re #468. // Need to strip it in order to match by image tag and find available update. let image = image.strip_prefix("docker.io/").unwrap_or(&image); images .iter() .find(|i| i.name == image) .map(|i| &i.id != curr_image_id) .unwrap_or_default() } else { false }; if update_available { if !stack.config.auto_update && stack.config.send_alerts && container.is_some() && container.as_ref().unwrap().state == ContainerStateStatusEnum::Running && !stack_alert_sent_cache() .lock() .unwrap() .contains(&(stack.id.clone(), service_name.clone())) { stack_alert_sent_cache() .lock() .unwrap() .insert((stack.id.clone(), service_name.clone())); let ts = komodo_timestamp(); let alert = Alert { id: Default::default(), ts, resolved: true, resolved_ts: ts.into(), level: SeverityLevel::Ok, target: ResourceTarget::Stack(stack.id.clone()), data: AlertData::StackImageUpdateAvailable { id: stack.id.clone(), name: stack.name.clone(), server_name: server_name.clone(), server_id: stack.config.server_id.clone(), service: service_name.clone(), image: image.clone(), }, }; tokio::spawn(async move { let res = db_client().alerts.insert_one(&alert).await; if let Err(e) = res { error!( "Failed to record StackImageUpdateAvailable to db | {e:#}" ); } send_alerts(&[alert]).await; }); } } else { stack_alert_sent_cache() .lock() .unwrap() .remove(&(stack.id.clone(), service_name.clone())); } StackService { service: service_name.clone(), image: image.clone(), container, update_available, } }).collect::>(); let mut images_with_update = Vec::new(); let mut services_to_update = Vec::new(); for service in services_with_containers.iter() { if service.update_available { images_with_update.push(service.image.clone()); // Only allow it to actually trigger an auto update deploy // if the service is running. if service .container .as_ref() .map(|c| c.state == ContainerStateStatusEnum::Running) .unwrap_or_default() { services_to_update.push(service.service.clone()); } } } let state = get_stack_state_from_containers( &stack.config.ignore_services, &services, containers, ); if !services_to_update.is_empty() && stack.config.auto_update && state == StackState::Running && !action_states() .stack .get_or_insert_default(&stack.id) .await .busy() .unwrap_or(true) { let id = stack.id.clone(); let server_name = server_name.clone(); let services = if stack.config.auto_update_all_services { Vec::new() } else { services_to_update }; tokio::spawn(async move { match execute::inner_handler( ExecuteRequest::DeployStack(DeployStack { stack: stack.name.clone(), services, stop_time: None, }), auto_redeploy_user().to_owned(), ) .await { Ok(_) => { let ts = komodo_timestamp(); let alert = Alert { id: Default::default(), ts, resolved: true, resolved_ts: ts.into(), level: SeverityLevel::Ok, target: ResourceTarget::Stack(id.clone()), data: AlertData::StackAutoUpdated { id, name: stack.name.clone(), server_name, server_id: stack.config.server_id, images: images_with_update, }, }; let res = db_client().alerts.insert_one(&alert).await; if let Err(e) = res { error!( "Failed to record StackAutoUpdated to db | {e:#}" ); } send_alerts(&[alert]).await; } Err(e) => { warn!("Failed auto update Stack {} | {e:#}", stack.name,) } } }); } services_with_containers .sort_by(|a, b| a.service.cmp(&b.service)); let prev = stack_status_cache .get(&stack.id) .await .map(|s| s.curr.state); let status = CachedStackStatus { id: stack.id.clone(), state, services: services_with_containers, }; stack_status_cache .insert(stack.id, History { curr: status, prev }.into()) .await; } } ================================================ FILE: bin/core/src/network.rs ================================================ //! # Network Configuration Module //! //! This module provides manual network interface configuration for multi-NIC Docker environments. //! It allows Komodo Core to specify which network interface should be used as the default route //! for internet traffic, which is particularly useful in complex networking setups with multiple //! network interfaces. //! //! ## Features //! - Automatic container environment detection //! - Interface validation (existence and UP state) //! - Gateway discovery from routing tables or network configuration //! - Safe default route modification with privilege checking //! - Comprehensive error handling and logging use anyhow::{Context, anyhow}; use tokio::process::Command; use tracing::{debug, info, trace, warn}; /// Standard gateway addresses to test for Docker networks const DOCKER_GATEWAY_CANDIDATES: &[&str] = &[".1", ".254"]; /// Container environment detection files const DOCKERENV_FILE: &str = "/.dockerenv"; const CGROUP_FILE: &str = "/proc/1/cgroup"; /// Check if running in container environment fn is_container_environment() -> bool { // Check for Docker-specific indicators if std::path::Path::new(DOCKERENV_FILE).exists() { return true; } // Check container environment variable if std::env::var("container").is_ok() { return true; } // Check cgroup for container runtime indicators if let Ok(content) = std::fs::read_to_string(CGROUP_FILE) && (content.contains("docker") || content.contains("containerd")) { return true; } false } /// Configure internet gateway for specified interface pub async fn configure_internet_gateway() { use crate::config::core_config; let config = core_config(); if !is_container_environment() { debug!("Not in container, skipping network configuration"); return; } if !config.internet_interface.is_empty() { debug!( "Configuring internet interface: {}", config.internet_interface ); if let Err(e) = configure_manual_interface(&config.internet_interface).await { warn!("Failed to configure internet gateway: {e:#}"); } } else { debug!("No interface specified, using default routing"); } } /// Configure interface as default route async fn configure_manual_interface( interface_name: &str, ) -> anyhow::Result<()> { // Verify interface exists and is up let interface_check = Command::new("ip") .args(["addr", "show", interface_name]) .output() .await .context("Failed to check interface status")?; if !interface_check.status.success() { return Err(anyhow!( "Interface '{}' does not exist or is not accessible. Available interfaces can be listed with 'ip addr show'", interface_name )); } let interface_info = String::from_utf8_lossy(&interface_check.stdout); if !interface_info.contains("state UP") { return Err(anyhow!( "Interface '{}' is not UP. Please ensure the interface is enabled and connected", interface_name )); } debug!("Interface {} is UP", interface_name); let gateway = find_gateway(interface_name).await?; debug!("Found gateway {} for {}", gateway, interface_name); set_default_gateway(&gateway, interface_name).await?; info!( "🌐 Configured {} as default gateway via {}", interface_name, gateway ); Ok(()) } /// Find gateway for interface async fn find_gateway( interface_name: &str, ) -> anyhow::Result { // Get interface IP address let addr_output = Command::new("ip") .args(["addr", "show", interface_name]) .output() .await .context("Failed to get interface address")?; let addr_info = String::from_utf8_lossy(&addr_output.stdout); let mut ip_cidr = None; // Extract IP/CIDR from interface info for line in addr_info.lines() { if line.trim().starts_with("inet ") && !line.contains("127.0.0.1") { let parts: Vec<&str> = line.split_whitespace().collect(); if let Some(found_ip_cidr) = parts.get(1) { debug!( "Interface {} has IP {}", interface_name, found_ip_cidr ); ip_cidr = Some(*found_ip_cidr); break; } } } let ip_cidr = ip_cidr.ok_or_else(|| anyhow!( "Could not find IP address for interface '{}'. Ensure interface has a valid IPv4 address", interface_name ))?; trace!( "Finding gateway for interface {} in network {}", interface_name, ip_cidr ); // Try to find gateway from routing table let route_output = Command::new("ip") .args(["route", "show", "dev", interface_name]) .output() .await .context("Failed to get routes for interface")?; if route_output.status.success() { let routes = String::from_utf8(route_output.stdout)?; trace!("Routes for {}: {}", interface_name, routes.trim()); // Look for routes with gateway for line in routes.lines() { if line.contains("via") { let parts: Vec<&str> = line.split_whitespace().collect(); if let Some(via_idx) = parts.iter().position(|&x| x == "via") && let Some(&gateway) = parts.get(via_idx + 1) { trace!( "Found gateway {} for {} from routing table", gateway, interface_name ); return Ok(gateway.to_string()); } } } } // Derive gateway from network configuration (Docker standard: .1) if let Some(network_base) = ip_cidr.split('/').next() { let ip_parts: Vec<&str> = network_base.split('.').collect(); if ip_parts.len() == 4 { let potential_gateways: Vec = DOCKER_GATEWAY_CANDIDATES .iter() .map(|suffix| { format!( "{}.{}.{}{}", ip_parts[0], ip_parts[1], ip_parts[2], suffix ) }) .collect(); for gateway in potential_gateways { trace!( "Testing potential gateway {} for {}", gateway, interface_name ); // Check if gateway is reachable let route_test = Command::new("ip") .args(["route", "get", &gateway, "dev", interface_name]) .output() .await; if let Ok(output) = route_test && output.status.success() { trace!( "Gateway {} is reachable via {}", gateway, interface_name ); return Ok(gateway.to_string()); } // Fallback: assume .1 is gateway (Docker standard) if gateway.ends_with(".1") { trace!( "Assuming Docker gateway {} for {}", gateway, interface_name ); return Ok(gateway.to_string()); } } } } Err(anyhow!( "Could not determine gateway for interface '{}' in network '{}'. \ Ensure the interface is properly configured with a valid gateway", interface_name, ip_cidr )) } /// Set default gateway to use specified interface async fn set_default_gateway( gateway: &str, interface_name: &str, ) -> anyhow::Result<()> { trace!( "Setting default gateway to {} via {}", gateway, interface_name ); // Check if we have network privileges if !check_network_privileges().await { warn!( "⚠️ Container lacks network privileges (NET_ADMIN capability required)" ); warn!( "Add 'cap_add: [\"NET_ADMIN\"]' to your docker-compose.yaml" ); return Err(anyhow!( "Insufficient network privileges to modify routing table. \ Container needs NET_ADMIN capability to configure network interfaces" )); } // Remove existing default routes let remove_default = Command::new("sh") .args(["-c", "ip route del default 2>/dev/null || true"]) .output() .await; if let Ok(output) = remove_default && output.status.success() { trace!("Removed existing default routes"); } // Add new default route let add_default_cmd = format!( "ip route add default via {gateway} dev {interface_name}" ); trace!("Adding default route: {}", add_default_cmd); let add_default = Command::new("sh") .args(["-c", &add_default_cmd]) .output() .await .context("Failed to add default route")?; if !add_default.status.success() { let error = String::from_utf8_lossy(&add_default.stderr) .trim() .to_string(); return Err(anyhow!( "❌ Failed to set default gateway via '{}': {}. \ Verify interface configuration and network permissions", interface_name, error )); } trace!("Default gateway set to {} via {}", gateway, interface_name); Ok(()) } /// Check if we have sufficient network privileges async fn check_network_privileges() -> bool { // Try to test NET_ADMIN capability with a harmless route operation let capability_test = Command::new("sh") .args(["-c", "ip route add 198.51.100.1/32 dev lo 2>/dev/null && ip route del 198.51.100.1/32 dev lo 2>/dev/null"]) .output() .await; matches!(capability_test, Ok(output) if output.status.success()) } ================================================ FILE: bin/core/src/permission.rs ================================================ use std::collections::HashSet; use anyhow::{Context, anyhow}; use database::mongo_indexed::doc; use database::mungos::find::find_collect; use futures::{FutureExt, future::BoxFuture}; use indexmap::IndexSet; use komodo_client::{ api::read::GetPermission, entities::{ permission::{PermissionLevel, PermissionLevelAndSpecifics}, resource::Resource, user::User, }, }; use resolver_api::Resolve; use crate::{ api::read::ReadArgs, config::core_config, helpers::query::{get_user_user_groups, user_target_query}, resource::{KomodoResource, get}, state::db_client, }; pub async fn get_check_permissions( id_or_name: &str, user: &User, required_permissions: PermissionLevelAndSpecifics, ) -> anyhow::Result> { let resource = get::(id_or_name).await?; // Allow all if admin if user.admin { return Ok(resource); } let user_permissions = get_user_permission_on_resource::(user, &resource.id).await?; if ( // Allow if its just read or below, and transparent mode enabled (required_permissions.level <= PermissionLevel::Read && core_config().transparent_mode) // Allow if resource has base permission level greater than or equal to required permission level || resource.base_permission.level >= required_permissions.level ) && user_permissions .fulfills_specific(&required_permissions.specific) { return Ok(resource); } if user_permissions.fulfills(&required_permissions) { Ok(resource) } else { Err(anyhow!( "User does not have required permissions on this {}. Must have at least {} permissions{}", T::resource_type(), required_permissions.level, if required_permissions.specific.is_empty() { String::new() } else { format!( ", as well as these specific permissions: [{}]", required_permissions.specifics_for_log() ) } )) } } #[instrument(level = "debug")] pub fn get_user_permission_on_resource<'a, T: KomodoResource>( user: &'a User, resource_id: &'a str, ) -> BoxFuture<'a, anyhow::Result> { Box::pin(async { // Admin returns early with max permissions if user.admin { return Ok(PermissionLevel::Write.all()); } let resource_type = T::resource_type(); let resource = get::(resource_id).await?; let initial_specific = if let Some(additional_target) = T::inherit_specific_permissions_from(&resource) // Ensure target is actually assigned && !additional_target.is_empty() { GetPermission { target: additional_target, } .resolve(&ReadArgs { user: user.clone() }) .await .map_err(|e| e.error) .context("failed to get user permission on additional target")? .specific } else { IndexSet::new() }; let mut permission = PermissionLevelAndSpecifics { level: if core_config().transparent_mode { PermissionLevel::Read } else { PermissionLevel::None }, specific: initial_specific, }; // Add in the resource level global base permissions if resource.base_permission.level > permission.level { permission.level = resource.base_permission.level; } permission .specific .extend(resource.base_permission.specific); // Overlay users base on resource variant if let Some(user_permission) = user.all.get(&resource_type).cloned() { if user_permission.level > permission.level { permission.level = user_permission.level; } permission.specific.extend(user_permission.specific); } // Overlay any user groups base on resource variant let groups = get_user_user_groups(&user.id).await?; for group in &groups { if let Some(group_permission) = group.all.get(&resource_type).cloned() { if group_permission.level > permission.level { permission.level = group_permission.level; } permission.specific.extend(group_permission.specific); } } // Overlay any specific permissions let permission = find_collect( &db_client().permissions, doc! { "$or": user_target_query(&user.id, &groups)?, "resource_target.type": resource_type.as_ref(), "resource_target.id": resource_id }, None, ) .await .context("failed to query db for permissions")? .into_iter() // get the max resource permission user has between personal / any user groups .fold(permission, |mut permission, resource_permission| { if resource_permission.level > permission.level { permission.level = resource_permission.level } permission.specific.extend(resource_permission.specific); permission }); Ok(permission) }) } /// Returns None if still no need to filter by resource id (eg transparent mode, group membership with all access). #[instrument(level = "debug")] pub async fn get_resource_ids_for_user( user: &User, ) -> anyhow::Result>> { // Check admin or transparent mode if user.admin || core_config().transparent_mode { return Ok(None); } let resource_type = T::resource_type(); // Check user 'all' on variant if let Some(permission) = user.all.get(&resource_type).cloned() && permission.level > PermissionLevel::None { return Ok(None); } // Check user groups 'all' on variant let groups = get_user_user_groups(&user.id).await?; for group in &groups { if let Some(permission) = group.all.get(&resource_type).cloned() && permission.level > PermissionLevel::None { return Ok(None); } } let (base, perms) = tokio::try_join!( // Get any resources with non-none base permission, find_collect( T::coll(), doc! { "$or": [ { "base_permission": { "$in": ["Read", "Execute", "Write"] } }, { "base_permission.level": { "$in": ["Read", "Execute", "Write"] } } ] }, None, ) .map(|res| res.with_context(|| format!( "failed to query {resource_type} on db" ))), // And any ids using the permissions table find_collect( &db_client().permissions, doc! { "$or": user_target_query(&user.id, &groups)?, "resource_target.type": resource_type.as_ref(), "level": { "$in": ["Read", "Execute", "Write"] } }, None, ) .map(|res| res.context("failed to query permissions on db")) )?; // Add specific ids let ids = perms .into_iter() .map(|p| p.resource_target.extract_variant_id().1.to_string()) // Chain in the ones with non-None base permissions .chain(base.into_iter().map(|res| res.id)) // collect into hashset first to remove any duplicates .collect::>(); Ok(Some(ids.into_iter().collect())) } ================================================ FILE: bin/core/src/resource/action.rs ================================================ use std::time::Duration; use anyhow::Context; use database::mungos::{ find::find_collect, mongodb::{Collection, bson::doc, options::FindOneOptions}, }; use komodo_client::entities::{ NoData, Operation, ResourceTarget, ResourceTargetVariant, action::{ Action, ActionConfig, ActionConfigDiff, ActionListItem, ActionListItemInfo, ActionQuerySpecifics, ActionState, PartialActionConfig, }, resource::Resource, update::Update, user::User, }; use crate::{ helpers::query::{get_action_state, get_last_run_at}, schedule::{ cancel_schedule, get_schedule_item_info, update_schedule, }, state::{action_state_cache, action_states, db_client}, }; impl super::KomodoResource for Action { type Config = ActionConfig; type PartialConfig = PartialActionConfig; type ConfigDiff = ActionConfigDiff; type Info = NoData; type ListItem = ActionListItem; type QuerySpecifics = ActionQuerySpecifics; fn resource_type() -> ResourceTargetVariant { ResourceTargetVariant::Action } fn resource_target(id: impl Into) -> ResourceTarget { ResourceTarget::Action(id.into()) } fn coll() -> &'static Collection> { &db_client().actions } async fn to_list_item( action: Resource, ) -> Self::ListItem { let (state, last_run_at) = tokio::join!( get_action_state(&action.id), get_last_run_at::(&action.id) ); let (next_scheduled_run, schedule_error) = get_schedule_item_info( &ResourceTarget::Action(action.id.clone()), ); ActionListItem { name: action.name, id: action.id, template: action.template, tags: action.tags, resource_type: ResourceTargetVariant::Action, info: ActionListItemInfo { state, last_run_at: last_run_at.unwrap_or(None), next_scheduled_run, schedule_error, }, } } async fn busy(id: &String) -> anyhow::Result { action_states() .action .get(id) .await .unwrap_or_default() .busy() } // CREATE fn create_operation() -> Operation { Operation::CreateAction } fn user_can_create(user: &User) -> bool { user.admin } async fn validate_create_config( config: &mut Self::PartialConfig, _user: &User, ) -> anyhow::Result<()> { if config.file_contents.is_none() { config.file_contents = Some(DEFAULT_ACTION_FILE_CONTENTS.to_string()); } Ok(()) } async fn post_create( created: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { update_schedule(created); refresh_action_state_cache().await; Ok(()) } // UPDATE fn update_operation() -> Operation { Operation::UpdateAction } async fn validate_update_config( _id: &str, _config: &mut Self::PartialConfig, _user: &User, ) -> anyhow::Result<()> { Ok(()) } async fn post_update( updated: &Self, update: &mut Update, ) -> anyhow::Result<()> { Self::post_create(updated, update).await } // RENAME fn rename_operation() -> Operation { Operation::RenameAction } // DELETE fn delete_operation() -> Operation { Operation::DeleteAction } async fn pre_delete( _resource: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { Ok(()) } async fn post_delete( resource: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { cancel_schedule(&ResourceTarget::Action(resource.id.clone())); action_state_cache().remove(&resource.id).await; Ok(()) } } pub fn spawn_action_state_refresh_loop() { tokio::spawn(async move { loop { refresh_action_state_cache().await; tokio::time::sleep(Duration::from_secs(60)).await; } }); } pub async fn refresh_action_state_cache() { let _ = async { let actions = find_collect(&db_client().actions, None, None) .await .context("Failed to get Actions from db")?; let cache = action_state_cache(); for action in actions { let state = get_action_state_from_db(&action.id).await; cache.insert(action.id, state).await; } anyhow::Ok(()) } .await .inspect_err(|e| { error!("Failed to refresh Action state cache | {e:#}") }); } async fn get_action_state_from_db(id: &str) -> ActionState { async { let state = db_client() .updates .find_one(doc! { "target.type": "Action", "target.id": id, "operation": "RunAction" }) .with_options( FindOneOptions::builder() .sort(doc! { "start_ts": -1 }) .build(), ) .await? .map(|u| { if u.success { ActionState::Ok } else { ActionState::Failed } }) .unwrap_or(ActionState::Ok); anyhow::Ok(state) } .await .inspect_err(|e| { warn!("Failed to get Action state for {id} | {e:#}") }) .unwrap_or(ActionState::Unknown) } const DEFAULT_ACTION_FILE_CONTENTS: &str = "// Run actions using the pre initialized 'komodo' client. const version: Types.GetVersionResponse = await komodo.read('GetVersion', {}); console.log('🦎 Komodo version:', version.version, '🦎\\n'); // Access arguments using the 'ARGS' object. console.log(ARGS);"; ================================================ FILE: bin/core/src/resource/alerter.rs ================================================ use database::mungos::mongodb::Collection; use derive_variants::ExtractVariant; use komodo_client::entities::{ Operation, ResourceTarget, ResourceTargetVariant, alerter::{ Alerter, AlerterConfig, AlerterConfigDiff, AlerterListItem, AlerterListItemInfo, AlerterQuerySpecifics, PartialAlerterConfig, }, resource::Resource, update::Update, user::User, }; use crate::state::db_client; impl super::KomodoResource for Alerter { type Config = AlerterConfig; type PartialConfig = PartialAlerterConfig; type ConfigDiff = AlerterConfigDiff; type Info = (); type ListItem = AlerterListItem; type QuerySpecifics = AlerterQuerySpecifics; fn resource_type() -> ResourceTargetVariant { ResourceTargetVariant::Alerter } fn resource_target(id: impl Into) -> ResourceTarget { ResourceTarget::Alerter(id.into()) } fn coll() -> &'static Collection> { &db_client().alerters } async fn to_list_item( alerter: Resource, ) -> Self::ListItem { AlerterListItem { name: alerter.name, id: alerter.id, template: alerter.template, tags: alerter.tags, resource_type: ResourceTargetVariant::Alerter, info: AlerterListItemInfo { endpoint_type: alerter.config.endpoint.extract_variant(), enabled: alerter.config.enabled, }, } } async fn busy(_id: &String) -> anyhow::Result { Ok(false) } // CREATE fn create_operation() -> Operation { Operation::CreateAlerter } fn user_can_create(user: &User) -> bool { user.admin } async fn validate_create_config( _config: &mut Self::PartialConfig, _user: &User, ) -> anyhow::Result<()> { Ok(()) } async fn post_create( _created: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { Ok(()) } // UPDATE fn update_operation() -> Operation { Operation::UpdateAlerter } async fn validate_update_config( _id: &str, _config: &mut Self::PartialConfig, _user: &User, ) -> anyhow::Result<()> { Ok(()) } async fn post_update( _updated: &Self, _update: &mut Update, ) -> anyhow::Result<()> { Ok(()) } // RENAME fn rename_operation() -> Operation { Operation::RenameAlerter } // DELETE fn delete_operation() -> Operation { Operation::DeleteAlerter } async fn pre_delete( _resource: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { Ok(()) } async fn post_delete( _resource: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { Ok(()) } } ================================================ FILE: bin/core/src/resource/build.rs ================================================ use std::time::Duration; use anyhow::Context; use database::mungos::{ find::find_collect, mongodb::{Collection, bson::doc, options::FindOptions}, }; use formatting::format_serror; use komodo_client::{ api::write::RefreshBuildCache, entities::{ Operation, ResourceTarget, ResourceTargetVariant, build::{ Build, BuildConfig, BuildConfigDiff, BuildInfo, BuildListItem, BuildListItemInfo, BuildQuerySpecifics, BuildState, PartialBuildConfig, }, builder::Builder, environment_vars_from_str, optional_string, permission::PermissionLevel, repo::Repo, resource::Resource, to_docker_compatible_name, update::Update, user::{User, build_user}, }, }; use resolver_api::Resolve; use crate::{ api::write::WriteArgs, config::core_config, helpers::{ empty_or_only_spaces, query::get_latest_update, repo_link, }, permission::get_check_permissions, state::{ action_states, all_resources_cache, build_state_cache, db_client, }, }; impl super::KomodoResource for Build { type Config = BuildConfig; type PartialConfig = PartialBuildConfig; type ConfigDiff = BuildConfigDiff; type Info = BuildInfo; type ListItem = BuildListItem; type QuerySpecifics = BuildQuerySpecifics; fn resource_type() -> ResourceTargetVariant { ResourceTargetVariant::Build } fn resource_target(id: impl Into) -> ResourceTarget { ResourceTarget::Build(id.into()) } fn validated_name(name: &str) -> String { to_docker_compatible_name(name) } fn coll() -> &'static Collection> { &db_client().builds } async fn to_list_item( build: Resource, ) -> Self::ListItem { let state = get_build_state(&build.id).await; let default_git = ( build.config.git_provider, build.config.repo, build.config.branch, build.config.git_https, ); let (git_provider, repo, branch, git_https) = if build.config.linked_repo.is_empty() { default_git } else { all_resources_cache() .load() .repos .get(&build.config.linked_repo) .map(|r| { ( r.config.git_provider.clone(), r.config.repo.clone(), r.config.branch.clone(), r.config.git_https, ) }) .unwrap_or(default_git) }; BuildListItem { name: build.name, id: build.id, template: build.template, tags: build.tags, resource_type: ResourceTargetVariant::Build, info: BuildListItemInfo { last_built_at: build.info.last_built_at, version: build.config.version, builder_id: build.config.builder_id, files_on_host: build.config.files_on_host, dockerfile_contents: !build.config.dockerfile.is_empty(), linked_repo: build.config.linked_repo, repo_link: repo_link( &git_provider, &repo, &branch, git_https, ), git_provider, repo, branch, image_registry_domain: build .config .image_registry .first() .and_then(|r| optional_string(&r.domain)), built_hash: build.info.built_hash, latest_hash: build.info.latest_hash, state, }, } } async fn busy(id: &String) -> anyhow::Result { action_states() .build .get(id) .await .unwrap_or_default() .busy() } // CREATE fn create_operation() -> Operation { Operation::CreateBuild } fn user_can_create(user: &User) -> bool { user.admin || (!core_config().disable_non_admin_create && user.create_build_permissions) } async fn validate_create_config( config: &mut Self::PartialConfig, user: &User, ) -> anyhow::Result<()> { validate_config(config, user).await } async fn post_create( created: &Resource, update: &mut Update, ) -> anyhow::Result<()> { refresh_build_state_cache().await; if let Err(e) = (RefreshBuildCache { build: created.name.clone(), }) .resolve(&WriteArgs { user: build_user().to_owned(), }) .await { update.push_error_log( "Refresh build cache", format_serror(&e.error.context("The build cache has failed to refresh. This may be due to a misconfiguration of the Build").into()) ); }; Ok(()) } // UPDATE fn update_operation() -> Operation { Operation::UpdateBuild } async fn validate_update_config( _id: &str, config: &mut Self::PartialConfig, user: &User, ) -> anyhow::Result<()> { validate_config(config, user).await } async fn post_update( updated: &Self, update: &mut Update, ) -> anyhow::Result<()> { Self::post_create(updated, update).await } // RENAME fn rename_operation() -> Operation { Operation::RenameBuild } // DELETE fn delete_operation() -> Operation { Operation::DeleteBuild } async fn pre_delete( _resource: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { Ok(()) } async fn post_delete( resource: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { build_state_cache().remove(&resource.id).await; Ok(()) } } pub fn spawn_build_state_refresh_loop() { tokio::spawn(async move { loop { refresh_build_state_cache().await; tokio::time::sleep(Duration::from_secs(60)).await; } }); } pub async fn refresh_build_state_cache() { let _ = async { let builds = find_collect(&db_client().builds, None, None) .await .context("failed to get builds from db")?; let cache = build_state_cache(); for build in builds { let state = get_build_state_from_db(&build.id).await; cache.insert(build.id, state).await; } anyhow::Ok(()) } .await .inspect_err(|e| { error!("failed to refresh build state cache | {e:#}") }); } #[instrument(skip(user))] async fn validate_config( config: &mut PartialBuildConfig, user: &User, ) -> anyhow::Result<()> { if let Some(builder_id) = &config.builder_id && !builder_id.is_empty() { let builder = super::get_check_permissions::( builder_id, user, PermissionLevel::Read.attach(), ) .await .context("Cannot attach Build to this Builder")?; config.builder_id = Some(builder.id) } if let Some(linked_repo) = &config.linked_repo && !linked_repo.is_empty() { let repo = get_check_permissions::( linked_repo, user, PermissionLevel::Read.attach(), ) .await .context("Cannot attach Repo to this Build")?; // in case it comes in as name config.linked_repo = Some(repo.id); } if let Some(build_args) = &config.build_args { environment_vars_from_str(build_args) .context("Invalid build_args")?; } if let Some(secret_args) = &config.secret_args { environment_vars_from_str(secret_args) .context("Invalid secret_args")?; } if let Some(extra_args) = &mut config.extra_args { extra_args.retain(|v| !empty_or_only_spaces(v)) } Ok(()) } async fn get_build_state(id: &String) -> BuildState { if action_states() .build .get(id) .await .map(|s| s.get().map(|s| s.building)) .transpose() .ok() .flatten() .unwrap_or_default() { return BuildState::Building; } build_state_cache().get(id).await.unwrap_or_default() } async fn get_build_state_from_db(id: &str) -> BuildState { async { let state = match tokio::try_join!( latest_2_build_updates(id), get_latest_update( ResourceTargetVariant::Build, id, Operation::CancelBuild ), )? { ([Some(build), second], Some(cancel)) if cancel.start_ts > build.start_ts => { match second { Some(build) => { if build.success { BuildState::Ok } else { BuildState::Failed } } None => BuildState::Ok, } } ([Some(build), _], _) => { if build.success { BuildState::Ok } else { BuildState::Failed } } _ => { // No build update ever, should be fine BuildState::Ok } }; anyhow::Ok(state) } .await .inspect_err(|e| { warn!("failed to get build state for {id} | {e:#}") }) .unwrap_or(BuildState::Unknown) } async fn latest_2_build_updates( id: &str, ) -> anyhow::Result<[Option; 2]> { let mut builds = find_collect( &db_client().updates, doc! { "target.type": "Build", "target.id": id, "operation": "RunBuild" }, FindOptions::builder() .sort(doc! { "start_ts": -1 }) .limit(2) .build(), ) .await .context("failed to query for latest updates")?; let second = builds.pop(); let first = builds.pop(); Ok([first, second]) } ================================================ FILE: bin/core/src/resource/builder.rs ================================================ use anyhow::Context; use database::mungos::mongodb::{ Collection, bson::{Document, doc, to_document}, }; use indexmap::IndexSet; use komodo_client::entities::{ MergePartial, Operation, ResourceTarget, ResourceTargetVariant, builder::{ Builder, BuilderConfig, BuilderConfigDiff, BuilderConfigVariant, BuilderListItem, BuilderListItemInfo, BuilderQuerySpecifics, PartialBuilderConfig, PartialServerBuilderConfig, }, permission::{PermissionLevel, SpecificPermission}, resource::Resource, server::Server, update::Update, user::User, }; use crate::state::db_client; impl super::KomodoResource for Builder { type Config = BuilderConfig; type PartialConfig = PartialBuilderConfig; type ConfigDiff = BuilderConfigDiff; type Info = (); type ListItem = BuilderListItem; type QuerySpecifics = BuilderQuerySpecifics; fn resource_type() -> ResourceTargetVariant { ResourceTargetVariant::Builder } fn resource_target(id: impl Into) -> ResourceTarget { ResourceTarget::Builder(id.into()) } fn creator_specific_permissions() -> IndexSet { [SpecificPermission::Attach].into_iter().collect() } fn coll() -> &'static Collection> { &db_client().builders } async fn to_list_item( builder: Resource, ) -> Self::ListItem { let (builder_type, instance_type) = match builder.config { BuilderConfig::Url(_) => { (BuilderConfigVariant::Url.to_string(), None) } BuilderConfig::Server(config) => ( BuilderConfigVariant::Server.to_string(), Some(config.server_id), ), BuilderConfig::Aws(config) => ( BuilderConfigVariant::Aws.to_string(), Some(config.instance_type), ), }; BuilderListItem { name: builder.name, id: builder.id, template: builder.template, tags: builder.tags, resource_type: ResourceTargetVariant::Builder, info: BuilderListItemInfo { builder_type, instance_type, }, } } async fn busy(_id: &String) -> anyhow::Result { Ok(false) } // CREATE fn create_operation() -> Operation { Operation::CreateBuilder } fn user_can_create(user: &User) -> bool { user.admin } async fn validate_create_config( config: &mut Self::PartialConfig, user: &User, ) -> anyhow::Result<()> { validate_config(config, user).await } async fn post_create( _created: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { Ok(()) } // UPDATE fn update_operation() -> Operation { Operation::UpdateBuilder } async fn validate_update_config( _id: &str, config: &mut Self::PartialConfig, user: &User, ) -> anyhow::Result<()> { validate_config(config, user).await } fn update_document( original: Resource, config: Self::PartialConfig, ) -> Result { let config = original.config.merge_partial(config); to_document(&config) } async fn post_update( _updated: &Self, _update: &mut Update, ) -> anyhow::Result<()> { Ok(()) } // RENAME fn rename_operation() -> Operation { Operation::RenameBuilder } // DELETE fn delete_operation() -> Operation { Operation::DeleteBuilder } async fn pre_delete( resource: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { db_client() .builds .update_many( doc! { "config.builder_id": &resource.id }, database::mungos::update::Update::Set( doc! { "config.builder_id": "" }, ), ) .await .context("failed to update_many builds on database")?; db_client() .repos .update_many( doc! { "config.builder_id": &resource.id }, database::mungos::update::Update::Set( doc! { "config.builder_id": "" }, ), ) .await .context("failed to update_many repos on database")?; Ok(()) } async fn post_delete( _resource: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { Ok(()) } } #[instrument(skip(user))] async fn validate_config( config: &mut PartialBuilderConfig, user: &User, ) -> anyhow::Result<()> { match config { PartialBuilderConfig::Server(PartialServerBuilderConfig { server_id: Some(server_id), }) if !server_id.is_empty() => { let server = super::get_check_permissions::( server_id, user, PermissionLevel::Read.attach(), ) .await?; *server_id = server.id; } _ => {} } Ok(()) } ================================================ FILE: bin/core/src/resource/deployment.rs ================================================ use anyhow::Context; use database::mungos::mongodb::Collection; use formatting::format_serror; use indexmap::IndexSet; use komodo_client::entities::{ Operation, ResourceTarget, ResourceTargetVariant, build::Build, deployment::{ Deployment, DeploymentConfig, DeploymentConfigDiff, DeploymentImage, DeploymentListItem, DeploymentListItemInfo, DeploymentQuerySpecifics, DeploymentState, PartialDeploymentConfig, conversions_from_str, }, environment_vars_from_str, permission::{PermissionLevel, SpecificPermission}, resource::Resource, server::Server, to_container_compatible_name, update::Update, user::User, }; use periphery_client::api::container::RemoveContainer; use crate::{ config::core_config, helpers::{ empty_or_only_spaces, periphery_client, query::get_deployment_state, }, monitor::update_cache_for_server, state::{action_states, db_client, deployment_status_cache}, }; use super::get_check_permissions; impl super::KomodoResource for Deployment { type Config = DeploymentConfig; type PartialConfig = PartialDeploymentConfig; type ConfigDiff = DeploymentConfigDiff; type Info = (); type ListItem = DeploymentListItem; type QuerySpecifics = DeploymentQuerySpecifics; fn resource_type() -> ResourceTargetVariant { ResourceTargetVariant::Deployment } fn resource_target(id: impl Into) -> ResourceTarget { ResourceTarget::Deployment(id.into()) } fn validated_name(name: &str) -> String { to_container_compatible_name(name) } fn creator_specific_permissions() -> IndexSet { [ SpecificPermission::Inspect, SpecificPermission::Logs, SpecificPermission::Terminal, ] .into_iter() .collect() } fn inherit_specific_permissions_from( _self: &Resource, ) -> Option { ResourceTarget::Server(_self.config.server_id.clone()).into() } fn coll() -> &'static Collection> { &db_client().deployments } async fn to_list_item( deployment: Resource, ) -> Self::ListItem { let status = deployment_status_cache().get(&deployment.id).await; let state = if action_states() .deployment .get(&deployment.id) .await .map(|s| s.get().map(|s| s.deploying)) .transpose() .ok() .flatten() .unwrap_or_default() { DeploymentState::Deploying } else { status.as_ref().map(|s| s.curr.state).unwrap_or_default() }; let (build_image, build_id) = match deployment.config.image { DeploymentImage::Build { build_id, version } => { let (build_name, build_id, build_version) = super::get::(&build_id) .await .map(|b| (b.name, b.id, b.config.version)) .unwrap_or(( String::from("unknown"), String::new(), Default::default(), )); let version = if version.is_none() { build_version.to_string() } else { version.to_string() }; (format!("{build_name}:{version}"), Some(build_id)) } DeploymentImage::Image { image } => (image, None), }; let (image, update_available) = status .as_ref() .and_then(|s| { s.curr.container.as_ref().map(|c| { ( c.image .clone() .unwrap_or_else(|| String::from("Unknown")), s.curr.update_available, ) }) }) .unwrap_or((build_image, false)); DeploymentListItem { name: deployment.name, id: deployment.id, template: deployment.template, tags: deployment.tags, resource_type: ResourceTargetVariant::Deployment, info: DeploymentListItemInfo { state, status: status.as_ref().and_then(|s| { s.curr.container.as_ref().and_then(|c| c.status.to_owned()) }), image, update_available, server_id: deployment.config.server_id, build_id, }, } } async fn busy(id: &String) -> anyhow::Result { action_states() .deployment .get(id) .await .unwrap_or_default() .busy() } // CREATE fn create_operation() -> Operation { Operation::CreateDeployment } fn user_can_create(user: &User) -> bool { user.admin || !core_config().disable_non_admin_create } async fn validate_create_config( config: &mut Self::PartialConfig, user: &User, ) -> anyhow::Result<()> { validate_config(config, user).await } async fn post_create( created: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { if created.config.server_id.is_empty() { return Ok(()); } let Ok(server) = super::get::(&created.config.server_id) .await .inspect_err(|e| { warn!( "Failed to get Server for Deployment {} | {e:#}", created.name ) }) else { return Ok(()); }; update_cache_for_server(&server, true).await; Ok(()) } // UPDATE fn update_operation() -> Operation { Operation::UpdateDeployment } async fn validate_update_config( _id: &str, config: &mut Self::PartialConfig, user: &User, ) -> anyhow::Result<()> { validate_config(config, user).await } async fn post_update( updated: &Self, update: &mut Update, ) -> anyhow::Result<()> { Self::post_create(updated, update).await } // RENAME fn rename_operation() -> Operation { Operation::RenameDeployment } // DELETE fn delete_operation() -> Operation { Operation::DeleteDeployment } async fn pre_delete( deployment: &Resource, update: &mut Update, ) -> anyhow::Result<()> { let state = get_deployment_state(&deployment.id) .await .context("Failed to get deployment state")?; if matches!( state, DeploymentState::NotDeployed | DeploymentState::Unknown ) { return Ok(()); } // container needs to be destroyed let server = match super::get::( &deployment.config.server_id, ) .await { Ok(server) => server, Err(e) => { update.push_error_log( "Remove Container", format_serror( &e.context(format!( "failed to retrieve server at {} from db.", deployment.config.server_id )) .into(), ), ); return Ok(()); } }; if !server.config.enabled { // Don't need to update.push_simple_log( "Remove Container", "Skipping container removal, server is disabled.", ); return Ok(()); } let periphery = match periphery_client(&server) { Ok(periphery) => periphery, Err(e) => { // This case won't ever happen, as periphery_client only fallible if the server is disabled. // Leaving it for completeness sake update.push_error_log( "Remove Container", format_serror( &e.context("Failed to get periphery client").into(), ), ); return Ok(()); } }; match periphery .request(RemoveContainer { name: deployment.name.clone(), signal: deployment.config.termination_signal.into(), time: deployment.config.termination_timeout.into(), }) .await { Ok(log) => update.logs.push(log), Err(e) => update.push_error_log( "Remove Container", format_serror( &e.context("Failed to remove container").into(), ), ), }; Ok(()) } async fn post_delete( resource: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { deployment_status_cache().remove(&resource.id).await; Ok(()) } } #[instrument(skip(user))] async fn validate_config( config: &mut PartialDeploymentConfig, user: &User, ) -> anyhow::Result<()> { if let Some(server_id) = &config.server_id && !server_id.is_empty() { let server = get_check_permissions::( server_id, user, PermissionLevel::Read.attach(), ) .await .context("Cannot attach Deployment to this Server")?; config.server_id = Some(server.id); } if let Some(DeploymentImage::Build { build_id, version }) = &config.image && !build_id.is_empty() { let build = get_check_permissions::( build_id, user, PermissionLevel::Read.attach(), ) .await .context("Cannot update deployment with this build attached.")?; config.image = Some(DeploymentImage::Build { build_id: build.id, version: *version, }); } if let Some(volumes) = &config.volumes { conversions_from_str(volumes).context("Invalid volumes")?; } if let Some(ports) = &config.ports { conversions_from_str(ports).context("Invalid ports")?; } if let Some(environment) = &config.environment { environment_vars_from_str(environment) .context("Invalid environment")?; } if let Some(extra_args) = &mut config.extra_args { extra_args.retain(|v| !empty_or_only_spaces(v)) } Ok(()) } ================================================ FILE: bin/core/src/resource/mod.rs ================================================ use std::{ collections::{HashMap, HashSet}, str::FromStr, }; use anyhow::{Context, anyhow}; use database::mungos::{ by_id::{delete_one_by_id, update_one_by_id}, find::find_collect, mongodb::{ Collection, bson::{Document, doc, oid::ObjectId, to_document}, options::FindOptions, }, }; use formatting::format_serror; use futures::future::join_all; use indexmap::IndexSet; use komodo_client::{ api::{read::ExportResourcesToToml, write::CreateTag}, entities::{ Operation, ResourceTarget, ResourceTargetVariant, komodo_timestamp, permission::{ PermissionLevel, PermissionLevelAndSpecifics, SpecificPermission, }, resource::{AddFilters, Resource, ResourceQuery}, tag::Tag, to_general_name, update::Update, user::{User, system_user}, }, parsers::parse_string_list, }; use partial_derive2::{Diff, MaybeNone, PartialDiff}; use reqwest::StatusCode; use resolver_api::Resolve; use serde::{Serialize, de::DeserializeOwned}; use serror::AddStatusCodeError; use crate::{ api::{read::ReadArgs, write::WriteArgs}, helpers::{ create_permission, flatten_document, query::{get_tag, id_or_name_filter}, update::{add_update, make_update}, }, permission::{get_check_permissions, get_resource_ids_for_user}, state::db_client, }; mod action; mod alerter; mod build; mod builder; mod deployment; mod procedure; mod refresh; mod repo; mod server; mod stack; mod sync; pub use action::{ refresh_action_state_cache, spawn_action_state_refresh_loop, }; pub use build::{ refresh_build_state_cache, spawn_build_state_refresh_loop, }; pub use procedure::{ refresh_procedure_state_cache, spawn_procedure_state_refresh_loop, }; pub use refresh::{ refresh_all_resources_cache, spawn_all_resources_cache_refresh_loop, spawn_resource_refresh_loop, }; pub use repo::{ refresh_repo_state_cache, spawn_repo_state_refresh_loop, }; /// Implement on each Komodo resource for common methods pub trait KomodoResource { type ListItem: Serialize + Send; type Config: Clone + Default + Send + Sync + Unpin + Serialize + DeserializeOwned + From + PartialDiff + 'static; type PartialConfig: Clone + Default + From + Serialize + MaybeNone; type ConfigDiff: Into + Serialize + Diff + MaybeNone; type Info: Clone + Send + Sync + Unpin + Default + Serialize + DeserializeOwned + 'static; type QuerySpecifics: AddFilters + Default + std::fmt::Debug; fn resource_type() -> ResourceTargetVariant; fn resource_target(id: impl Into) -> ResourceTarget; fn coll() -> &'static Collection>; async fn to_list_item( resource: Resource, ) -> Self::ListItem; #[allow(clippy::ptr_arg)] async fn busy(id: &String) -> anyhow::Result; /// Some resource types have restrictions on the allowed formatting for names. /// Stacks, Builds, and Deployments all require names to be "docker compatible", /// which means all lowercase, and no spaces or dots. fn validated_name(name: &str) -> String { to_general_name(name) } /// These permissions go to the creator of the resource, /// and include full access to the resource. fn creator_specific_permissions() -> IndexSet { IndexSet::new() } /// For Stacks / Deployments, they should inherit specific /// permissions like `Logs`, `Inspect`, and `Terminal` /// from their attached Server. fn inherit_specific_permissions_from( _self: &Resource, ) -> Option { None } // ======= // CREATE // ======= fn create_operation() -> Operation; fn user_can_create(user: &User) -> bool; async fn validate_create_config( config: &mut Self::PartialConfig, user: &User, ) -> anyhow::Result<()>; async fn default_info() -> anyhow::Result { Ok(Default::default()) } async fn post_create( created: &Resource, update: &mut Update, ) -> anyhow::Result<()>; // ======= // UPDATE // ======= fn update_operation() -> Operation; async fn validate_update_config( id: &str, config: &mut Self::PartialConfig, user: &User, ) -> anyhow::Result<()>; /// Should be overridden for enum configs, eg Alerter, Builder, ... fn update_document( _original: Resource, config: Self::PartialConfig, ) -> Result { to_document(&config) } /// Run any required task after resource updated in database but /// before the request resolves. async fn post_update( updated: &Resource, update: &mut Update, ) -> anyhow::Result<()>; // ======= // RENAME // ======= fn rename_operation() -> Operation; // ======= // DELETE // ======= fn delete_operation() -> Operation; /// Clean up all links to this resource before deleting it. async fn pre_delete( resource: &Resource, update: &mut Update, ) -> anyhow::Result<()>; /// Run any required task after resource deleted from database but /// before the request resolves. async fn post_delete( resource: &Resource, update: &mut Update, ) -> anyhow::Result<()>; } // Methods // ====== // GET // ====== pub async fn get( id_or_name: &str, ) -> anyhow::Result> { if id_or_name.is_empty() { return Err(anyhow!( "Cannot find {} with empty name / id", T::resource_type() )); } T::coll() .find_one(id_or_name_filter(id_or_name)) .await .context("failed to query db for resource")? .with_context(|| { format!( "did not find any {} matching {id_or_name}", T::resource_type() ) }) } // ====== // LIST // ====== /// Returns None if still no need to filter by resource id (eg transparent mode, group membership with all access). #[instrument(level = "debug")] pub async fn get_resource_object_ids_for_user( user: &User, ) -> anyhow::Result>> { get_resource_ids_for_user::(user).await.map(|ids| { ids.map(|ids| { ids .into_iter() .flat_map(|id| ObjectId::from_str(&id)) .collect() }) }) } #[instrument(level = "debug")] pub async fn list_for_user( mut query: ResourceQuery, user: &User, permissions: PermissionLevelAndSpecifics, all_tags: &[Tag], ) -> anyhow::Result> { validate_resource_query_tags(&mut query, all_tags)?; let mut filters = Document::new(); query.add_filters(&mut filters); list_for_user_using_document::(filters, user, permissions).await } // #[instrument(level = "debug")] // pub async fn list_for_user_using_pattern( // pattern: &str, // query: ResourceQuery, // user: &User, // permissions: PermissionLevelAndSpecifics, // all_tags: &[Tag], // ) -> anyhow::Result> { // let list = list_full_for_user_using_pattern::( // pattern, // query, // user, // permissions, // all_tags, // ) // .await? // .into_iter() // .map(|resource| T::to_list_item(resource)); // Ok(join_all(list).await) // } #[instrument(level = "debug")] pub async fn list_for_user_using_document( filters: Document, user: &User, permissions: PermissionLevelAndSpecifics, ) -> anyhow::Result> { let list = list_full_for_user_using_document::(filters, user) .await? .into_iter() .map(|resource| T::to_list_item(resource)); Ok(join_all(list).await) } /// Lists full resource matching wildcard syntax, /// or regex if wrapped with "\\" /// /// ## Example /// ``` /// let items = list_full_for_user_using_match_string::("foo-*", Default::default(), user, all_tags).await?; /// let items = list_full_for_user_using_match_string::("\\^foo-.*$\\", Default::default(), user, all_tags).await?; /// ``` #[instrument(level = "debug")] pub async fn list_full_for_user_using_pattern( pattern: &str, query: ResourceQuery, user: &User, permissions: PermissionLevelAndSpecifics, all_tags: &[Tag], ) -> anyhow::Result>> { let resources = list_full_for_user::(query, user, permissions, all_tags) .await?; let patterns = parse_string_list(pattern); let mut names = HashSet::::new(); for pattern in patterns { if pattern.starts_with('\\') && pattern.ends_with('\\') { let regex = regex::Regex::new(&pattern[1..(pattern.len() - 1)]) .context("Regex matching string invalid")?; for resource in &resources { if regex.is_match(&resource.name) { names.insert(resource.name.clone()); } } } else { let wildcard = wildcard::Wildcard::new(pattern.as_bytes()) .context("Wildcard matching string invalid")?; for resource in &resources { if wildcard.is_match(resource.name.as_bytes()) { names.insert(resource.name.clone()); } } }; } Ok( resources .into_iter() .filter(|resource| names.contains(resource.name.as_str())) .collect(), ) } #[instrument(level = "debug")] pub async fn list_full_for_user( mut query: ResourceQuery, user: &User, permissions: PermissionLevelAndSpecifics, all_tags: &[Tag], ) -> anyhow::Result>> { validate_resource_query_tags(&mut query, all_tags)?; let mut filters = Document::new(); query.add_filters(&mut filters); list_full_for_user_using_document::(filters, user).await } #[instrument(level = "debug")] pub async fn list_full_for_user_using_document( mut filters: Document, user: &User, ) -> anyhow::Result>> { if let Some(ids) = get_resource_object_ids_for_user::(user).await? { filters.insert("_id", doc! { "$in": ids }); } find_collect( T::coll(), filters, FindOptions::builder().sort(doc! { "name": 1 }).build(), ) .await .with_context(|| { format!("failed to pull {}s from mongo", T::resource_type()) }) } pub type IdResourceMap = HashMap< String, Resource< ::Config, ::Info, >, >; #[instrument(level = "debug")] pub async fn get_id_to_resource_map( id_to_tags: &HashMap, match_tags: &[String], ) -> anyhow::Result> { let res = find_collect(T::coll(), None, None) .await .with_context(|| { format!("failed to pull {}s from mongo", T::resource_type()) })? .into_iter() .filter(|resource| { if match_tags.is_empty() { return true; } for tag in match_tags.iter() { for resource_tag in &resource.tags { match ObjectId::from_str(resource_tag) { Ok(_) => match id_to_tags .get(resource_tag) .map(|tag| tag.name.as_str()) { Some(name) => { if tag != name { return false; } } None => return false, }, Err(_) => { if resource_tag != tag { return false; } } } } } true }) .map(|r| (r.id.clone(), r)) .collect(); Ok(res) } // ======= // CREATE // ======= pub async fn create( name: &str, mut config: T::PartialConfig, user: &User, ) -> serror::Result> { if !T::user_can_create(user) { return Err( anyhow!( "User does not have permissions to create {}.", T::resource_type() ) .status_code(StatusCode::FORBIDDEN), ); } if name.is_empty() { return Err( anyhow!("Must provide non-empty name for resource") .status_code(StatusCode::BAD_REQUEST), ); } let name = T::validated_name(name); if ObjectId::from_str(&name).is_ok() { return Err( anyhow!("Valid ObjectIds cannot be used as names") .status_code(StatusCode::BAD_REQUEST), ); } // Ensure an existing resource with same name doesn't already exist // The database indexing also ensures this but doesn't give a good error message. if list_full_for_user::( Default::default(), system_user(), PermissionLevel::Read.into(), &[], ) .await .context("Failed to list all resources for duplicate name check")? .into_iter() .any(|r| r.name == name) { return Err( anyhow!("Resource with name '{}' already exists", name) .status_code(StatusCode::CONFLICT), ); } let start_ts = komodo_timestamp(); T::validate_create_config(&mut config, user).await?; let resource = Resource:: { id: Default::default(), name, description: Default::default(), template: Default::default(), tags: Default::default(), config: config.into(), info: T::default_info().await?, base_permission: PermissionLevel::None.into(), updated_at: start_ts, }; let resource_id = T::coll() .insert_one(&resource) .await .with_context(|| { format!("failed to add {} to db", T::resource_type()) })? .inserted_id .as_object_id() .context("inserted_id is not ObjectId")? .to_string(); let resource = get::(&resource_id).await?; let target = resource_target::(resource_id); create_permission( user, target.clone(), PermissionLevel::Write, T::creator_specific_permissions(), ) .await; let mut update = make_update(target, T::create_operation(), user); update.start_ts = start_ts; update.push_simple_log( &format!("create {}", T::resource_type()), format!( "created {}\nid: {}\nname: {}", T::resource_type(), resource.id, resource.name ), ); update.push_simple_log( "config", serde_json::to_string_pretty(&resource.config) .context("failed to serialize resource config to JSON")?, ); T::post_create(&resource, &mut update).await?; refresh_all_resources_cache().await; update.finalize(); add_update(update).await?; Ok(resource) } // ======= // UPDATE // ======= pub async fn update( id_or_name: &str, mut config: T::PartialConfig, user: &User, ) -> anyhow::Result> { let resource = get_check_permissions::( id_or_name, user, PermissionLevel::Write.into(), ) .await?; if T::busy(&resource.id).await? { return Err(anyhow!("{} busy", T::resource_type())); } T::validate_update_config(&resource.id, &mut config, user).await?; // Gets a diff object. let diff = resource.config.partial_diff(config); if diff.is_none() { return Ok(resource); } // Leave this Result unhandled for now let prev_toml = ExportResourcesToToml { targets: vec![T::resource_target(&resource.id)], ..Default::default() } .resolve(&ReadArgs { user: system_user().to_owned(), }) .await .map_err(|e| e.error) .context("Failed to export resource toml before update"); // This minimizes the update against the existing config let config: T::PartialConfig = diff.into(); let id = resource.id.clone(); let config_doc = T::update_document(resource, config) .context("failed to serialize config to bson document")?; let update_doc = flatten_document(doc! { "config": config_doc }); update_one_by_id(T::coll(), &id, doc! { "$set": update_doc }, None) .await .context("failed to update resource on database")?; let curr_toml = ExportResourcesToToml { targets: vec![T::resource_target(&id)], ..Default::default() } .resolve(&ReadArgs { user: system_user().to_owned(), }) .await .map_err(|e| e.error) .context("Failed to export resource toml after update"); let mut update = make_update( resource_target::(id), T::update_operation(), user, ); match prev_toml { Ok(res) => update.prev_toml = res.toml, Err(e) => update // These logs are pushed with success == true, so user still knows the update was succesful. .push_simple_log("Failed export", format_serror(&e.into())), } match curr_toml { Ok(res) => update.current_toml = res.toml, Err(e) => update // These logs are pushed with success == true, so user still knows the update was succesful. .push_simple_log("Failed export", format_serror(&e.into())), } let updated = get::(id_or_name).await?; T::post_update(&updated, &mut update).await?; refresh_all_resources_cache().await; update.finalize(); add_update(update).await?; Ok(updated) } fn resource_target(id: String) -> ResourceTarget { match T::resource_type() { ResourceTargetVariant::System => ResourceTarget::System(id), ResourceTargetVariant::Build => ResourceTarget::Build(id), ResourceTargetVariant::Builder => ResourceTarget::Builder(id), ResourceTargetVariant::Deployment => { ResourceTarget::Deployment(id) } ResourceTargetVariant::Server => ResourceTarget::Server(id), ResourceTargetVariant::Repo => ResourceTarget::Repo(id), ResourceTargetVariant::Alerter => ResourceTarget::Alerter(id), ResourceTargetVariant::Procedure => ResourceTarget::Procedure(id), ResourceTargetVariant::ResourceSync => { ResourceTarget::ResourceSync(id) } ResourceTargetVariant::Stack => ResourceTarget::Stack(id), ResourceTargetVariant::Action => ResourceTarget::Action(id), } } pub struct ResourceMetaUpdate { pub description: Option, pub template: Option, pub tags: Option>, } impl ResourceMetaUpdate { pub fn is_none(&self) -> bool { self.description.is_none() && self.template.is_none() && self.tags.is_none() } } pub async fn update_meta( id_or_name: &str, meta: ResourceMetaUpdate, args: &WriteArgs, ) -> anyhow::Result<()> { get_check_permissions::( id_or_name, &args.user, PermissionLevel::Write.into(), ) .await?; let mut set = Document::new(); if let Some(description) = meta.description { set.insert("description", description); } if let Some(template) = meta.template { set.insert("template", template); } if let Some(tags) = meta.tags { // First normalize to tag ids only let futures = tags.iter().map(|tag| async { match get_tag(tag).await { Ok(tag) => Ok(tag.id), Err(_) => CreateTag { name: tag.to_string(), color: None, } .resolve(args) .await .map(|tag| tag.id), } }); let tags = join_all(futures) .await .into_iter() .flatten() .collect::>(); set.insert("tags", tags); } T::coll() .update_one(id_or_name_filter(id_or_name), doc! { "$set": set }) .await?; refresh_all_resources_cache().await; Ok(()) } pub async fn remove_tag_from_all( tag_id: &str, ) -> anyhow::Result<()> { T::coll() .update_many(doc! {}, doc! { "$pull": { "tags": tag_id } }) .await .context("failed to remove tag from resources")?; Ok(()) } // ======= // RENAME // ======= pub async fn rename( id_or_name: &str, name: &str, user: &User, ) -> anyhow::Result { let resource = get_check_permissions::( id_or_name, user, PermissionLevel::Write.into(), ) .await?; let mut update = make_update( resource_target::(resource.id.clone()), T::rename_operation(), user, ); let name = T::validated_name(name); update_one_by_id( T::coll(), &resource.id, database::mungos::update::Update::Set( doc! { "name": &name, "updated_at": komodo_timestamp() }, ), None, ) .await .with_context(|| { format!( "Failed to update {ty} on db. This name may already be taken.", ty = T::resource_type() ) })?; update.push_simple_log( &format!("Rename {}", T::resource_type()), format!( "Renamed {ty} {id} from {prev_name} to {name}", ty = T::resource_type(), id = resource.id, prev_name = resource.name ), ); refresh_all_resources_cache().await; update.finalize(); update.id = add_update(update.clone()).await?; Ok(update) } // ======= // DELETE // ======= pub async fn delete( id_or_name: &str, args: &WriteArgs, ) -> anyhow::Result> { let resource = get_check_permissions::( id_or_name, &args.user, PermissionLevel::Write.into(), ) .await?; if T::busy(&resource.id).await? { return Err(anyhow!("{} busy", T::resource_type())); } let target = resource_target::(resource.id.clone()); let toml = ExportResourcesToToml { targets: vec![target.clone()], ..Default::default() } .resolve(&ReadArgs { user: args.user.clone(), }) .await .map_err(|e| e.error)? .toml; let mut update = make_update(target.clone(), T::delete_operation(), &args.user); T::pre_delete(&resource, &mut update).await?; delete_all_permissions_on_resource(target.clone()).await; remove_from_recently_viewed(target.clone()).await; delete_one_by_id(T::coll(), &resource.id, None) .await .with_context(|| { format!("Failed to delete {} from database", T::resource_type()) })?; update.push_simple_log( &format!("Delete {}", T::resource_type()), format!("Deleted {} {}", T::resource_type(), resource.name), ); update.push_simple_log("Deleted Toml", toml); tokio::join!( async { if let Err(e) = T::post_delete(&resource, &mut update).await { update .push_error_log("post delete", format_serror(&e.into())); } }, delete_from_alerters::(&resource.id) ); refresh_all_resources_cache().await; update.finalize(); add_update(update).await?; Ok(resource) } async fn delete_from_alerters(id: &str) { let target_bson = doc! { "type": T::resource_type().as_ref(), "id": id, }; if let Err(e) = db_client() .alerters .update_many(Document::new(), doc! { "$pull": { "config.resources": &target_bson, "config.except_resources": target_bson, } }) .await .context("Failed to clear deleted resource from alerter whitelist / blacklist") { warn!("{e:#}"); } } // ======= #[instrument(level = "debug")] pub fn validate_resource_query_tags( query: &mut ResourceQuery, all_tags: &[Tag], ) -> anyhow::Result<()> { query.tags = query .tags .iter() .map(|tag| { all_tags .iter() .find(|t| t.name == *tag || t.id == *tag) .map(|tag| tag.id.clone()) .with_context(|| { format!("No tag found matching name or id: {tag}") }) }) .collect::>>()?; Ok(()) } #[instrument] pub async fn delete_all_permissions_on_resource(target: T) where T: Into + std::fmt::Debug, { let target: ResourceTarget = target.into(); let (variant, id) = target.extract_variant_id(); if let Err(e) = db_client() .permissions .delete_many(doc! { "resource_target.type": variant.as_ref(), "resource_target.id": &id }) .await { warn!( "failed to delete_many permissions matching target {target:?} | {e:#}" ); } } #[instrument] pub async fn remove_from_recently_viewed(resource: T) where T: Into + std::fmt::Debug, { let resource: ResourceTarget = resource.into(); let (recent_field, id) = match resource { ResourceTarget::Server(id) => ("recents.Server", id), ResourceTarget::Deployment(id) => ("recents.Deployment", id), ResourceTarget::Build(id) => ("recents.Build", id), ResourceTarget::Repo(id) => ("recents.Repo", id), ResourceTarget::Procedure(id) => ("recents.Procedure", id), ResourceTarget::Action(id) => ("recents.Action", id), ResourceTarget::Stack(id) => ("recents.Stack", id), ResourceTarget::Builder(id) => ("recents.Builder", id), ResourceTarget::Alerter(id) => ("recents.Alerter", id), ResourceTarget::ResourceSync(id) => ("recents.ResourceSync", id), ResourceTarget::System(_) => return, }; if let Err(e) = db_client() .users .update_many( doc! {}, doc! { "$pull": { recent_field: id } }, ) .await .context("failed to remove resource from users recently viewed") { warn!("{e:#}"); } } ================================================ FILE: bin/core/src/resource/procedure.rs ================================================ use std::time::Duration; use anyhow::{Context, anyhow}; use database::mungos::{ find::find_collect, mongodb::{Collection, bson::doc, options::FindOneOptions}, }; use futures::{TryStreamExt, stream::FuturesUnordered}; use komodo_client::{ api::execute::Execution, entities::{ Operation, ResourceTarget, ResourceTargetVariant, action::Action, alerter::Alerter, build::Build, deployment::Deployment, permission::PermissionLevel, procedure::{ PartialProcedureConfig, Procedure, ProcedureConfig, ProcedureConfigDiff, ProcedureListItem, ProcedureListItemInfo, ProcedureQuerySpecifics, ProcedureState, }, repo::Repo, resource::Resource, server::Server, stack::Stack, sync::ResourceSync, update::Update, user::User, }, }; use crate::{ config::core_config, helpers::query::{get_last_run_at, get_procedure_state}, schedule::{ cancel_schedule, get_schedule_item_info, update_schedule, }, state::{action_states, db_client, procedure_state_cache}, }; impl super::KomodoResource for Procedure { type Config = ProcedureConfig; type PartialConfig = PartialProcedureConfig; type ConfigDiff = ProcedureConfigDiff; type Info = (); type ListItem = ProcedureListItem; type QuerySpecifics = ProcedureQuerySpecifics; fn resource_type() -> ResourceTargetVariant { ResourceTargetVariant::Procedure } fn resource_target(id: impl Into) -> ResourceTarget { ResourceTarget::Procedure(id.into()) } fn coll() -> &'static Collection> { &db_client().procedures } async fn to_list_item( procedure: Resource, ) -> Self::ListItem { let (state, last_run_at) = tokio::join!( get_procedure_state(&procedure.id), get_last_run_at::(&procedure.id) ); let (next_scheduled_run, schedule_error) = get_schedule_item_info( &ResourceTarget::Procedure(procedure.id.clone()), ); ProcedureListItem { name: procedure.name, id: procedure.id, template: procedure.template, tags: procedure.tags, resource_type: ResourceTargetVariant::Procedure, info: ProcedureListItemInfo { stages: procedure.config.stages.len() as i64, state, last_run_at: last_run_at.unwrap_or(None), next_scheduled_run, schedule_error, }, } } async fn busy(id: &String) -> anyhow::Result { action_states() .procedure .get(id) .await .unwrap_or_default() .busy() } // CREATE fn create_operation() -> Operation { Operation::CreateProcedure } fn user_can_create(user: &User) -> bool { user.admin || !core_config().disable_non_admin_create } async fn validate_create_config( config: &mut Self::PartialConfig, user: &User, ) -> anyhow::Result<()> { validate_config(config, user, None).await } async fn post_create( created: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { update_schedule(created); refresh_procedure_state_cache().await; Ok(()) } // UPDATE fn update_operation() -> Operation { Operation::UpdateProcedure } async fn validate_update_config( id: &str, config: &mut Self::PartialConfig, user: &User, ) -> anyhow::Result<()> { validate_config(config, user, Some(id)).await } async fn post_update( updated: &Self, update: &mut Update, ) -> anyhow::Result<()> { Self::post_create(updated, update).await } // RENAME fn rename_operation() -> Operation { Operation::RenameProcedure } // DELETE fn delete_operation() -> Operation { Operation::DeleteProcedure } async fn pre_delete( _resource: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { Ok(()) } async fn post_delete( resource: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { cancel_schedule(&ResourceTarget::Procedure(resource.id.clone())); procedure_state_cache().remove(&resource.id).await; Ok(()) } } #[instrument(skip(user))] async fn validate_config( config: &mut PartialProcedureConfig, user: &User, id: Option<&str>, ) -> anyhow::Result<()> { let Some(stages) = &mut config.stages else { return Ok(()); }; for stage in stages { for exec in &mut stage.executions { match &mut exec.execution { Execution::None(_) => {} Execution::RunProcedure(params) => { let procedure = super::get_check_permissions::( ¶ms.procedure, user, PermissionLevel::Execute.into(), ) .await?; match id { Some(id) if procedure.id == id => { return Err(anyhow!( "Cannot have self-referential procedure" )); } _ => {} } params.procedure = procedure.id; } Execution::BatchRunProcedure(_params) => { if !user.admin { return Err(anyhow!( "Non admin user cannot configure Batch executions" )); } } Execution::RunAction(params) => { let action = super::get_check_permissions::( ¶ms.action, user, PermissionLevel::Execute.into(), ) .await?; params.action = action.id; } Execution::BatchRunAction(_params) => { if !user.admin { return Err(anyhow!( "Non admin user cannot configure Batch executions" )); } } Execution::RunBuild(params) => { let build = super::get_check_permissions::( ¶ms.build, user, PermissionLevel::Execute.into(), ) .await?; params.build = build.id; } Execution::BatchRunBuild(_params) => { if !user.admin { return Err(anyhow!( "Non admin user cannot configure Batch executions" )); } } Execution::CancelBuild(params) => { let build = super::get_check_permissions::( ¶ms.build, user, PermissionLevel::Execute.into(), ) .await?; params.build = build.id; } Execution::Deploy(params) => { let deployment = super::get_check_permissions::( ¶ms.deployment, user, PermissionLevel::Execute.into(), ) .await?; params.deployment = deployment.id; } Execution::BatchDeploy(_params) => { if !user.admin { return Err(anyhow!( "Non admin user cannot configure Batch executions" )); } } Execution::PullDeployment(params) => { let deployment = super::get_check_permissions::( ¶ms.deployment, user, PermissionLevel::Execute.into(), ) .await?; params.deployment = deployment.id; } Execution::StartDeployment(params) => { let deployment = super::get_check_permissions::( ¶ms.deployment, user, PermissionLevel::Execute.into(), ) .await?; params.deployment = deployment.id; } Execution::RestartDeployment(params) => { let deployment = super::get_check_permissions::( ¶ms.deployment, user, PermissionLevel::Execute.into(), ) .await?; params.deployment = deployment.id; } Execution::PauseDeployment(params) => { let deployment = super::get_check_permissions::( ¶ms.deployment, user, PermissionLevel::Execute.into(), ) .await?; params.deployment = deployment.id; } Execution::UnpauseDeployment(params) => { let deployment = super::get_check_permissions::( ¶ms.deployment, user, PermissionLevel::Execute.into(), ) .await?; params.deployment = deployment.id; } Execution::StopDeployment(params) => { let deployment = super::get_check_permissions::( ¶ms.deployment, user, PermissionLevel::Execute.into(), ) .await?; params.deployment = deployment.id; } Execution::DestroyDeployment(params) => { let deployment = super::get_check_permissions::( ¶ms.deployment, user, PermissionLevel::Execute.into(), ) .await?; params.deployment = deployment.id; } Execution::BatchDestroyDeployment(_params) => { if !user.admin { return Err(anyhow!( "Non admin user cannot configure Batch executions" )); } } Execution::CloneRepo(params) => { let repo = super::get_check_permissions::( ¶ms.repo, user, PermissionLevel::Execute.into(), ) .await?; params.repo = repo.id; } Execution::BatchCloneRepo(_params) => { if !user.admin { return Err(anyhow!( "Non admin user cannot configure Batch executions" )); } } Execution::PullRepo(params) => { let repo = super::get_check_permissions::( ¶ms.repo, user, PermissionLevel::Execute.into(), ) .await?; params.repo = repo.id; } Execution::BatchPullRepo(_params) => { if !user.admin { return Err(anyhow!( "Non admin user cannot configure Batch executions" )); } } Execution::BuildRepo(params) => { let repo = super::get_check_permissions::( ¶ms.repo, user, PermissionLevel::Execute.into(), ) .await?; params.repo = repo.id; } Execution::BatchBuildRepo(_params) => { if !user.admin { return Err(anyhow!( "Non admin user cannot configure Batch executions" )); } } Execution::CancelRepoBuild(params) => { let repo = super::get_check_permissions::( ¶ms.repo, user, PermissionLevel::Execute.into(), ) .await?; params.repo = repo.id; } Execution::StartContainer(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::RestartContainer(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::PauseContainer(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::UnpauseContainer(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::StopContainer(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::DestroyContainer(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::StartAllContainers(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::RestartAllContainers(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::PauseAllContainers(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::UnpauseAllContainers(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::StopAllContainers(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::PruneContainers(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::DeleteNetwork(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::PruneNetworks(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::DeleteImage(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::PruneImages(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::DeleteVolume(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::PruneVolumes(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::PruneDockerBuilders(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::PruneBuildx(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::PruneSystem(params) => { let server = super::get_check_permissions::( ¶ms.server, user, PermissionLevel::Execute.into(), ) .await?; params.server = server.id; } Execution::RunSync(params) => { let sync = super::get_check_permissions::( ¶ms.sync, user, PermissionLevel::Execute.into(), ) .await?; params.sync = sync.id; } Execution::CommitSync(params) => { // This one is actually a write operation. let sync = super::get_check_permissions::( ¶ms.sync, user, PermissionLevel::Write.into(), ) .await?; params.sync = sync.id; } Execution::DeployStack(params) => { let stack = super::get_check_permissions::( ¶ms.stack, user, PermissionLevel::Execute.into(), ) .await?; params.stack = stack.id; } Execution::BatchDeployStack(_params) => { if !user.admin { return Err(anyhow!( "Non admin user cannot configure Batch executions" )); } } Execution::DeployStackIfChanged(params) => { let stack = super::get_check_permissions::( ¶ms.stack, user, PermissionLevel::Execute.into(), ) .await?; params.stack = stack.id; } Execution::BatchDeployStackIfChanged(_params) => { if !user.admin { return Err(anyhow!( "Non admin user cannot configure Batch executions" )); } } Execution::PullStack(params) => { let stack = super::get_check_permissions::( ¶ms.stack, user, PermissionLevel::Execute.into(), ) .await?; params.stack = stack.id; } Execution::BatchPullStack(_params) => { if !user.admin { return Err(anyhow!( "Non admin user cannot configure Batch executions" )); } } Execution::StartStack(params) => { let stack = super::get_check_permissions::( ¶ms.stack, user, PermissionLevel::Execute.into(), ) .await?; params.stack = stack.id; } Execution::RestartStack(params) => { let stack = super::get_check_permissions::( ¶ms.stack, user, PermissionLevel::Execute.into(), ) .await?; params.stack = stack.id; } Execution::PauseStack(params) => { let stack = super::get_check_permissions::( ¶ms.stack, user, PermissionLevel::Execute.into(), ) .await?; params.stack = stack.id; } Execution::UnpauseStack(params) => { let stack = super::get_check_permissions::( ¶ms.stack, user, PermissionLevel::Execute.into(), ) .await?; params.stack = stack.id; } Execution::StopStack(params) => { let stack = super::get_check_permissions::( ¶ms.stack, user, PermissionLevel::Execute.into(), ) .await?; params.stack = stack.id; } Execution::DestroyStack(params) => { let stack = super::get_check_permissions::( ¶ms.stack, user, PermissionLevel::Execute.into(), ) .await?; params.stack = stack.id; } Execution::RunStackService(params) => { let stack = super::get_check_permissions::( ¶ms.stack, user, PermissionLevel::Execute.into(), ) .await?; params.stack = stack.id; } Execution::BatchDestroyStack(_params) => { if !user.admin { return Err(anyhow!( "Non admin user cannot configure Batch executions" )); } } Execution::TestAlerter(params) => { let alerter = super::get_check_permissions::( ¶ms.alerter, user, PermissionLevel::Execute.into(), ) .await?; params.alerter = alerter.id; } Execution::SendAlert(params) => { params.alerters = params .alerters .iter() .map(async |alerter| { let id = super::get_check_permissions::( alerter, user, PermissionLevel::Execute.into(), ) .await? .id; anyhow::Ok(id) }) .collect::>() .try_collect::>() .await?; } Execution::ClearRepoCache(_params) => { if !user.admin { return Err(anyhow!( "Non admin user cannot clear repo cache" )); } } Execution::BackupCoreDatabase(_params) => { if !user.admin { return Err(anyhow!( "Non admin user cannot trigger core database backup" )); } } Execution::GlobalAutoUpdate(_params) => { if !user.admin { return Err(anyhow!( "Non admin user cannot trigger global auto update" )); } } Execution::Sleep(_) => {} } } } Ok(()) } pub fn spawn_procedure_state_refresh_loop() { tokio::spawn(async move { loop { refresh_procedure_state_cache().await; tokio::time::sleep(Duration::from_secs(60)).await; } }); } pub async fn refresh_procedure_state_cache() { let _ = async { let procedures = find_collect(&db_client().procedures, None, None) .await .context("Failed to get Procedures from db")?; let cache = procedure_state_cache(); for procedure in procedures { let state = get_procedure_state_from_db(&procedure.id).await; cache.insert(procedure.id, state).await; } anyhow::Ok(()) } .await .inspect_err(|e| { error!("Failed to refresh Procedure state cache | {e:#}") }); } async fn get_procedure_state_from_db(id: &str) -> ProcedureState { async { let state = db_client() .updates .find_one(doc! { "target.type": "Procedure", "target.id": id, "operation": "RunProcedure" }) .with_options( FindOneOptions::builder() .sort(doc! { "start_ts": -1 }) .build(), ) .await? .map(|u| { if u.success { ProcedureState::Ok } else { ProcedureState::Failed } }) .unwrap_or(ProcedureState::Ok); anyhow::Ok(state) } .await .inspect_err(|e| { warn!("Failed to get Procedure state for {id} | {e:#}") }) .unwrap_or(ProcedureState::Unknown) } ================================================ FILE: bin/core/src/resource/refresh.rs ================================================ use std::time::Duration; use async_timing_util::{Timelength, get_timelength_in_ms}; use database::mungos::find::find_collect; use komodo_client::{ api::write::{ RefreshBuildCache, RefreshRepoCache, RefreshResourceSyncPending, RefreshStackCache, }, entities::user::{build_user, repo_user, stack_user, sync_user}, }; use resolver_api::Resolve; use crate::{ api::write::WriteArgs, config::core_config, helpers::all_resources::AllResourcesById, state::{all_resources_cache, db_client}, }; pub fn spawn_all_resources_cache_refresh_loop() { tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(15)); loop { interval.tick().await; refresh_all_resources_cache().await; } }); } pub async fn refresh_all_resources_cache() { let all = match AllResourcesById::load().await { Ok(all) => all, Err(e) => { error!("Failed to load all resources by id cache | {e:#}"); return; } }; all_resources_cache().store(all.into()); } pub fn spawn_resource_refresh_loop() { let interval: Timelength = core_config() .resource_poll_interval .try_into() .expect("Invalid resource poll interval"); tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_millis( get_timelength_in_ms(interval) as u64, )); loop { interval.tick().await; refresh_all().await; } }); } async fn refresh_all() { refresh_stacks().await; refresh_builds().await; refresh_repos().await; refresh_syncs().await; } async fn refresh_stacks() { let Ok(stacks) = find_collect(&db_client().stacks, None, None) .await .inspect_err(|e| { warn!( "Failed to get Stacks from database in refresh task | {e:#}" ) }) else { return; }; for stack in stacks { RefreshStackCache { stack: stack.id } .resolve( &WriteArgs { user: stack_user().clone() }, ) .await .inspect_err(|e| { warn!("Failed to refresh Stack cache in refresh task | Stack: {} | {:#}", stack.name, e.error) }) .ok(); } } async fn refresh_builds() { let Ok(builds) = find_collect(&db_client().builds, None, None) .await .inspect_err(|e| { warn!( "Failed to get Builds from database in refresh task | {e:#}" ) }) else { return; }; for build in builds { RefreshBuildCache { build: build.id } .resolve( &WriteArgs { user: build_user().clone() }, ) .await .inspect_err(|e| { warn!("Failed to refresh Build cache in refresh task | Build: {} | {:#}", build.name, e.error) }) .ok(); } } async fn refresh_repos() { let Ok(repos) = find_collect(&db_client().repos, None, None) .await .inspect_err(|e| { warn!( "Failed to get Repos from database in refresh task | {e:#}" ) }) else { return; }; for repo in repos { RefreshRepoCache { repo: repo.id } .resolve( &WriteArgs { user: repo_user().clone() }, ) .await .inspect_err(|e| { warn!("Failed to refresh Repo cache in refresh task | Repo: {} | {:#}", repo.name, e.error) }) .ok(); } } async fn refresh_syncs() { let Ok(syncs) = find_collect( &db_client().resource_syncs, None, None, ) .await .inspect_err(|e| { warn!( "failed to get resource syncs from db in refresh task | {e:#}" ) }) else { return; }; for sync in syncs { RefreshResourceSyncPending { sync: sync.id } .resolve( &WriteArgs { user: sync_user().clone() }, ) .await .inspect_err(|e| { warn!("Failed to refresh ResourceSync in refresh task | Sync: {} | {:#}", sync.name, e.error) }) .ok(); } } ================================================ FILE: bin/core/src/resource/repo.rs ================================================ use std::time::Duration; use anyhow::Context; use database::mungos::{ find::find_collect, mongodb::{Collection, bson::doc, options::FindOneOptions}, }; use formatting::format_serror; use komodo_client::entities::{ Operation, ResourceTarget, ResourceTargetVariant, builder::Builder, permission::PermissionLevel, repo::{ PartialRepoConfig, Repo, RepoConfig, RepoConfigDiff, RepoInfo, RepoListItem, RepoListItemInfo, RepoQuerySpecifics, RepoState, }, resource::Resource, server::Server, to_path_compatible_name, update::Update, user::User, }; use periphery_client::api::git::DeleteRepo; use crate::{ config::core_config, helpers::{periphery_client, repo_link}, state::{ action_states, db_client, repo_state_cache, repo_status_cache, }, }; use super::get_check_permissions; impl super::KomodoResource for Repo { type Config = RepoConfig; type PartialConfig = PartialRepoConfig; type ConfigDiff = RepoConfigDiff; type Info = RepoInfo; type ListItem = RepoListItem; type QuerySpecifics = RepoQuerySpecifics; fn resource_type() -> ResourceTargetVariant { ResourceTargetVariant::Repo } fn resource_target(id: impl Into) -> ResourceTarget { ResourceTarget::Repo(id.into()) } fn validated_name(name: &str) -> String { to_path_compatible_name(name) } fn coll() -> &'static Collection> { &db_client().repos } async fn to_list_item( repo: Resource, ) -> Self::ListItem { let state = get_repo_state(&repo.id).await; let status = repo_status_cache().get(&repo.id).await.unwrap_or_default(); RepoListItem { name: repo.name, id: repo.id, template: repo.template, tags: repo.tags, resource_type: ResourceTargetVariant::Repo, info: RepoListItemInfo { server_id: repo.config.server_id, builder_id: repo.config.builder_id, last_pulled_at: repo.info.last_pulled_at, last_built_at: repo.info.last_built_at, repo_link: repo_link( &repo.config.git_provider, &repo.config.repo, &repo.config.branch, repo.config.git_https, ), git_provider: repo.config.git_provider, repo: repo.config.repo, branch: repo.config.branch, state, cloned_hash: status.latest_hash.clone(), cloned_message: status.latest_message.clone(), latest_hash: repo.info.latest_hash, built_hash: repo.info.built_hash, }, } } async fn busy(id: &String) -> anyhow::Result { action_states() .repo .get(id) .await .unwrap_or_default() .busy() } // CREATE fn create_operation() -> Operation { Operation::CreateRepo } fn user_can_create(user: &User) -> bool { user.admin || !core_config().disable_non_admin_create } async fn validate_create_config( config: &mut Self::PartialConfig, user: &User, ) -> anyhow::Result<()> { validate_config(config, user).await } async fn post_create( _created: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { refresh_repo_state_cache().await; Ok(()) } // UPDATE fn update_operation() -> Operation { Operation::UpdateRepo } async fn validate_update_config( _id: &str, config: &mut Self::PartialConfig, user: &User, ) -> anyhow::Result<()> { validate_config(config, user).await } async fn post_update( _updated: &Self, _update: &mut Update, ) -> anyhow::Result<()> { refresh_repo_state_cache().await; Ok(()) } // RENAME fn rename_operation() -> Operation { Operation::RenameRepo } // DELETE fn delete_operation() -> Operation { Operation::DeleteRepo } async fn pre_delete( repo: &Resource, update: &mut Update, ) -> anyhow::Result<()> { if repo.config.server_id.is_empty() { return Ok(()); } let server = super::get::(&repo.config.server_id).await?; let periphery = periphery_client(&server)?; match periphery .request(DeleteRepo { name: if repo.config.path.is_empty() { to_path_compatible_name(&repo.name) } else { repo.config.path.clone() }, is_build: false, }) .await { Ok(log) => update.logs.push(log), Err(e) => update.push_error_log( "Delete Repo on Periphery", format_serror( &e.context("Failed to delete repo files").into(), ), ), } Ok(()) } async fn post_delete( repo: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { repo_state_cache().remove(&repo.id).await; Ok(()) } } pub fn spawn_repo_state_refresh_loop() { tokio::spawn(async move { loop { refresh_repo_state_cache().await; tokio::time::sleep(Duration::from_secs(60)).await; } }); } pub async fn refresh_repo_state_cache() { let _ = async { let repos = find_collect(&db_client().repos, None, None) .await .context("failed to get repos from db")?; let cache = repo_state_cache(); for repo in repos { let state = get_repo_state_from_db(&repo.id).await; cache.insert(repo.id, state).await; } anyhow::Ok(()) } .await .inspect_err(|e| { warn!("failed to refresh repo state cache | {e:#}") }); } #[instrument(skip(user))] async fn validate_config( config: &mut PartialRepoConfig, user: &User, ) -> anyhow::Result<()> { if let Some(server_id) = &config.server_id && !server_id.is_empty() { let server = get_check_permissions::( server_id, user, PermissionLevel::Read.attach(), ) .await .context("Cannot attach Repo to this Server")?; config.server_id = Some(server.id); } if let Some(builder_id) = &config.builder_id && !builder_id.is_empty() { let builder = super::get_check_permissions::( builder_id, user, PermissionLevel::Read.attach(), ) .await .context("Cannot attach Repo to this Builder")?; config.builder_id = Some(builder.id); } Ok(()) } async fn get_repo_state(id: &String) -> RepoState { if let Some(state) = action_states() .repo .get(id) .await .and_then(|s| { s.get() .map(|s| { if s.cloning { Some(RepoState::Cloning) } else if s.pulling { Some(RepoState::Pulling) } else if s.building { Some(RepoState::Building) } else { None } }) .ok() }) .flatten() { return state; } repo_state_cache().get(id).await.unwrap_or_default() } async fn get_repo_state_from_db(id: &str) -> RepoState { async { let state = db_client() .updates .find_one(doc! { "target.type": "Repo", "target.id": id, "$or": [ { "operation": "CloneRepo" }, { "operation": "PullRepo" }, { "operation": "BuildRepo" }, ], }) .with_options( FindOneOptions::builder() .sort(doc! { "start_ts": -1 }) .build(), ) .await? .map(|u| { if u.success { RepoState::Ok } else { RepoState::Failed } }) .unwrap_or(RepoState::Ok); anyhow::Ok(state) } .await .inspect_err(|e| warn!("failed to get repo state for {id} | {e:#}")) .unwrap_or(RepoState::Unknown) } ================================================ FILE: bin/core/src/resource/server.rs ================================================ use anyhow::Context; use database::mungos::mongodb::{Collection, bson::doc}; use indexmap::IndexSet; use komodo_client::entities::{ Operation, ResourceTarget, ResourceTargetVariant, komodo_timestamp, permission::SpecificPermission, resource::Resource, server::{ PartialServerConfig, Server, ServerConfig, ServerConfigDiff, ServerListItem, ServerListItemInfo, ServerQuerySpecifics, }, update::Update, user::User, }; use crate::{ config::core_config, helpers::query::get_system_info, monitor::update_cache_for_server, state::{action_states, db_client, server_status_cache}, }; impl super::KomodoResource for Server { type Config = ServerConfig; type PartialConfig = PartialServerConfig; type ConfigDiff = ServerConfigDiff; type Info = (); type ListItem = ServerListItem; type QuerySpecifics = ServerQuerySpecifics; fn resource_type() -> ResourceTargetVariant { ResourceTargetVariant::Server } fn resource_target(id: impl Into) -> ResourceTarget { ResourceTarget::Server(id.into()) } fn creator_specific_permissions() -> IndexSet { [ SpecificPermission::Terminal, SpecificPermission::Inspect, SpecificPermission::Attach, SpecificPermission::Logs, SpecificPermission::Processes, ] .into_iter() .collect() } fn coll() -> &'static Collection> { &db_client().servers } async fn to_list_item( server: Resource, ) -> Self::ListItem { let status = server_status_cache().get(&server.id).await; let (terminals_disabled, container_exec_disabled) = get_system_info(&server) .await .map(|i| (i.terminals_disabled, i.container_exec_disabled)) .unwrap_or((true, true)); ServerListItem { name: server.name, id: server.id, template: server.template, tags: server.tags, resource_type: ResourceTargetVariant::Server, info: ServerListItemInfo { state: status.as_ref().map(|s| s.state).unwrap_or_default(), version: status .map(|s| s.version.clone()) .unwrap_or(String::from("Unknown")), region: server.config.region, address: server.config.address, external_address: server.config.external_address, send_unreachable_alerts: server .config .send_unreachable_alerts, send_cpu_alerts: server.config.send_cpu_alerts, send_mem_alerts: server.config.send_mem_alerts, send_disk_alerts: server.config.send_disk_alerts, send_version_mismatch_alerts: server .config .send_version_mismatch_alerts, terminals_disabled, container_exec_disabled, }, } } async fn busy(id: &String) -> anyhow::Result { action_states() .server .get(id) .await .unwrap_or_default() .busy() } // CREATE fn create_operation() -> Operation { Operation::CreateServer } fn user_can_create(user: &User) -> bool { user.admin || (!core_config().disable_non_admin_create && user.create_server_permissions) } async fn validate_create_config( _config: &mut Self::PartialConfig, _user: &User, ) -> anyhow::Result<()> { Ok(()) } async fn post_create( created: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { update_cache_for_server(created, true).await; Ok(()) } // UPDATE fn update_operation() -> Operation { Operation::UpdateServer } async fn validate_update_config( _id: &str, _config: &mut Self::PartialConfig, _user: &User, ) -> anyhow::Result<()> { Ok(()) } async fn post_update( updated: &Self, _update: &mut Update, ) -> anyhow::Result<()> { update_cache_for_server(updated, true).await; Ok(()) } // RENAME fn rename_operation() -> Operation { Operation::RenameServer } // DELETE fn delete_operation() -> Operation { Operation::DeleteServer } async fn pre_delete( resource: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { let db = db_client(); let id = &resource.id; db.builders .update_many( doc! { "config.params.server_id": &id }, doc! { "$set": { "config.params.server_id": "" } }, ) .await .context("failed to detach server from builders")?; db.deployments .update_many( doc! { "config.server_id": &id }, doc! { "$set": { "config.server_id": "" } }, ) .await .context("failed to detach server from deployments")?; db.stacks .update_many( doc! { "config.server_id": &id }, doc! { "$set": { "config.server_id": "" } }, ) .await .context("failed to detach server from stacks")?; db.repos .update_many( doc! { "config.server_id": &id }, doc! { "$set": { "config.server_id": "" } }, ) .await .context("failed to detach server from repos")?; db.alerts .update_many( doc! { "target.type": "Server", "target.id": &id }, doc! { "$set": { "resolved": true, "resolved_ts": komodo_timestamp() } }, ) .await .context("failed to close deleted server alerts")?; Ok(()) } async fn post_delete( resource: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { server_status_cache().remove(&resource.id).await; Ok(()) } } ================================================ FILE: bin/core/src/resource/stack.rs ================================================ use anyhow::Context; use database::mungos::mongodb::Collection; use formatting::format_serror; use indexmap::IndexSet; use komodo_client::{ api::write::RefreshStackCache, entities::{ Operation, ResourceTarget, ResourceTargetVariant, permission::{PermissionLevel, SpecificPermission}, repo::Repo, resource::Resource, server::Server, stack::{ PartialStackConfig, Stack, StackConfig, StackConfigDiff, StackInfo, StackListItem, StackListItemInfo, StackQuerySpecifics, StackServiceWithUpdate, StackState, }, to_docker_compatible_name, update::Update, user::{User, stack_user}, }, }; use periphery_client::api::compose::ComposeExecution; use resolver_api::Resolve; use crate::{ api::write::WriteArgs, config::core_config, helpers::{periphery_client, query::get_stack_state, repo_link}, monitor::update_cache_for_server, state::{ action_states, all_resources_cache, db_client, server_status_cache, stack_status_cache, }, }; use super::get_check_permissions; impl super::KomodoResource for Stack { type Config = StackConfig; type PartialConfig = PartialStackConfig; type ConfigDiff = StackConfigDiff; type Info = StackInfo; type ListItem = StackListItem; type QuerySpecifics = StackQuerySpecifics; fn resource_type() -> ResourceTargetVariant { ResourceTargetVariant::Stack } fn resource_target(id: impl Into) -> ResourceTarget { ResourceTarget::Stack(id.into()) } fn validated_name(name: &str) -> String { to_docker_compatible_name(name) } fn creator_specific_permissions() -> IndexSet { [ SpecificPermission::Inspect, SpecificPermission::Logs, SpecificPermission::Terminal, ] .into_iter() .collect() } fn inherit_specific_permissions_from( _self: &Resource, ) -> Option { ResourceTarget::Server(_self.config.server_id.clone()).into() } fn coll() -> &'static Collection> { &db_client().stacks } async fn to_list_item( stack: Resource, ) -> Self::ListItem { let status = stack_status_cache().get(&stack.id).await; let state = if action_states() .stack .get(&stack.id) .await .map(|s| s.get().map(|s| s.deploying)) .transpose() .ok() .flatten() .unwrap_or_default() { StackState::Deploying } else { status.as_ref().map(|s| s.curr.state).unwrap_or_default() }; let project_name = stack.project_name(false); let services = status .as_ref() .map(|s| { s.curr .services .iter() .map(|service| StackServiceWithUpdate { service: service.service.clone(), image: service.image.clone(), update_available: service.update_available, }) .collect::>() }) .unwrap_or_default(); let default_git = ( stack.config.git_provider, stack.config.repo, stack.config.branch, stack.config.git_https, ); let (git_provider, repo, branch, git_https) = if stack.config.linked_repo.is_empty() { default_git } else { all_resources_cache() .load() .repos .get(&stack.config.linked_repo) .map(|r| { ( r.config.git_provider.clone(), r.config.repo.clone(), r.config.branch.clone(), r.config.git_https, ) }) .unwrap_or(default_git) }; // This is only true if it is KNOWN to be true. so other cases are false. let (project_missing, status) = if stack.config.server_id.is_empty() || matches!(state, StackState::Down | StackState::Unknown) { (false, None) } else if let Some(status) = server_status_cache() .get(&stack.config.server_id) .await .as_ref() { if let Some(projects) = &status.projects { if let Some(project) = projects .iter() .find(|project| project.name == project_name) { (false, project.status.clone()) } else { // The project doesn't exist (true, None) } } else { (false, None) } } else { (false, None) }; StackListItem { name: stack.name, id: stack.id, template: stack.template, tags: stack.tags, resource_type: ResourceTargetVariant::Stack, info: StackListItemInfo { state, status, services, project_missing, file_contents: !stack.config.file_contents.is_empty(), server_id: stack.config.server_id, linked_repo: stack.config.linked_repo, missing_files: stack.info.missing_files, files_on_host: stack.config.files_on_host, repo_link: repo_link( &git_provider, &repo, &branch, git_https, ), git_provider, repo, branch, latest_hash: stack.info.latest_hash, deployed_hash: stack.info.deployed_hash, }, } } async fn busy(id: &String) -> anyhow::Result { action_states() .stack .get(id) .await .unwrap_or_default() .busy() } // CREATE fn create_operation() -> Operation { Operation::CreateStack } fn user_can_create(user: &User) -> bool { user.admin || !core_config().disable_non_admin_create } async fn validate_create_config( config: &mut Self::PartialConfig, user: &User, ) -> anyhow::Result<()> { validate_config(config, user).await } async fn post_create( created: &Resource, update: &mut Update, ) -> anyhow::Result<()> { if let Err(e) = (RefreshStackCache { stack: created.name.clone(), }) .resolve(&WriteArgs { user: stack_user().to_owned(), }) .await { update.push_error_log( "Refresh stack cache", format_serror(&e.error.context("The stack cache has failed to refresh. This may be due to a misconfiguration of the Stack").into()) ); }; if created.config.server_id.is_empty() { return Ok(()); } let Ok(server) = super::get::(&created.config.server_id) .await .inspect_err(|e| { warn!( "Failed to get Server for Stack {} | {e:#}", created.name ) }) else { return Ok(()); }; update_cache_for_server(&server, true).await; Ok(()) } // UPDATE fn update_operation() -> Operation { Operation::UpdateStack } async fn validate_update_config( _id: &str, config: &mut Self::PartialConfig, user: &User, ) -> anyhow::Result<()> { validate_config(config, user).await } async fn post_update( updated: &Resource, update: &mut Update, ) -> anyhow::Result<()> { Self::post_create(updated, update).await } // RENAME fn rename_operation() -> Operation { Operation::RenameStack } // DELETE fn delete_operation() -> Operation { Operation::DeleteStack } async fn pre_delete( stack: &Resource, update: &mut Update, ) -> anyhow::Result<()> { // If it is Up, it should be taken down let state = get_stack_state(stack) .await .context("failed to get stack state")?; if matches!(state, StackState::Down | StackState::Unknown) { return Ok(()); } // stack needs to be destroyed let server = match super::get::(&stack.config.server_id).await { Ok(server) => server, Err(e) => { update.push_error_log( "destroy stack", format_serror( &e.context(format!( "failed to retrieve server at {} from db.", stack.config.server_id )) .into(), ), ); return Ok(()); } }; if !server.config.enabled { update.push_simple_log( "destroy stack", "skipping stack destroy, server is disabled.", ); return Ok(()); } let periphery = match periphery_client(&server) { Ok(periphery) => periphery, Err(e) => { // This case won't ever happen, as periphery_client only fallible if the server is disabled. // Leaving it for completeness sake update.push_error_log( "destroy stack", format_serror( &e.context("failed to get periphery client").into(), ), ); return Ok(()); } }; match periphery .request(ComposeExecution { project: stack.project_name(false), command: String::from("down --remove-orphans"), }) .await { Ok(log) => update.logs.push(log), Err(e) => update.push_simple_log( "Failed to destroy stack", format_serror( &e.context( "failed to destroy stack on periphery server before delete", ) .into(), ), ), }; Ok(()) } async fn post_delete( resource: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { stack_status_cache().remove(&resource.id).await; Ok(()) } } #[instrument(skip(user))] async fn validate_config( config: &mut PartialStackConfig, user: &User, ) -> anyhow::Result<()> { if let Some(server_id) = &config.server_id && !server_id.is_empty() { let server = get_check_permissions::( server_id, user, PermissionLevel::Read.attach(), ) .await .context("Cannot attach Stack to this Server")?; // in case it comes in as name config.server_id = Some(server.id); } if let Some(linked_repo) = &config.linked_repo && !linked_repo.is_empty() { let repo = get_check_permissions::( linked_repo, user, PermissionLevel::Read.attach(), ) .await .context("Cannot attach Repo to this Stack")?; // in case it comes in as name config.linked_repo = Some(repo.id); } Ok(()) } ================================================ FILE: bin/core/src/resource/sync.rs ================================================ use anyhow::Context; use database::mongo_indexed::doc; use database::mungos::mongodb::Collection; use formatting::format_serror; use komodo_client::{ api::write::RefreshResourceSyncPending, entities::{ Operation, ResourceTarget, ResourceTargetVariant, komodo_timestamp, permission::PermissionLevel, repo::Repo, resource::Resource, sync::{ PartialResourceSyncConfig, ResourceSync, ResourceSyncConfig, ResourceSyncConfigDiff, ResourceSyncInfo, ResourceSyncListItem, ResourceSyncListItemInfo, ResourceSyncQuerySpecifics, ResourceSyncState, }, update::Update, user::{User, sync_user}, }, }; use resolver_api::Resolve; use crate::{ api::write::WriteArgs, helpers::repo_link, permission::get_check_permissions, state::{action_states, all_resources_cache, db_client}, }; impl super::KomodoResource for ResourceSync { type Config = ResourceSyncConfig; type PartialConfig = PartialResourceSyncConfig; type ConfigDiff = ResourceSyncConfigDiff; type Info = ResourceSyncInfo; type ListItem = ResourceSyncListItem; type QuerySpecifics = ResourceSyncQuerySpecifics; fn resource_type() -> ResourceTargetVariant { ResourceTargetVariant::ResourceSync } fn resource_target(id: impl Into) -> ResourceTarget { ResourceTarget::ResourceSync(id.into()) } fn coll() -> &'static Collection> { &db_client().resource_syncs } async fn to_list_item( resource_sync: Resource, ) -> Self::ListItem { let state = get_resource_sync_state(&resource_sync.id, &resource_sync.info) .await; let default_git = ( resource_sync.config.git_provider, resource_sync.config.repo, resource_sync.config.branch, resource_sync.config.git_https, ); let (git_provider, repo, branch, git_https) = if resource_sync.config.linked_repo.is_empty() { default_git } else { all_resources_cache() .load() .repos .get(&resource_sync.config.linked_repo) .map(|r| { ( r.config.git_provider.clone(), r.config.repo.clone(), r.config.branch.clone(), r.config.git_https, ) }) .unwrap_or(default_git) }; ResourceSyncListItem { name: resource_sync.name, id: resource_sync.id, template: resource_sync.template, tags: resource_sync.tags, resource_type: ResourceTargetVariant::ResourceSync, info: ResourceSyncListItemInfo { file_contents: !resource_sync.config.file_contents.is_empty(), files_on_host: resource_sync.config.files_on_host, managed: resource_sync.config.managed, linked_repo: resource_sync.config.linked_repo, repo_link: repo_link( &git_provider, &repo, &branch, git_https, ), git_provider, repo, branch, last_sync_ts: resource_sync.info.last_sync_ts, last_sync_hash: resource_sync.info.last_sync_hash, last_sync_message: resource_sync.info.last_sync_message, resource_path: resource_sync.config.resource_path, state, }, } } async fn busy(id: &String) -> anyhow::Result { action_states() .sync .get(id) .await .unwrap_or_default() .busy() } // CREATE fn create_operation() -> Operation { Operation::CreateResourceSync } fn user_can_create(user: &User) -> bool { user.admin } async fn validate_create_config( config: &mut Self::PartialConfig, user: &User, ) -> anyhow::Result<()> { validate_config(config, user).await } async fn post_create( created: &Resource, update: &mut Update, ) -> anyhow::Result<()> { if let Err(e) = (RefreshResourceSyncPending { sync: created.id.clone(), }) .resolve(&WriteArgs { user: sync_user().to_owned(), }) .await { update.push_error_log( "Refresh sync pending", format_serror(&e.error.context("The sync pending cache has failed to refresh. This is likely due to a misconfiguration of the sync").into()) ); }; Ok(()) } // UPDATE fn update_operation() -> Operation { Operation::UpdateResourceSync } async fn validate_update_config( _id: &str, config: &mut Self::PartialConfig, user: &User, ) -> anyhow::Result<()> { validate_config(config, user).await } async fn post_update( updated: &Resource, update: &mut Update, ) -> anyhow::Result<()> { Self::post_create(updated, update).await } // RENAME fn rename_operation() -> Operation { Operation::RenameResourceSync } // DELETE fn delete_operation() -> Operation { Operation::DeleteResourceSync } async fn pre_delete( resource: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { db_client().alerts .update_many( doc! { "target.type": "ResourceSync", "target.id": &resource.id }, doc! { "$set": { "resolved": true, "resolved_ts": komodo_timestamp() } }, ) .await .context("failed to close deleted sync alerts")?; Ok(()) } async fn post_delete( _resource: &Resource, _update: &mut Update, ) -> anyhow::Result<()> { Ok(()) } } #[instrument(skip(user))] async fn validate_config( config: &mut PartialResourceSyncConfig, user: &User, ) -> anyhow::Result<()> { if let Some(linked_repo) = &config.linked_repo && !linked_repo.is_empty() { let repo = get_check_permissions::( linked_repo, user, PermissionLevel::Read.attach(), ) .await .context("Cannot attach Repo to this Resource Sync")?; // in case it comes in as name config.linked_repo = Some(repo.id); } Ok(()) } async fn get_resource_sync_state( id: &String, data: &ResourceSyncInfo, ) -> ResourceSyncState { if let Some(state) = action_states() .sync .get(id) .await .and_then(|s| { s.get() .map(|s| { if s.syncing { Some(ResourceSyncState::Syncing) } else { None } }) .ok() }) .flatten() { return state; } if data.pending_error.is_some() || !data.remote_errors.is_empty() { ResourceSyncState::Failed } else if !data.resource_updates.is_empty() || !data.variable_updates.is_empty() || !data.user_group_updates.is_empty() || data.pending_deploy.to_deploy > 0 { ResourceSyncState::Pending } else { ResourceSyncState::Ok } } ================================================ FILE: bin/core/src/schedule.rs ================================================ use std::{ collections::HashMap, sync::{OnceLock, RwLock}, }; use anyhow::{Context, anyhow}; use async_timing_util::Timelength; use chrono::Local; use croner::parser::CronParser; use database::mungos::find::find_collect; use formatting::format_serror; use komodo_client::{ api::execute::{RunAction, RunProcedure}, entities::{ ResourceTarget, ResourceTargetVariant, ScheduleFormat, action::Action, alert::{Alert, AlertData, SeverityLevel}, komodo_timestamp, procedure::Procedure, user::{action_user, procedure_user}, }, }; use resolver_api::Resolve; use crate::{ alert::send_alerts, api::execute::{ExecuteArgs, ExecuteRequest}, config::core_config, helpers::update::init_execution_update, state::db_client, }; pub fn spawn_schedule_executor() { // Executor thread tokio::spawn(async move { update_schedules().await; loop { let current_time = async_timing_util::wait_until_timelength( Timelength::OneSecond, 0, ) .await as i64; let mut lock = schedules().write().unwrap(); let drained = lock.drain().collect::>(); for (target, next_run) in drained { match next_run { Ok(next_run_time) if current_time >= next_run_time => { tokio::spawn(async move { match &target { ResourceTarget::Action(id) => { let action = match crate::resource::get::( id, ) .await { Ok(action) => action, Err(e) => { warn!( "Scheduled action run on {id} failed | failed to get procedure | {e:?}" ); return; } }; let request = ExecuteRequest::RunAction(RunAction { action: id.clone(), args: Default::default(), }); let update = match init_execution_update( &request, action_user(), ) .await { Ok(update) => update, Err(e) => { error!( "Failed to make update for scheduled action run, action {id} is not being run | {e:#}" ); return; } }; let ExecuteRequest::RunAction(request) = request else { unreachable!() }; if let Err(e) = request .resolve(&ExecuteArgs { user: action_user().to_owned(), update, }) .await { warn!( "Scheduled action run on {id} failed | {e:?}" ); } update_schedule(&action); if action.config.schedule_alert { let alert = Alert { id: Default::default(), target, ts: komodo_timestamp(), resolved_ts: Some(komodo_timestamp()), resolved: true, level: SeverityLevel::Ok, data: AlertData::ScheduleRun { resource_type: ResourceTargetVariant::Action, id: action.id, name: action.name, }, }; send_alerts(&[alert]).await } } ResourceTarget::Procedure(id) => { let procedure = match crate::resource::get::< Procedure, >(id) .await { Ok(procedure) => procedure, Err(e) => { warn!( "Scheduled procedure run on {id} failed | failed to get procedure | {e:?}" ); return; } }; let request = ExecuteRequest::RunProcedure(RunProcedure { procedure: id.clone(), }); let update = match init_execution_update( &request, procedure_user(), ) .await { Ok(update) => update, Err(e) => { error!( "Failed to make update for scheduled procedure run, procedure {id} is not being run | {e:#}" ); return; } }; let ExecuteRequest::RunProcedure(request) = request else { unreachable!() }; if let Err(e) = request .resolve(&ExecuteArgs { user: procedure_user().to_owned(), update, }) .await { warn!( "Scheduled procedure run on {id} failed | {e:?}" ); } update_schedule(&procedure); if procedure.config.schedule_alert { let alert = Alert { id: Default::default(), target, ts: komodo_timestamp(), resolved_ts: Some(komodo_timestamp()), resolved: true, level: SeverityLevel::Ok, data: AlertData::ScheduleRun { resource_type: ResourceTargetVariant::Procedure, id: procedure.id, name: procedure.name, }, }; send_alerts(&[alert]).await } } _ => unreachable!(), } }); } other => { lock.insert(target, other); continue; } }; } } }); } type UnixTimestampMs = i64; type Schedules = HashMap>; fn schedules() -> &'static RwLock { static SCHEDULES: OnceLock> = OnceLock::new(); SCHEDULES.get_or_init(Default::default) } pub fn get_schedule_item_info( target: &ResourceTarget, ) -> (Option, Option) { match schedules().read().unwrap().get(target) { Some(Ok(time)) => (Some(*time), None), Some(Err(e)) => (None, Some(e.clone())), None => (None, None), } } pub fn cancel_schedule(target: &ResourceTarget) { schedules().write().unwrap().remove(target); } pub async fn update_schedules() { let (procedures, actions) = tokio::join!( find_collect(&db_client().procedures, None, None), find_collect(&db_client().actions, None, None), ); let procedures = match procedures .context("failed to get all procedures from db") { Ok(procedures) => procedures, Err(e) => { error!("failed to get procedures for schedule update | {e:#}"); Vec::new() } }; let actions = match actions.context("failed to get all actions from db") { Ok(actions) => actions, Err(e) => { error!("failed to get actions for schedule update | {e:#}"); Vec::new() } }; // clear out any schedules which don't match to existing resources { let mut lock = schedules().write().unwrap(); lock.retain(|target, _| match target { ResourceTarget::Action(id) => { actions.iter().any(|action| &action.id == id) } ResourceTarget::Procedure(id) => { procedures.iter().any(|procedure| &procedure.id == id) } _ => unreachable!(), }); } for procedure in procedures { update_schedule(&procedure); } for action in actions { update_schedule(&action); } } /// Re/spawns the schedule for the given procedure pub fn update_schedule(schedule: impl HasSchedule) { // Cancel any existing schedule for the procedure cancel_schedule(&schedule.target()); if !schedule.enabled() || schedule.schedule().is_empty() { return; } schedules().write().unwrap().insert( schedule.target(), find_next_occurrence(schedule) .map_err(|e| format_serror(&e.into())), ); } fn cron_parser() -> &'static CronParser { static CRON_PARSER: OnceLock = OnceLock::new(); CRON_PARSER.get_or_init(|| { CronParser::builder() .seconds(croner::parser::Seconds::Required) .dom_and_dow(true) .build() }) } /// Finds the next run occurence in UTC ms. fn find_next_occurrence( schedule: impl HasSchedule, ) -> anyhow::Result { let cron = match schedule.format() { ScheduleFormat::Cron => cron_parser() .parse(schedule.schedule()) .context("Invalid CRON schedule")?, ScheduleFormat::English => { let cron = english_to_cron::str_cron_syntax(schedule.schedule()) .map_err(|e| { anyhow!("Failed to parse english to cron | {e:?}") })? .split(' ') // croner does not accept year .take(6) .collect::>() .join(" "); cron_parser() .parse(&cron) .with_context(|| format!("English expression produced invalid CRON schedule | produced: {cron}"))? } }; let next = match (schedule.timezone(), core_config().timezone.as_str()) { ("", "") => { let tz_time = chrono::Local::now().with_timezone(&Local); cron .find_next_occurrence(&tz_time, false) .context("Failed to find next run time")? .timestamp_millis() } ("", timezone) | (timezone, _) => { let tz: chrono_tz::Tz = timezone.parse().context("Failed to parse timezone")?; let tz_time = chrono::Local::now().with_timezone(&tz); cron .find_next_occurrence(&tz_time, false) .context("Failed to find next run time")? .timestamp_millis() } }; Ok(next) } pub trait HasSchedule { fn target(&self) -> ResourceTarget; fn enabled(&self) -> bool; fn format(&self) -> ScheduleFormat; fn schedule(&self) -> &str; fn timezone(&self) -> &str; } impl HasSchedule for &Procedure { fn target(&self) -> ResourceTarget { ResourceTarget::Procedure(self.id.clone()) } fn enabled(&self) -> bool { self.config.schedule_enabled } fn format(&self) -> ScheduleFormat { self.config.schedule_format } fn schedule(&self) -> &str { &self.config.schedule } fn timezone(&self) -> &str { &self.config.schedule_timezone } } impl HasSchedule for &Action { fn target(&self) -> ResourceTarget { ResourceTarget::Action(self.id.clone()) } fn enabled(&self) -> bool { self.config.schedule_enabled } fn format(&self) -> ScheduleFormat { self.config.schedule_format } fn schedule(&self) -> &str { &self.config.schedule } fn timezone(&self) -> &str { &self.config.schedule_timezone } } ================================================ FILE: bin/core/src/stack/execute.rs ================================================ use komodo_client::{ api::execute::*, entities::{ permission::PermissionLevel, stack::{Stack, StackActionState}, update::{Log, Update}, user::User, }, }; use periphery_client::{PeripheryClient, api::compose::*}; use crate::{ helpers::{periphery_client, update::update_update}, monitor::update_cache_for_server, state::action_states, }; use super::get_stack_and_server; pub trait ExecuteCompose { type Extras; async fn execute( periphery: PeripheryClient, stack: Stack, services: Vec, extras: Self::Extras, ) -> anyhow::Result; } pub async fn execute_compose( stack: &str, services: Vec, user: &User, set_in_progress: impl Fn(&mut StackActionState), mut update: Update, extras: T::Extras, ) -> anyhow::Result { let (stack, server) = get_stack_and_server( stack, user, PermissionLevel::Execute.into(), true, ) .await?; // get the action state for the stack (or insert default). let action_state = action_states().stack.get_or_insert_default(&stack.id).await; // Will check to ensure stack not already busy before updating, and return Err if so. // The returned guard will set the action state back to default when dropped. let _action_guard = action_state.update(set_in_progress)?; // Send update here for frontend to recheck action state update_update(update.clone()).await?; let periphery = periphery_client(&server)?; if !services.is_empty() { update.logs.push(Log::simple( "Service/s", format!( "Execution requested for Stack service/s {}", services.join(", ") ), )) } update .logs .push(T::execute(periphery, stack, services, extras).await?); // Ensure cached stack state up to date by updating server cache update_cache_for_server(&server, true).await; update.finalize(); update_update(update.clone()).await?; Ok(update) } fn service_args(services: &[String]) -> String { if !services.is_empty() { format!(" {}", services.join(" ")) } else { String::new() } } impl ExecuteCompose for StartStack { type Extras = (); async fn execute( periphery: PeripheryClient, stack: Stack, services: Vec, _: Self::Extras, ) -> anyhow::Result { let service_args = service_args(&services); periphery .request(ComposeExecution { project: stack.project_name(false), command: format!("start{service_args}"), }) .await } } impl ExecuteCompose for RestartStack { type Extras = (); async fn execute( periphery: PeripheryClient, stack: Stack, services: Vec, _: Self::Extras, ) -> anyhow::Result { let service_args = service_args(&services); periphery .request(ComposeExecution { project: stack.project_name(false), command: format!("restart{service_args}"), }) .await } } impl ExecuteCompose for PauseStack { type Extras = (); async fn execute( periphery: PeripheryClient, stack: Stack, services: Vec, _: Self::Extras, ) -> anyhow::Result { let service_args = service_args(&services); periphery .request(ComposeExecution { project: stack.project_name(false), command: format!("pause{service_args}"), }) .await } } impl ExecuteCompose for UnpauseStack { type Extras = (); async fn execute( periphery: PeripheryClient, stack: Stack, services: Vec, _: Self::Extras, ) -> anyhow::Result { let service_args = service_args(&services); periphery .request(ComposeExecution { project: stack.project_name(false), command: format!("unpause{service_args}"), }) .await } } impl ExecuteCompose for StopStack { type Extras = Option; async fn execute( periphery: PeripheryClient, stack: Stack, services: Vec, timeout: Self::Extras, ) -> anyhow::Result { let service_args = service_args(&services); let maybe_timeout = maybe_timeout(timeout); periphery .request(ComposeExecution { project: stack.project_name(false), command: format!("stop{maybe_timeout}{service_args}"), }) .await } } impl ExecuteCompose for DestroyStack { type Extras = (Option, bool); async fn execute( periphery: PeripheryClient, stack: Stack, services: Vec, (timeout, remove_orphans): Self::Extras, ) -> anyhow::Result { let service_args = service_args(&services); let maybe_timeout = maybe_timeout(timeout); let maybe_remove_orphans = if remove_orphans { " --remove-orphans" } else { "" }; periphery .request(ComposeExecution { project: stack.project_name(false), command: format!( "down{maybe_timeout}{maybe_remove_orphans}{service_args}" ), }) .await } } pub fn maybe_timeout(timeout: Option) -> String { if let Some(timeout) = timeout { format!(" --timeout {timeout}") } else { String::new() } } ================================================ FILE: bin/core/src/stack/mod.rs ================================================ use anyhow::{Context, anyhow}; use komodo_client::entities::{ permission::PermissionLevelAndSpecifics, server::{Server, ServerState}, stack::Stack, user::User, }; use regex::Regex; use crate::{ helpers::query::get_server_with_state, permission::get_check_permissions, }; pub mod execute; pub mod remote; pub mod services; pub async fn get_stack_and_server( stack: &str, user: &User, permissions: PermissionLevelAndSpecifics, block_if_server_unreachable: bool, ) -> anyhow::Result<(Stack, Server)> { let stack = get_check_permissions::(stack, user, permissions).await?; if stack.config.server_id.is_empty() { return Err(anyhow!("Stack has no server configured")); } let (server, state) = get_server_with_state(&stack.config.server_id).await?; if block_if_server_unreachable && state != ServerState::Ok { return Err(anyhow!( "Cannot send command when server is unreachable or disabled" )); } Ok((stack, server)) } pub fn compose_container_match_regex( container_name: &str, ) -> anyhow::Result { let regex = format!("^{container_name}-?[0-9]*$"); Regex::new(®ex).with_context(|| { format!("failed to construct valid regex from {regex}") }) } ================================================ FILE: bin/core/src/stack/remote.rs ================================================ use std::{fs, path::PathBuf}; use anyhow::Context; use formatting::format_serror; use komodo_client::entities::{ FileContents, RepoExecutionArgs, repo::Repo, stack::{Stack, StackRemoteFileContents}, update::Log, }; use crate::{config::core_config, helpers::git_token}; pub struct RemoteComposeContents { pub successful: Vec, pub errored: Vec, pub hash: Option, pub message: Option, pub _logs: Vec, } /// Returns Result<(read paths, error paths, logs, short hash, commit message)> pub async fn get_repo_compose_contents( stack: &Stack, repo: Option<&Repo>, // Collect any files which are missing in the repo. mut missing_files: Option<&mut Vec>, ) -> anyhow::Result { let clone_args: RepoExecutionArgs = repo.map(Into::into).unwrap_or(stack.into()); let (repo_path, _logs, hash, message) = ensure_remote_repo(clone_args) .await .context("Failed to clone stack repo")?; let run_directory = repo_path.join(&stack.config.run_directory); // This will remove any intermediate '/./' which can be a problem for some OS. let run_directory = run_directory.components().collect::(); let mut successful = Vec::new(); let mut errored = Vec::new(); for file in stack.all_file_dependencies() { let file_path = run_directory.join(&file.path); if !file_path.exists() && let Some(missing_files) = &mut missing_files { missing_files.push(file.path.clone()); } // If file does not exist, will show up in err case so the log is handled match fs::read_to_string(&file_path).with_context(|| { format!("Failed to read file contents from {file_path:?}") }) { Ok(contents) => successful.push(StackRemoteFileContents { path: file.path, contents, services: file.services, requires: file.requires, }), Err(e) => errored.push(FileContents { path: file.path, contents: format_serror(&e.into()), }), } } Ok(RemoteComposeContents { successful, errored, hash, message, _logs, }) } /// Returns (destination, logs, hash, message) pub async fn ensure_remote_repo( mut clone_args: RepoExecutionArgs, ) -> anyhow::Result<(PathBuf, Vec, Option, Option)> { let config = core_config(); let access_token = if let Some(username) = &clone_args.account { git_token(&clone_args.provider, username, |https| { clone_args.https = https }) .await .with_context( || format!("Failed to get git token in call to db. Stopping run. | {} | {username}", clone_args.provider), )? } else { None }; let repo_path = clone_args.unique_path(&core_config().repo_directory)?; clone_args.destination = Some(repo_path.display().to_string()); git::pull_or_clone(clone_args, &config.repo_directory, access_token) .await .context("Failed to clone stack repo") .map(|(res, _)| { (repo_path, res.logs, res.commit_hash, res.commit_message) }) } ================================================ FILE: bin/core/src/stack/services.rs ================================================ use anyhow::Context; use komodo_client::entities::stack::{ ComposeFile, ComposeService, ComposeServiceDeploy, Stack, StackServiceNames, }; pub fn extract_services_from_stack( stack: &Stack, ) -> Vec { if let Some(mut services) = stack.info.deployed_services.clone() { for service in services.iter_mut().filter(|s| s.image.is_empty()) { service.image = stack .info .latest_services .iter() .find(|s| s.service_name == service.service_name) .map(|s| s.image.clone()) .unwrap_or_default(); } services } else { stack.info.latest_services.clone() } } pub fn extract_services_into_res( project_name: &str, compose_contents: &str, res: &mut Vec, ) -> anyhow::Result<()> { let compose = serde_yaml_ng::from_str::(compose_contents) .context( "failed to parse service names from compose contents", )?; let mut services = Vec::with_capacity(compose.services.capacity()); for ( service_name, ComposeService { container_name, deploy, image, }, ) in compose.services { let image = image.unwrap_or_default(); match deploy { Some(ComposeServiceDeploy { replicas: Some(replicas), }) if replicas > 1 => { for i in 1..1 + replicas { services.push(StackServiceNames { container_name: format!( "{project_name}-{service_name}-{i}" ), service_name: format!("{service_name}-{i}"), image: image.clone(), }); } } _ => { services.push(StackServiceNames { container_name: container_name.unwrap_or_else(|| { format!("{project_name}-{service_name}") }), service_name, image, }); } } } res.extend(services); Ok(()) } ================================================ FILE: bin/core/src/startup.rs ================================================ use std::str::FromStr; use colored::Colorize; use database::mungos::{ find::find_collect, mongodb::bson::{Document, doc, oid::ObjectId, to_document}, }; use futures::future::join_all; use komodo_client::{ api::{ auth::SignUpLocalUser, execute::{ BackupCoreDatabase, Execution, GlobalAutoUpdate, RunAction, }, write::{ CreateBuilder, CreateProcedure, CreateServer, CreateTag, UpdateResourceMeta, }, }, entities::{ ResourceTarget, builder::{PartialBuilderConfig, PartialServerBuilderConfig}, komodo_timestamp, procedure::{EnabledExecution, ProcedureConfig, ProcedureStage}, server::{PartialServerConfig, Server}, sync::ResourceSync, tag::TagColor, update::Log, user::{action_user, system_user}, }, }; use resolver_api::Resolve; use crate::{ api::{ auth::AuthArgs, execute::{ExecuteArgs, ExecuteRequest}, write::WriteArgs, }, config::core_config, helpers::update::init_execution_update, network, resource, state::db_client, }; /// Runs the Actions with `run_at_startup: true` pub async fn run_startup_actions() { let startup_actions = match find_collect( &db_client().actions, doc! { "config.run_at_startup": true }, None, ) .await { Ok(actions) => actions, Err(e) => { error!("Failed to fetch actions for startup | {e:#?}"); return; } }; for action in startup_actions { let name = action.name; let id = action.id; let update = match init_execution_update( &ExecuteRequest::RunAction(RunAction { action: name.clone(), args: Default::default(), }), action_user(), ) .await { Ok(update) => update, Err(e) => { error!( "Failed to initialize update for action {name} ({id}) | {e:#?}" ); continue; } }; if let Err(e) = (RunAction { action: name.clone(), args: Default::default(), }) .resolve(&ExecuteArgs { user: action_user().to_owned(), update, }) .await { error!( "Failed to execute startup action {name} ({id}) | {e:#?}" ); } } } /// This function should be run on startup, /// after the db client has been initialized pub async fn on_startup() { // Configure manual network interface if specified network::configure_internet_gateway().await; tokio::join!( in_progress_update_cleanup(), open_alert_cleanup(), clean_up_server_templates(), ensure_first_server_and_builder(), ensure_init_user_and_resources(), ); } async fn in_progress_update_cleanup() { let log = Log::error( "Komodo shutdown", String::from( "Komodo shutdown during execution. If this is a build, the builder may not have been terminated.", ), ); // This static log won't fail to serialize, unwrap ok. let log = to_document(&log).unwrap(); if let Err(e) = db_client() .updates .update_many( doc! { "status": "InProgress" }, doc! { "$set": { "status": "Complete", "success": false, }, "$push": { "logs": log } }, ) .await { error!("failed to cleanup in progress updates on startup | {e:#}") } } /// Run on startup, ensure open alerts pointing to invalid resources are closed. async fn open_alert_cleanup() { let db = db_client(); let Ok(alerts) = find_collect(&db.alerts, doc! { "resolved": false }, None) .await .inspect_err(|e| { error!( "failed to list all alerts for startup open alert cleanup | {e:?}" ) }) else { return; }; let futures = alerts.into_iter().map(|alert| async move { match alert.target { ResourceTarget::Server(id) => { resource::get::(&id) .await .is_err() .then(|| ObjectId::from_str(&alert.id).inspect_err(|e| warn!("failed to clean up alert - id is invalid ObjectId | {e:?}")).ok()).flatten() } ResourceTarget::ResourceSync(id) => { resource::get::(&id) .await .is_err() .then(|| ObjectId::from_str(&alert.id).inspect_err(|e| warn!("failed to clean up alert - id is invalid ObjectId | {e:?}")).ok()).flatten() } // No other resources should have open alerts. _ => ObjectId::from_str(&alert.id).inspect_err(|e| warn!("failed to clean up alert - id is invalid ObjectId | {e:?}")).ok(), } }); let to_update_ids = join_all(futures) .await .into_iter() .flatten() .collect::>(); if let Err(e) = db .alerts .update_many( doc! { "_id": { "$in": to_update_ids } }, doc! { "$set": { "resolved": true, "resolved_ts": komodo_timestamp() } }, ) .await { error!( "failed to clean up invalid open alerts on startup | {e:#}" ) } } /// Ensures a default server / builder exists with the defined address async fn ensure_first_server_and_builder() { let config = core_config(); let Some(address) = config.first_server.clone() else { return; }; let db = db_client(); let Ok(server) = db .servers .find_one(Document::new()) .await .inspect_err(|e| error!("Failed to initialize 'first_server'. Failed to query db. {e:?}")) else { return; }; let server = if let Some(server) = server { server } else { match (CreateServer { name: config.first_server_name.clone(), config: PartialServerConfig { address: Some(address), enabled: Some(true), ..Default::default() }, }) .resolve(&WriteArgs { user: system_user().to_owned(), }) .await { Ok(server) => server, Err(e) => { error!( "Failed to initialize 'first_server'. Failed to CreateServer. {:#}", e.error ); return; } } }; let Ok(None) = db.builders .find_one(Document::new()).await .inspect_err(|e| error!("Failed to initialize 'first_builder' | Failed to query db | {e:?}")) else { return; }; if let Err(e) = (CreateBuilder { name: config.first_server_name.clone(), config: PartialBuilderConfig::Server( PartialServerBuilderConfig { server_id: Some(server.id), }, ), }) .resolve(&WriteArgs { user: system_user().to_owned(), }) .await { error!( "Failed to initialize 'first_builder' | Failed to CreateBuilder | {:#}", e.error ); } } async fn ensure_init_user_and_resources() { let db = db_client(); // Assumes if there are any existing users, procedures, or tags, // the default procedures do not need to be set up. let Ok((None, None, None)) = tokio::try_join!( db.users.find_one(Document::new()), db.procedures.find_one(Document::new()), db.tags.find_one(Document::new()), ).inspect_err(|e| error!("Failed to initialize default procedures | Failed to query db | {e:?}")) else { return }; let config = core_config(); // Init admin user if set in config. if let Some(username) = &config.init_admin_username { info!("Creating init admin user..."); SignUpLocalUser { username: username.clone(), password: config.init_admin_password.clone(), } .resolve(&AuthArgs::default()) .await .expect("Failed to initialize default admin user."); db.users .find_one(doc! { "username": username }) .await .expect("Failed to query database for initial user") .expect("Failed to find initial user after creation"); }; if config.disable_init_resources { info!("System resources init {}", "DISABLED".red()); return; } info!("Creating init system resources..."); let write_args = WriteArgs { user: system_user().to_owned(), }; // Create default 'system' tag let default_tags = match (CreateTag { name: String::from("system"), color: Some(TagColor::Red), }) .resolve(&write_args) .await { Ok(tag) => vec![tag.id], Err(e) => { warn!("Failed to create default tag | {:#}", e.error); Vec::new() } }; // Backup Core Database async { let Ok(config) = ProcedureConfig::builder() .stages(vec![ProcedureStage { name: String::from("Stage 1"), enabled: true, executions: vec![ EnabledExecution { execution: Execution::BackupCoreDatabase(BackupCoreDatabase {}), enabled: true } ] }]) .schedule(String::from("Every day at 01:00")) .build() .inspect_err(|e| error!("Failed to initialize backup core database procedure | Failed to build Procedure | {e:?}")) else { return; }; let procedure = match (CreateProcedure { name: String::from("Backup Core Database"), config: config.into() }).resolve(&write_args).await { Ok(procedure) => procedure, Err(e) => { error!( "Failed to initialize default database backup Procedure | Failed to create Procedure | {:#}", e.error ); return; } }; if let Err(e) = (UpdateResourceMeta { target: ResourceTarget::Procedure(procedure.id), tags: Some(default_tags.clone()), description: Some(String::from( "Triggers the Core database backup at the scheduled time.", )), template: None, }).resolve(&write_args).await { warn!("Failed to update default database backup Procedure tags / description | {:#}", e.error); } }.await; // GlobalAutoUpdate async { let Ok(config) = ProcedureConfig::builder() .stages(vec![ProcedureStage { name: String::from("Stage 1"), enabled: true, executions: vec![ EnabledExecution { execution: Execution::GlobalAutoUpdate(GlobalAutoUpdate {}), enabled: true } ] }]) .schedule(String::from("Every day at 03:00")) .build() .inspect_err(|e| error!("Failed to initialize global auto update procedure | Failed to build Procedure | {e:?}")) else { return; }; let procedure = match (CreateProcedure { name: String::from("Global Auto Update"), config: config.into(), }) .resolve(&write_args) .await { Ok(procedure) => procedure, Err(e) => { error!( "Failed to initialize global auto update Procedure | Failed to create Procedure | {:#}", e.error ); return; } }; if let Err(e) = (UpdateResourceMeta { target: ResourceTarget::Procedure(procedure.id), tags: Some(default_tags.clone()), description: Some(String::from( "Pulls and auto updates Stacks and Deployments using 'poll_for_updates' or 'auto_update'.", )), template: None, }) .resolve(&write_args) .await { warn!( "Failed to update global auto update Procedure tags / description | {:#}", e.error ); } }.await; } /// v1.17.5 removes the ServerTemplate resource. /// References to this resource type need to be cleaned up /// to avoid type errors reading from the database. async fn clean_up_server_templates() { let db = db_client(); tokio::join!( async { db.permissions .delete_many(doc! { "resource_target.type": "ServerTemplate", }) .await .expect( "Failed to clean up server template permissions on db", ); }, async { db.updates .delete_many(doc! { "target.type": "ServerTemplate" }) .await .expect("Failed to clean up server template updates on db"); }, async { db.users .update_many( Document::new(), doc! { "$unset": { "recents.ServerTemplate": 1, "all.ServerTemplate": 1 } } ) .await .expect("Failed to clean up server template updates on db"); }, async { db.user_groups .update_many( Document::new(), doc! { "$unset": { "all.ServerTemplate": 1 } }, ) .await .expect("Failed to clean up server template updates on db"); }, ); } ================================================ FILE: bin/core/src/state.rs ================================================ use std::{ collections::HashMap, sync::{Arc, OnceLock}, }; use anyhow::Context; use arc_swap::ArcSwap; use database::Client; use komodo_client::entities::{ action::ActionState, build::BuildState, config::core::{CoreConfig, GithubWebhookAppConfig}, deployment::DeploymentState, procedure::ProcedureState, repo::RepoState, stack::StackState, }; use octorust::auth::{ Credentials, InstallationTokenGenerator, JWTCredentials, }; use crate::{ auth::jwt::JwtClient, config::core_config, helpers::{ action_state::ActionStates, all_resources::AllResourcesById, cache::Cache, }, monitor::{ CachedDeploymentStatus, CachedRepoStatus, CachedServerStatus, CachedStackStatus, History, }, }; static DB_CLIENT: OnceLock = OnceLock::new(); pub fn db_client() -> &'static Client { DB_CLIENT .get() .expect("db_client accessed before initialized") } /// Must be called in app startup sequence. pub async fn init_db_client() { let client = Client::new(&core_config().database) .await .context("failed to initialize database client") .unwrap(); DB_CLIENT.set(client).expect("db_clint"); } pub fn jwt_client() -> &'static JwtClient { static JWT_CLIENT: OnceLock = OnceLock::new(); JWT_CLIENT.get_or_init(|| match JwtClient::new(core_config()) { Ok(client) => client, Err(e) => { error!("failed to initialialize JwtClient | {e:#}"); panic!("Exiting"); } }) } pub fn github_client() -> Option<&'static HashMap> { static GITHUB_CLIENT: OnceLock< Option>, > = OnceLock::new(); GITHUB_CLIENT .get_or_init(|| { let CoreConfig { github_webhook_app: GithubWebhookAppConfig { app_id, installations, pk_path, .. }, .. } = core_config(); if *app_id == 0 || installations.is_empty() { return None; } let private_key = match std::fs::read(pk_path).with_context(|| format!("github webhook app | failed to load private key at {pk_path}")) { Ok(key) => key, Err(e) => { error!("{e:#}"); return None; } }; let private_key = match nom_pem::decode_block(&private_key) { Ok(key) => key, Err(e) => { error!("github webhook app | failed to decode private key at {pk_path} | {e:?}"); return None; } }; let jwt = match JWTCredentials::new(*app_id, private_key.data).context("failed to initialize github JWTCredentials") { Ok(jwt) => jwt, Err(e) => { error!("github webhook app | failed to make github JWTCredentials | pk path: {pk_path} | {e:#}"); return None } }; let mut clients = HashMap::with_capacity(installations.capacity()); for installation in installations { let token_generator = InstallationTokenGenerator::new( installation.id, jwt.clone(), ); let client = match octorust::Client::new( "github-app", Credentials::InstallationToken(token_generator), ).with_context(|| format!("failed to initialize github webhook client for installation {}", installation.id)) { Ok(client) => client, Err(e) => { error!("{e:#}"); continue; } }; clients.insert(installation.namespace.to_string(), client); } Some(clients) }) .as_ref() } pub fn action_states() -> &'static ActionStates { static ACTION_STATES: OnceLock = OnceLock::new(); ACTION_STATES.get_or_init(ActionStates::default) } /// Cache of ids to status pub type DeploymentStatusCache = Cache< String, Arc>, >; /// Cache of ids to status pub fn deployment_status_cache() -> &'static DeploymentStatusCache { static DEPLOYMENT_STATUS_CACHE: OnceLock = OnceLock::new(); DEPLOYMENT_STATUS_CACHE.get_or_init(Default::default) } pub type StackStatusCache = Cache>>; pub fn stack_status_cache() -> &'static StackStatusCache { static STACK_STATUS_CACHE: OnceLock = OnceLock::new(); STACK_STATUS_CACHE.get_or_init(Default::default) } pub type ServerStatusCache = Cache>; pub fn server_status_cache() -> &'static ServerStatusCache { static SERVER_STATUS_CACHE: OnceLock = OnceLock::new(); SERVER_STATUS_CACHE.get_or_init(Default::default) } pub type RepoStatusCache = Cache>; pub fn repo_status_cache() -> &'static RepoStatusCache { static REPO_STATUS_CACHE: OnceLock = OnceLock::new(); REPO_STATUS_CACHE.get_or_init(Default::default) } pub type BuildStateCache = Cache; pub fn build_state_cache() -> &'static BuildStateCache { static BUILD_STATE_CACHE: OnceLock = OnceLock::new(); BUILD_STATE_CACHE.get_or_init(Default::default) } pub type RepoStateCache = Cache; pub fn repo_state_cache() -> &'static RepoStateCache { static REPO_STATE_CACHE: OnceLock = OnceLock::new(); REPO_STATE_CACHE.get_or_init(Default::default) } pub type ProcedureStateCache = Cache; pub fn procedure_state_cache() -> &'static ProcedureStateCache { static PROCEDURE_STATE_CACHE: OnceLock = OnceLock::new(); PROCEDURE_STATE_CACHE.get_or_init(Default::default) } pub type ActionStateCache = Cache; pub fn action_state_cache() -> &'static ActionStateCache { static ACTION_STATE_CACHE: OnceLock = OnceLock::new(); ACTION_STATE_CACHE.get_or_init(Default::default) } pub fn all_resources_cache() -> &'static ArcSwap { static ALL_RESOURCES: OnceLock> = OnceLock::new(); ALL_RESOURCES.get_or_init(Default::default) } ================================================ FILE: bin/core/src/sync/deploy.rs ================================================ use std::{collections::HashMap, time::Duration}; use anyhow::{Context, anyhow}; use formatting::{Color, bold, colored, format_serror, muted}; use futures::future::join_all; use komodo_client::{ api::{ execute::{Deploy, DeployStack}, read::ListBuildVersions, }, entities::{ ResourceTarget, deployment::{ Deployment, DeploymentConfig, DeploymentImage, DeploymentState, PartialDeploymentConfig, }, stack::{ PartialStackConfig, Stack, StackConfig, StackRemoteFileContents, StackState, }, sync::SyncDeployUpdate, toml::ResourceToml, update::Log, user::sync_user, }, }; use resolver_api::Resolve; use crate::{ api::{ execute::{ExecuteArgs, ExecuteRequest}, read::ReadArgs, }, helpers::update::init_execution_update, state::{deployment_status_cache, stack_status_cache}, }; use super::ResourceSyncTrait; /// All entries in here are due to be deployed, /// after the given dependencies, /// with the given reason. pub type ToDeployCache = Vec<(ResourceTarget, String, Vec)>; #[derive(Clone, Copy)] pub struct SyncDeployParams<'a> { pub deployments: &'a [ResourceToml], // Names to deployments pub deployment_map: &'a HashMap, pub stacks: &'a [ResourceToml], // Names to stacks pub stack_map: &'a HashMap, } pub async fn deploy_from_cache( mut to_deploy: ToDeployCache, logs: &mut Vec, ) { if to_deploy.is_empty() { return; } let mut log = format!( "{}: running executions to sync deployment / stack state", muted("INFO") ); let mut round = 1; let user = sync_user(); while !to_deploy.is_empty() { // Collect all waiting deployments without waiting dependencies. let good_to_deploy = to_deploy .iter() .filter(|(_, _, after)| { to_deploy .iter() .all(|(target, _, _)| !after.contains(target)) }) // The target / reason need the be cloned out to to_deploy is not borrowed from. // to_deploy will be mutably accessed later. .map(|(target, reason, _)| (target.clone(), reason.clone())) .collect::>(); // Deploy the ones ready for deployment let res = join_all(good_to_deploy.iter().map( |(target, reason)| async move { let res = async { match &target { ResourceTarget::Deployment(name) => { let req = ExecuteRequest::Deploy(Deploy { deployment: name.to_string(), stop_signal: None, stop_time: None, }); let update = init_execution_update(&req, user).await?; let ExecuteRequest::Deploy(req) = req else { unreachable!() }; req .resolve(&ExecuteArgs { user: user.to_owned(), update, }) .await } ResourceTarget::Stack(name) => { let req = ExecuteRequest::DeployStack(DeployStack { stack: name.to_string(), services: Vec::new(), stop_time: None, }); let update = init_execution_update(&req, user).await?; let ExecuteRequest::DeployStack(req) = req else { unreachable!() }; req .resolve(&ExecuteArgs { user: user.to_owned(), update, }) .await } _ => unreachable!(), } } .await; (target, reason, res) }, )) .await; let mut has_error = false; // Log results of deploy for (target, reason, res) in res { let (resource, name) = target.extract_variant_id(); if let Err(e) = res { has_error = true; log.push_str(&format!( "\n{}: failed to deploy {resource} '{}' in round {} | {:#}", colored("ERROR", Color::Red), bold(name), bold(round), e.error )); } else { log.push_str(&format!( "\n{}: deployed {resource} '{}' in round {} with reason: {reason}", muted("INFO"), bold(name), bold(round) )); } } // Early exit if any deploy has errors if has_error { log.push_str(&format!( "\n{}: exited in round {} {}", muted("INFO"), bold(round), colored("with errors", Color::Red) )); logs.push(Log::error("Sync Deploy", log)); return; } // Remove the deployed ones from 'to_deploy' to_deploy .retain(|(target, _, _)| !good_to_deploy.contains_key(target)); // If there must be another round, these are dependent on the first round. // Sleep for 1s to allow for first round to startup if !to_deploy.is_empty() { // Increment the round round += 1; tokio::time::sleep(Duration::from_secs(1)).await; } } log.push_str(&format!( "\n{}: finished after {} round{}", muted("INFO"), bold(round), if round > 1 { "s" } else { Default::default() } )); logs.push(Log::simple("Sync Deploy", log)); } pub async fn get_updates_for_view( params: SyncDeployParams<'_>, ) -> SyncDeployUpdate { let inner = async { let mut update = SyncDeployUpdate { to_deploy: 0, log: String::from("Deploy Updates\n-------------------\n"), }; let mut lines = Vec::::new(); for (target, reason, after) in build_deploy_cache(params).await? { update.to_deploy += 1; let mut line = format!( "{}: {}. reason: {reason}", colored("Deploy", Color::Green), bold(format!("{target:?}")), ); if !after.is_empty() { line.push_str(&format!( "\n{}: {}", colored("After", Color::Blue), after .iter() .map(|target| format!("{target:?}")) .collect::>() .join(", ") )) } lines.push(line); } update.log.push_str(&lines.join("\n-------------------\n")); anyhow::Ok(update) }; match inner.await { Ok(res) => res, Err(e) => SyncDeployUpdate { to_deploy: 0, log: format_serror( &e.context("failed to get deploy updates for view").into(), ), }, } } /// Entries are keyed by ResourceTargets wrapping "name" instead of "id". /// If entry is None, it is confirmed no-deploy. /// If it is Some, it is confirmed deploy with provided reason and dependencies. /// /// Used to build up resources to deploy earlier in the sync. type ToDeployCacheInner = HashMap)>>; /// Maps build ids to latest versions as string. type BuildVersionCache = HashMap; pub async fn build_deploy_cache( params: SyncDeployParams<'_>, ) -> anyhow::Result { let mut cache = ToDeployCacheInner::new(); let mut build_version_cache = BuildVersionCache::new(); // Just ensure they are all in the cache by looping through them all for deployment in params.deployments { build_cache_for_deployment( deployment, params, &mut cache, &mut build_version_cache, ) .await?; } for stack in params.stacks { build_cache_for_stack( stack, params, &mut cache, &mut build_version_cache, ) .await?; } let cache = cache .into_iter() .filter_map(|(target, entry)| { let (reason, after) = entry?; Some((target, (reason, after))) }) .collect::>(); // Have to clone here to use it after 'into_iter' below. // All entries in cache at this point are deploying. let clone = cache.clone(); Ok( cache .into_iter() .map(|(target, (reason, mut after))| { // Only keep targets which are deploying. after.retain(|target| clone.contains_key(target)); (target, reason, after) }) .collect(), ) } type BuildRes<'a> = std::pin::Pin< Box< dyn std::future::Future> + Send + 'a, >, >; fn build_cache_for_deployment<'a>( deployment: &'a ResourceToml, SyncDeployParams { deployments, deployment_map, stacks, stack_map, }: SyncDeployParams<'a>, cache: &'a mut ToDeployCacheInner, build_version_cache: &'a mut BuildVersionCache, ) -> BuildRes<'a> { Box::pin(async move { let target = ResourceTarget::Deployment(deployment.name.clone()); // First check existing, and continue if already handled. if cache.contains_key(&target) { return Ok(()); } // Check if deployment doesn't have "deploy" enabled. if !deployment.deploy { cache.insert(target, None); return Ok(()); } let after = get_after_as_resource_targets( &deployment.name, &deployment.after, deployment_map, deployments, stack_map, stacks, )?; let Some(original) = deployment_map.get(&deployment.name) else { // This block is the None case, deployment is not created, should definitely deploy cache.insert( target, Some((String::from("deploy on creation"), after)), ); return Ok(()); }; let status = &deployment_status_cache() .get_or_insert_default(&original.id) .await .curr; let state = status.state; match state { DeploymentState::Unknown => { // Can't do anything with unknown state cache.insert(target, None); return Ok(()); } DeploymentState::Running => { // Here can diff the changes, to see if they merit a redeploy. // First merge toml resource config (partial) onto default resource config. // Makes sure things that aren't defined in toml (come through as None) actually get removed. let config: DeploymentConfig = deployment.config.clone().into(); let mut config: PartialDeploymentConfig = config.into(); Deployment::validate_partial_config(&mut config); let mut diff = Deployment::get_diff(original.config.clone(), config)?; Deployment::validate_diff(&mut diff); // Needs to only check config fields that affect docker run let changed = diff.server_id.is_some() || diff.image.is_some() || diff.image_registry_account.is_some() || diff.skip_secret_interp.is_some() || diff.network.is_some() || diff.restart.is_some() || diff.command.is_some() || diff.extra_args.is_some() || diff.ports.is_some() || diff.volumes.is_some() || diff.environment.is_some() || diff.labels.is_some(); if changed { cache.insert( target, Some(( String::from("deployment config has changed"), after, )), ); return Ok(()); } } // All other cases will require Deploy to enter Running state. _ => { cache.insert( target, Some(( format!( "deployment has {} state", colored(state, Color::Red) ), after, )), ); return Ok(()); } }; // We know the config hasn't changed at this point, but still need // to check if attached build has updated. Can check original for this (know it hasn't changed) if let DeploymentImage::Build { build_id, version } = &original.config.image { // check if version is none, ie use latest build if !version.is_none() { let deployed_version = status .container .as_ref() .and_then(|c| c.image.as_ref()?.split(':').next_back()) .unwrap_or("0.0.0"); match build_version_cache.get(build_id) { Some(version) if deployed_version != version => { cache.insert( target, Some(( format!("build has new version: {version}"), after, )), ); return Ok(()); } // Build version is the same, still need to check 'after' Some(_) => {} None => { let Some(version) = (ListBuildVersions { build: build_id.to_string(), limit: Some(1), ..Default::default() }) .resolve(&ReadArgs { user: sync_user().to_owned(), }) .await .map_err(|e| e.error) .context("failed to get build versions")? .pop() else { // The build has never been built. // Skip deploy regardless of 'after' (it can't be deployed) // Not sure how this would be reached on Running deployment... cache.insert(target, None); return Ok(()); }; let version = version.version.to_string(); build_version_cache .insert(build_id.to_string(), version.clone()); if deployed_version != version { // Same as 'Some' case out of the cache cache.insert( target, Some(( format!("build has new version: {version}"), after, )), ); return Ok(()); } } } } }; // Check 'after' to see if they deploy. insert_target_using_after_list( target, after, SyncDeployParams { deployments, deployment_map, stacks, stack_map, }, cache, build_version_cache, ) .await }) } fn build_cache_for_stack<'a>( stack: &'a ResourceToml, SyncDeployParams { deployments, deployment_map, stacks, stack_map, }: SyncDeployParams<'a>, cache: &'a mut ToDeployCacheInner, build_version_cache: &'a mut BuildVersionCache, ) -> BuildRes<'a> { Box::pin(async move { let target = ResourceTarget::Stack(stack.name.clone()); // First check existing, and continue if already handled. if cache.contains_key(&target) { return Ok(()); } // Check if stack doesn't have "deploy" enabled. if !stack.deploy { cache.insert(target, None); return Ok(()); } let after = get_after_as_resource_targets( &stack.name, &stack.after, deployment_map, deployments, stack_map, stacks, )?; let Some(original) = stack_map.get(&stack.name) else { // This block is the None case, deployment is not created, should definitely deploy cache.insert( target, Some((String::from("deploy on creation"), after)), ); return Ok(()); }; let status = &stack_status_cache() .get_or_insert_default(&original.id) .await .curr; let state = status.state; match state { StackState::Unknown => { // Can't do anything with unknown state cache.insert(target, None); return Ok(()); } StackState::Running => { // Here can diff the changes, to see if they merit a redeploy. // See if any remote contents don't match deployed contents #[allow(clippy::single_match)] match ( &original.info.deployed_contents, &original.info.remote_contents, ) { (Some(deployed_contents), Some(remote_contents)) => { for StackRemoteFileContents { path, contents, services: _services, requires: _requires, } in remote_contents { if let Some(deployed) = deployed_contents.iter().find(|c| &c.path == path) { if &deployed.contents != contents { cache.insert( target, Some(( format!( "File contents for {path} have changed" ), after, )), ); return Ok(()); } } else { cache.insert( target, Some(( format!("New file contents at {path}"), after, )), ); return Ok(()); } } } // Maybe should handle other cases _ => {} } // Merge toml resource config (partial) onto default resource config. // Makes sure things that aren't defined in toml (come through as None) actually get removed. let config: StackConfig = stack.config.clone().into(); let mut config: PartialStackConfig = config.into(); Stack::validate_partial_config(&mut config); let mut diff = Stack::get_diff(original.config.clone(), config)?; Stack::validate_diff(&mut diff); // Needs to only check config fields that affect docker compose command let changed = diff.server_id.is_some() || diff.project_name.is_some() || diff.run_directory.is_some() || diff.file_paths.is_some() || diff.file_contents.is_some() || diff.skip_secret_interp.is_some() || diff.extra_args.is_some() || diff.environment.is_some() || diff.env_file_path.is_some() || diff.repo.is_some() || diff.branch.is_some() || diff.commit.is_some(); if changed { cache.insert( target, Some((String::from("stack config has changed"), after)), ); return Ok(()); } } // All other cases will require Deploy to enter Running state. _ => { cache.insert( target, Some(( format!("stack has {} state", colored(state, Color::Red)), after, )), ); return Ok(()); } }; // Check 'after' to see if they deploy. insert_target_using_after_list( target, after, SyncDeployParams { deployments, deployment_map, stacks, stack_map, }, cache, build_version_cache, ) .await }) } async fn insert_target_using_after_list<'a>( target: ResourceTarget, after: Vec, SyncDeployParams { deployments, deployment_map, stacks, stack_map, }: SyncDeployParams<'a>, cache: &'a mut ToDeployCacheInner, build_version_cache: &'a mut BuildVersionCache, ) -> anyhow::Result<()> { for parent in &after { match cache.get(parent) { Some(Some(_)) => { // a parent will deploy let (variant, name) = parent.extract_variant_id(); cache.insert( target.to_owned(), Some(( format!( "{variant} parent dependency '{}' is deploying", bold(name) ), after, )), ); return Ok(()); } // The parent will not deploy, do nothing here. Some(None) => {} None => { match parent { ResourceTarget::Deployment(name) => { let Some(parent_deployment) = deployments.iter().find(|d| &d.name == name) else { // The parent is not in the sync, so won't be deploying // Note that cross-sync deploy dependencies are not currently supported. continue; }; // Recurse to add the parent to cache, then check again. build_cache_for_deployment( parent_deployment, SyncDeployParams { deployments, deployment_map, stacks, stack_map, }, cache, build_version_cache, ) .await?; match cache.get(parent) { Some(Some(_)) => { // Same as the 'Some' case above let (variant, name) = parent.extract_variant_id(); cache.insert( target.to_owned(), Some(( format!( "{variant} parent dependency '{}' is deploying", bold(name) ), after, )), ); return Ok(()); } // The parent will not deploy, do nothing here. Some(None) => {} None => { return Err(anyhow!( "Did not find parent in cache after build recursion. This should not happen." )); } } } ResourceTarget::Stack(name) => { let Some(parent_stack) = stacks.iter().find(|d| &d.name == name) else { // The parent is not in the sync, so won't be deploying // Note that cross-sync deploy dependencies are not currently supported. continue; }; // Recurse to add the parent to cache, then check again. build_cache_for_stack( parent_stack, SyncDeployParams { deployments, deployment_map, stacks, stack_map, }, cache, build_version_cache, ) .await?; match cache.get(parent) { Some(Some(_)) => { // Same as the 'Some' case above let (variant, name) = parent.extract_variant_id(); cache.insert( target.to_owned(), Some(( format!( "{variant} parent dependency '{}' is deploying", bold(name) ), after, )), ); return Ok(()); } // The parent will not deploy, do nothing here. Some(None) => {} None => { return Err(anyhow!( "Did not find parent in cache after build recursion. This should not happen." )); } } } _ => unreachable!(), } } } } // If it has reached here, its not deploying cache.insert(target, None); Ok(()) } fn get_after_as_resource_targets( resource_name: &str, after: &[String], // Names to deployments deployment_map: &HashMap, deployments: &[ResourceToml], // Names to stacks stack_map: &HashMap, stacks: &[ResourceToml], ) -> anyhow::Result> { after .iter() .map(|name| match deployment_map.get(name) { Some(_) => Ok(ResourceTarget::Deployment(name.clone())), None => { if deployments .iter() .any(|deployment| deployment.name.as_str() == resource_name) { Ok(ResourceTarget::Deployment(name.clone())) } else { match stack_map.get(name) { Some(_) => Ok(ResourceTarget::Stack(name.clone())), None => { if stacks .iter() .any(|stack| stack.name.as_str() == resource_name) { Ok(ResourceTarget::Stack(name.clone())) } else { Err(anyhow!("failed to match deploy dependency in 'after' list | resource: {resource_name} | dependency: {name}")) } } } } } }) .collect() } ================================================ FILE: bin/core/src/sync/execute.rs ================================================ use std::collections::HashMap; use anyhow::Context; use database::mungos::find::find_collect; use formatting::{Color, bold, colored, muted}; use komodo_client::entities::{ ResourceTargetVariant, tag::Tag, toml::ResourceToml, update::Log, user::sync_user, }; use partial_derive2::MaybeNone; use crate::{api::write::WriteArgs, resource::ResourceMetaUpdate}; use super::{ResourceSyncTrait, SyncDeltas, ToUpdateItem}; /// Gets all the resources to update. For use in sync execution. pub async fn get_updates_for_execution< Resource: ResourceSyncTrait, >( resources: Vec>, delete: bool, match_resource_type: Option, match_resources: Option<&[String]>, id_to_tags: &HashMap, match_tags: &[String], ) -> anyhow::Result> { let map = find_collect(Resource::coll(), None, None) .await .context("failed to get resources from db")? .into_iter() .filter(|r| { Resource::include_resource( &r.name, &r.config, match_resource_type, match_resources, &r.tags, id_to_tags, match_tags, ) }) .map(|r| (r.name.clone(), r)) .collect::>(); let resources = resources .into_iter() .filter(|r| { Resource::include_resource_partial( &r.name, &r.config, match_resource_type, match_resources, &r.tags, id_to_tags, match_tags, ) }) .collect::>(); let mut deltas = SyncDeltas::::default(); if delete { for resource in map.values() { if !resources.iter().any(|r| r.name == resource.name) { deltas.to_delete.push(resource.name.clone()); } } } for mut resource in resources { match map.get(&resource.name) { Some(original) => { // First merge toml resource config (partial) onto default resource config. // Makes sure things that aren't defined in toml (come through as None) actually get removed. let config: Resource::Config = resource.config.into(); resource.config = config.into(); Resource::validate_partial_config(&mut resource.config); let mut diff = Resource::get_diff( original.config.clone(), resource.config, )?; Resource::validate_diff(&mut diff); let original_tags = original .tags .iter() .filter_map(|id| id_to_tags.get(id).map(|t| t.name.clone())) .collect::>(); // Only proceed if there are any fields to update, // or a change to tags / description if diff.is_none() && resource.description == original.description && resource.template == original.template && resource.tags == original_tags { continue; } // Minimizes updates through diffing. resource.config = diff.into(); let update = ToUpdateItem { id: original.id.clone(), update_description: resource.description != original.description, update_template: resource.template != original.template, update_tags: resource.tags != original_tags, resource, }; deltas.to_update.push(update); } None => deltas.to_create.push(resource), } } Ok(deltas) } pub trait ExecuteResourceSync: ResourceSyncTrait { async fn execute_sync_updates( SyncDeltas { to_create, to_update, to_delete, }: SyncDeltas, ) -> Option { if to_create.is_empty() && to_update.is_empty() && to_delete.is_empty() { return None; } let mut has_error = false; let mut log = format!("running updates on {}s", Self::resource_type()); for resource in to_create { let name = resource.name.clone(); let id = match crate::resource::create::( &resource.name, resource.config, sync_user(), ) .await .map_err(|e| e.error) { Ok(resource) => resource.id, Err(e) => { has_error = true; log.push_str(&format!( "\n{}: failed to create {} '{}' | {e:#}", colored("ERROR", Color::Red), Self::resource_type(), bold(&name) )); continue; } }; run_update_meta::( id.clone(), &name, ResourceMetaUpdate { description: Some(resource.description), template: Some(resource.template), tags: Some(resource.tags), }, &mut log, &mut has_error, ) .await; log.push_str(&format!( "\n{}: {} {} '{}'", muted("INFO"), colored("created", Color::Green), Self::resource_type(), bold(&name) )); } for ToUpdateItem { id, resource, update_description, update_template, update_tags, } in to_update { let name = resource.name.clone(); let meta = ResourceMetaUpdate { description: update_description .then(|| resource.description.clone()), template: update_template.then_some(resource.template), tags: update_tags.then(|| resource.tags.clone()), }; if !meta.is_none() { run_update_meta::( id.clone(), &name, meta, &mut log, &mut has_error, ) .await; } if !resource.config.is_none() { if let Err(e) = crate::resource::update::( &id, resource.config, sync_user(), ) .await { has_error = true; log.push_str(&format!( "\n{}: failed to update config on {} '{}' | {e:#}", colored("ERROR", Color::Red), Self::resource_type(), bold(&name), )) } else { log.push_str(&format!( "\n{}: {} {} '{}' configuration", muted("INFO"), colored("updated", Color::Blue), Self::resource_type(), bold(&name) )); } } } for resource in to_delete { if let Err(e) = crate::resource::delete::( &resource, &WriteArgs { user: sync_user().to_owned(), }, ) .await { has_error = true; log.push_str(&format!( "\n{}: failed to delete {} '{}' | {e:#}", colored("ERROR", Color::Red), Self::resource_type(), bold(&resource), )) } else { log.push_str(&format!( "\n{}: {} {} '{}'", muted("INFO"), colored("deleted", Color::Red), Self::resource_type(), bold(&resource) )); } } let stage = format!("Update {}s", Self::resource_type()); Some(if has_error { Log::error(&stage, log) } else { Log::simple(&stage, log) }) } } pub async fn run_update_meta( id: String, name: &str, meta: ResourceMetaUpdate, log: &mut String, has_error: &mut bool, ) { if let Err(e) = crate::resource::update_meta::( &id, meta, &WriteArgs { user: sync_user().to_owned(), }, ) .await { *has_error = true; log.push_str(&format!( "\n{}: failed to update tags on {} '{}' | {:#}", colored("ERROR", Color::Red), Resource::resource_type(), bold(name), e )) } else { log.push_str(&format!( "\n{}: {} {} '{}' meta", muted("INFO"), colored("updated", Color::Blue), Resource::resource_type(), bold(name) )); } } ================================================ FILE: bin/core/src/sync/file.rs ================================================ use std::{ fs, path::{Path, PathBuf}, }; use anyhow::{Context, anyhow}; use formatting::{Color, bold, colored, format_serror, muted}; use komodo_client::entities::{ sync::SyncFileContents, toml::{ResourceToml, ResourcesToml}, update::Log, }; pub fn read_resources( root_path: &Path, resource_path: &[String], match_tags: &[String], logs: &mut Vec, files: &mut Vec, file_errors: &mut Vec, ) -> anyhow::Result { let mut resources = ResourcesToml::default(); for resource_path in resource_path { let resource_path = resource_path .parse::() .context("Invalid resource path")?; let full_path = root_path .join(&resource_path) .components() .collect::(); let mut log = format!( "{}: reading resources from {full_path:?}", muted("INFO") ); if full_path.is_file() { if let Err(e) = read_resource_file( root_path, None, &resource_path, match_tags, &mut resources, &mut log, files, ) .with_context(|| { format!("failed to read resources from {full_path:?}") }) { file_errors.push(SyncFileContents { resource_path: String::new(), path: resource_path.display().to_string(), contents: format_serror(&e.into()), }); logs.push(Log::error("Read remote resources", log)); } else { logs.push(Log::simple("Read remote resources", log)); }; } else if full_path.is_dir() { if let Err(e) = read_resources_directory( root_path, &resource_path, &PathBuf::new(), match_tags, &mut resources, &mut log, files, file_errors, ) .with_context(|| { format!("Failed to read resources from {full_path:?}") }) { file_errors.push(SyncFileContents { resource_path: String::new(), path: resource_path.display().to_string(), contents: format_serror(&e.into()), }); logs.push(Log::error("Read remote resources", log)); } else { logs.push(Log::simple("Read remote resources", log)); }; } else if !full_path.exists() { file_errors.push(SyncFileContents { resource_path: String::new(), path: resource_path.display().to_string(), contents: format_serror( &anyhow!("Initialize the file to proceed.") .context(format!("Path {full_path:?} does not exist.")) .into(), ), }); log.push_str(&format!( "{}: Resoure path {} does not exist.", colored("ERROR", Color::Red), bold(resource_path.display()) )); logs.push(Log::error("Read remote resources", log)); } else { log.push_str(&format!( "{}: Resoure path {} exists, but is neither a file nor a directory.", colored("WARN", Color::Red), bold(resource_path.display()) )); logs.push(Log::error("Read remote resources", log)); } } Ok(resources) } /// Use when incoming resource path is a file. fn read_resource_file( root_path: &Path, // relative to root path. resource_path: Option<&Path>, // relative to resource path if provided, or root path. file_path: &Path, match_tags: &[String], resources: &mut ResourcesToml, log: &mut String, files: &mut Vec, ) -> anyhow::Result<()> { let full_path = if let Some(resource_path) = resource_path { root_path.join(resource_path).join(file_path) } else { root_path.join(file_path) }; if !full_path .extension() .map(|ext| ext == "toml") .unwrap_or_default() { return Ok(()); } let contents = std::fs::read_to_string(&full_path) .context("failed to read file contents")?; files.push(SyncFileContents { resource_path: resource_path .map(|path| path.display().to_string()) .unwrap_or_default(), path: file_path.display().to_string(), contents: contents.clone(), }); let more = super::deserialize_resources_toml(&contents) .context("failed to parse resource file contents")?; log.push('\n'); let path_for_view = if let Some(resource_path) = resource_path.as_ref() { resource_path.join(file_path) } else { file_path.to_path_buf() }; log.push_str(&format!( "{}: {} from {}", muted("INFO"), colored("adding resources", Color::Green), colored(path_for_view.display(), Color::Blue) )); extend_resources(resources, more, match_tags); Ok(()) } /// Reads down into directories. #[allow(clippy::too_many_arguments)] fn read_resources_directory( root_path: &Path, // relative to root path. resource_path: &Path, // relative to resource path. start as empty path curr_path: &Path, match_tags: &[String], resources: &mut ResourcesToml, log: &mut String, files: &mut Vec, file_errors: &mut Vec, ) -> anyhow::Result<()> { let full_resource_path = root_path.join(resource_path); let full_path = full_resource_path.join(curr_path); let directory = fs::read_dir(&full_path).with_context(|| { format!("Failed to read directory contents at {full_path:?}") })?; for entry in directory.into_iter().flatten() { let path = entry.path(); let curr_path = path.strip_prefix(&full_resource_path).unwrap_or(&path); if path.is_file() { if let Err(e) = read_resource_file( root_path, Some(resource_path), curr_path, match_tags, resources, log, files, ) .with_context(|| { format!("failed to read resources from {full_path:?}") }) { file_errors.push(SyncFileContents { resource_path: String::new(), path: resource_path.display().to_string(), contents: format_serror(&e.into()), }); }; } else if path.is_dir() && let Err(e) = read_resources_directory( root_path, resource_path, curr_path, match_tags, resources, log, files, file_errors, ) .with_context(|| { format!("failed to read resources from {path:?}") }) { file_errors.push(SyncFileContents { resource_path: resource_path.display().to_string(), path: curr_path.display().to_string(), contents: format_serror(&e.into()), }); log.push('\n'); log.push_str(&format!( "{}: {} from {}", colored("ERROR", Color::Red), colored("adding resources", Color::Green), colored(path.display(), Color::Blue) )); } } Ok(()) } pub fn extend_resources( resources: &mut ResourcesToml, more: ResourcesToml, match_tags: &[String], ) { resources .servers .extend(filter_by_tag(more.servers, match_tags)); resources .stacks .extend(filter_by_tag(more.stacks, match_tags)); resources .deployments .extend(filter_by_tag(more.deployments, match_tags)); resources .builds .extend(filter_by_tag(more.builds, match_tags)); resources .repos .extend(filter_by_tag(more.repos, match_tags)); resources .procedures .extend(filter_by_tag(more.procedures, match_tags)); resources .actions .extend(filter_by_tag(more.actions, match_tags)); resources .alerters .extend(filter_by_tag(more.alerters, match_tags)); resources .builders .extend(filter_by_tag(more.builders, match_tags)); resources .resource_syncs .extend(filter_by_tag(more.resource_syncs, match_tags)); resources.user_groups.extend(more.user_groups); resources.variables.extend(more.variables); } fn filter_by_tag( resources: Vec>, match_tags: &[String], ) -> Vec> { resources .into_iter() .filter(|resource| { match_tags.iter().all(|tag| resource.tags.contains(tag)) }) .collect() } ================================================ FILE: bin/core/src/sync/mod.rs ================================================ use std::{collections::HashMap, str::FromStr}; use anyhow::anyhow; use database::mungos::mongodb::bson::oid::ObjectId; use komodo_client::entities::{ ResourceTargetVariant, tag::Tag, toml::{ResourceToml, ResourcesToml}, }; use toml::ToToml; use crate::resource::KomodoResource; pub mod deploy; pub mod execute; pub mod file; pub mod remote; pub mod resources; pub mod toml; pub mod user_groups; pub mod variables; pub mod view; #[derive(Default)] pub struct SyncDeltas { pub to_create: Vec>, pub to_update: Vec>, pub to_delete: Vec, } impl SyncDeltas { pub fn no_changes(&self) -> bool { self.to_create.is_empty() && self.to_update.is_empty() && self.to_delete.is_empty() } } pub struct ToUpdateItem { pub id: String, pub resource: ResourceToml, pub update_description: bool, pub update_template: bool, pub update_tags: bool, } pub trait ResourceSyncTrait: ToToml + Sized { /// To exclude resource syncs with "file_contents" (they aren't compatible) fn include_resource( name: &String, _config: &Self::Config, match_resource_type: Option, match_resources: Option<&[String]>, resource_tags: &[String], id_to_tags: &HashMap, match_tags: &[String], ) -> bool { include_resource_by_resource_type_and_name::( match_resource_type, match_resources, name, ) && include_resource_by_tags( resource_tags, id_to_tags, match_tags, ) } /// To exclude resource syncs with "file_contents" (they aren't compatible) fn include_resource_partial( name: &String, _config: &Self::PartialConfig, match_resource_type: Option, match_resources: Option<&[String]>, resource_tags: &[String], id_to_tags: &HashMap, match_tags: &[String], ) -> bool { include_resource_by_resource_type_and_name::( match_resource_type, match_resources, name, ) && include_resource_by_tags( resource_tags, id_to_tags, match_tags, ) } /// Apply any changes to incoming toml partial config /// before it is diffed against existing config fn validate_partial_config(_config: &mut Self::PartialConfig) {} /// Diffs the declared toml (partial) against the full existing config. /// Removes all fields from toml (partial) that haven't changed. fn get_diff( original: Self::Config, update: Self::PartialConfig, ) -> anyhow::Result; /// Apply any changes to computed config diff /// before logging fn validate_diff(_diff: &mut Self::ConfigDiff) {} } pub fn include_resource_by_tags( resource_tags: &[String], id_to_tags: &HashMap, match_tags: &[String], ) -> bool { let tag_names = resource_tags .iter() .filter_map(|resource_tag| { match ObjectId::from_str(resource_tag) { Ok(_) => id_to_tags.get(resource_tag).map(|tag| &tag.name), Err(_) => Some(resource_tag), } }) .collect::>(); match_tags.iter().all(|tag| tag_names.contains(&tag)) } pub fn include_resource_by_resource_type_and_name< T: KomodoResource, >( resource_type: Option, resources: Option<&[String]>, name: &String, ) -> bool { match (resource_type, resources) { (Some(resource_type), Some(resources)) => { if T::resource_type() != resource_type { return false; } resources.contains(name) } (Some(resource_type), None) => { if T::resource_type() != resource_type { return false; } true } (None, Some(resources)) => resources.contains(name), (None, None) => true, } } fn deserialize_resources_toml( toml_str: &str, ) -> anyhow::Result { ::toml::from_str::(&escape_between_triple_string( toml_str, )) // the error without this comes through with multiple lines (\n) and looks bad .map_err(|e| anyhow!("{e:#}")) } fn escape_between_triple_string(toml_str: &str) -> String { toml_str .split(r#"""""#) .enumerate() .map(|(i, section)| { // The odd entries are between triple string, // and the \ need to be escaped. if i % 2 == 0 { section.to_string() } else { section.replace(r#"\"#, r#"\\"#) } }) .collect::>() .join(r#"""""#) } ================================================ FILE: bin/core/src/sync/remote.rs ================================================ use anyhow::Context; use komodo_client::entities::{ RepoExecutionArgs, RepoExecutionResponse, repo::Repo, sync::{ResourceSync, SyncFileContents}, to_path_compatible_name, toml::ResourcesToml, update::Log, }; use crate::{config::core_config, helpers::git_token}; use super::file::extend_resources; pub struct RemoteResources { pub resources: anyhow::Result, pub files: Vec, pub file_errors: Vec, pub logs: Vec, pub hash: Option, pub message: Option, } /// Use `match_tags` to filter resources by tag. pub async fn get_remote_resources( sync: &ResourceSync, repo: Option<&Repo>, ) -> anyhow::Result { if sync.config.files_on_host { get_files_on_host(sync).await } else if let Some(repo) = repo { get_repo(sync, repo.into()).await } else if !sync.config.repo.is_empty() { get_repo(sync, sync.into()).await } else { get_ui_defined(sync).await } } async fn get_files_on_host( sync: &ResourceSync, ) -> anyhow::Result { let root_path = core_config() .sync_directory .join(to_path_compatible_name(&sync.name)); let (mut logs, mut files, mut file_errors) = (Vec::new(), Vec::new(), Vec::new()); let resources = super::file::read_resources( &root_path, &sync.config.resource_path, &sync.config.match_tags, &mut logs, &mut files, &mut file_errors, ); Ok(RemoteResources { resources, files, file_errors, logs, hash: None, message: None, }) } async fn get_repo( sync: &ResourceSync, mut clone_args: RepoExecutionArgs, ) -> anyhow::Result { let access_token = if let Some(account) = &clone_args.account { git_token(&clone_args.provider, account, |https| clone_args.https = https) .await .with_context( || format!("Failed to get git token in call to db. Stopping run. | {} | {account}", clone_args.provider), )? } else { None }; let repo_path = clone_args.unique_path(&core_config().repo_directory)?; clone_args.destination = Some(repo_path.display().to_string()); let ( RepoExecutionResponse { mut logs, commit_hash, commit_message, .. }, _, ) = git::pull_or_clone( clone_args, &core_config().repo_directory, access_token, ) .await .with_context(|| { format!("Failed to update resource repo at {repo_path:?}") })?; // let hash = hash.context("failed to get commit hash")?; // let message = // message.context("failed to get commit hash message")?; let (mut files, mut file_errors) = (Vec::new(), Vec::new()); let resources = super::file::read_resources( &repo_path, &sync.config.resource_path, &sync.config.match_tags, &mut logs, &mut files, &mut file_errors, ); Ok(RemoteResources { resources, files, file_errors, logs, hash: commit_hash, message: commit_message, }) } async fn get_ui_defined( sync: &ResourceSync, ) -> anyhow::Result { let mut resources = ResourcesToml::default(); let resources = super::deserialize_resources_toml(&sync.config.file_contents) .context("failed to parse resource file contents") .map(|more| { extend_resources( &mut resources, more, &sync.config.match_tags, ); resources }); Ok(RemoteResources { resources, files: vec![SyncFileContents { resource_path: String::new(), path: "database file".to_string(), contents: sync.config.file_contents.clone(), }], file_errors: vec![], logs: vec![Log::simple( "Read from database", "Resources added from database file".to_string(), )], hash: None, message: None, }) } ================================================ FILE: bin/core/src/sync/resources.rs ================================================ use std::collections::HashMap; use formatting::{Color, bold, colored, muted}; use komodo_client::{ api::execute::Execution, entities::{ ResourceTargetVariant, action::Action, alerter::Alerter, build::Build, builder::{Builder, BuilderConfig}, deployment::{Deployment, DeploymentImage}, procedure::Procedure, repo::Repo, server::Server, stack::Stack, sync::ResourceSync, tag::Tag, update::Log, user::sync_user, }, }; use partial_derive2::{MaybeNone, PartialDiff}; use crate::{ api::write::WriteArgs, resource::{KomodoResource, ResourceMetaUpdate}, state::all_resources_cache, sync::{ToUpdateItem, execute::run_update_meta}, }; use super::{ ResourceSyncTrait, SyncDeltas, execute::ExecuteResourceSync, include_resource_by_resource_type_and_name, include_resource_by_tags, }; impl ResourceSyncTrait for Server { fn get_diff( original: Self::Config, update: Self::PartialConfig, ) -> anyhow::Result { Ok(original.partial_diff(update)) } } impl ExecuteResourceSync for Server {} impl ResourceSyncTrait for Deployment { fn get_diff( mut original: Self::Config, update: Self::PartialConfig, ) -> anyhow::Result { let resources = all_resources_cache().load(); // need to replace the server id with name original.server_id = resources .servers .get(&original.server_id) .map(|s| s.name.clone()) .unwrap_or_default(); // need to replace the build id with name if let DeploymentImage::Build { build_id, version } = &original.image { original.image = DeploymentImage::Build { build_id: resources .builds .get(build_id) .map(|b| b.name.clone()) .unwrap_or_default(), version: *version, }; } Ok(original.partial_diff(update)) } } impl ExecuteResourceSync for Deployment {} impl ResourceSyncTrait for Stack { fn get_diff( mut original: Self::Config, update: Self::PartialConfig, ) -> anyhow::Result { let resources = all_resources_cache().load(); // Need to replace server id with name original.server_id = resources .servers .get(&original.server_id) .map(|s| s.name.clone()) .unwrap_or_default(); // Replace linked repo with name original.linked_repo = resources .repos .get(&original.linked_repo) .map(|r| r.name.clone()) .unwrap_or_default(); Ok(original.partial_diff(update)) } } impl ExecuteResourceSync for Stack {} impl ResourceSyncTrait for Build { fn get_diff( mut original: Self::Config, update: Self::PartialConfig, ) -> anyhow::Result { let resources = all_resources_cache().load(); original.builder_id = resources .builders .get(&original.builder_id) .map(|b| b.name.clone()) .unwrap_or_default(); original.linked_repo = resources .repos .get(&original.linked_repo) .map(|r| r.name.clone()) .unwrap_or_default(); Ok(original.partial_diff(update)) } fn validate_diff(diff: &mut Self::ConfigDiff) { if let Some((_, to)) = &diff.version { // When setting a build back to "latest" version, // Don't actually set version to None. // You can do this on the db, or set it to 0.0.1 if to.is_none() { diff.version = None; } } } } impl ExecuteResourceSync for Build {} impl ResourceSyncTrait for Repo { fn get_diff( mut original: Self::Config, update: Self::PartialConfig, ) -> anyhow::Result { let resources = all_resources_cache().load(); // Need to replace server id with name original.server_id = resources .servers .get(&original.server_id) .map(|s| s.name.clone()) .unwrap_or_default(); // Need to replace builder id with name original.builder_id = resources .builders .get(&original.builder_id) .map(|s| s.name.clone()) .unwrap_or_default(); Ok(original.partial_diff(update)) } } impl ExecuteResourceSync for Repo {} impl ResourceSyncTrait for Alerter { fn get_diff( original: Self::Config, update: Self::PartialConfig, ) -> anyhow::Result { Ok(original.partial_diff(update)) } } impl ExecuteResourceSync for Alerter {} impl ResourceSyncTrait for Builder { fn get_diff( mut original: Self::Config, update: Self::PartialConfig, ) -> anyhow::Result { // need to replace server builder id with name if let BuilderConfig::Server(config) = &mut original { let resources = all_resources_cache().load(); config.server_id = resources .servers .get(&config.server_id) .map(|s| s.name.clone()) .unwrap_or_default(); } Ok(original.partial_diff(update)) } } impl ExecuteResourceSync for Builder {} impl ResourceSyncTrait for Action { fn get_diff( original: Self::Config, update: Self::PartialConfig, ) -> anyhow::Result { Ok(original.partial_diff(update)) } } impl ExecuteResourceSync for Action {} impl ResourceSyncTrait for ResourceSync { fn include_resource( name: &String, config: &Self::Config, match_resource_type: Option, match_resources: Option<&[String]>, resource_tags: &[String], id_to_tags: &HashMap, match_tags: &[String], ) -> bool { if !include_resource_by_resource_type_and_name::( match_resource_type, match_resources, name, ) || !include_resource_by_tags( resource_tags, id_to_tags, match_tags, ) { return false; } // don't include fresh sync let contents_empty = config.file_contents.is_empty(); if contents_empty && !config.files_on_host && config.repo.is_empty() && config.linked_repo.is_empty() { return false; } // The file contents MUST be empty contents_empty && // The sync must be files on host mode OR git repo mode (config.files_on_host || !config.repo.is_empty() || !config.linked_repo.is_empty()) } fn include_resource_partial( name: &String, config: &Self::PartialConfig, match_resource_type: Option, match_resources: Option<&[String]>, resource_tags: &[String], id_to_tags: &HashMap, match_tags: &[String], ) -> bool { if !include_resource_by_resource_type_and_name::( match_resource_type, match_resources, name, ) || !include_resource_by_tags( resource_tags, id_to_tags, match_tags, ) { return false; } // don't include fresh sync let contents_empty = config .file_contents .as_ref() .map(String::is_empty) .unwrap_or(true); let files_on_host = config.files_on_host.unwrap_or_default(); if contents_empty && !files_on_host && config.repo.as_ref().map(String::is_empty).unwrap_or(true) && config .linked_repo .as_ref() .map(String::is_empty) .unwrap_or(true) { return false; } // The file contents MUST be empty contents_empty && // The sync must be files on host mode OR git repo mode (files_on_host || !config.repo.as_deref().unwrap_or_default().is_empty() || !config.linked_repo.as_deref().unwrap_or_default().is_empty()) } fn get_diff( mut original: Self::Config, update: Self::PartialConfig, ) -> anyhow::Result { let resources = all_resources_cache().load(); original.linked_repo = resources .repos .get(&original.linked_repo) .map(|r| r.name.clone()) .unwrap_or_default(); Ok(original.partial_diff(update)) } } impl ExecuteResourceSync for ResourceSync {} impl ResourceSyncTrait for Procedure { fn get_diff( mut original: Self::Config, update: Self::PartialConfig, ) -> anyhow::Result { let resources = all_resources_cache().load(); for stage in &mut original.stages { for execution in &mut stage.executions { match &mut execution.execution { Execution::None(_) => {} Execution::RunProcedure(config) => { config.procedure = resources .procedures .get(&config.procedure) .map(|p| p.name.clone()) .unwrap_or_default(); } Execution::BatchRunProcedure(_config) => {} Execution::RunAction(config) => { config.action = resources .actions .get(&config.action) .map(|p| p.name.clone()) .unwrap_or_default(); } Execution::BatchRunAction(_config) => {} Execution::RunBuild(config) => { config.build = resources .builds .get(&config.build) .map(|b| b.name.clone()) .unwrap_or_default(); } Execution::BatchRunBuild(_config) => {} Execution::CancelBuild(config) => { config.build = resources .builds .get(&config.build) .map(|b| b.name.clone()) .unwrap_or_default(); } Execution::Deploy(config) => { config.deployment = resources .deployments .get(&config.deployment) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::BatchDeploy(_config) => {} Execution::PullDeployment(config) => { config.deployment = resources .deployments .get(&config.deployment) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::StartDeployment(config) => { config.deployment = resources .deployments .get(&config.deployment) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::RestartDeployment(config) => { config.deployment = resources .deployments .get(&config.deployment) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::PauseDeployment(config) => { config.deployment = resources .deployments .get(&config.deployment) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::UnpauseDeployment(config) => { config.deployment = resources .deployments .get(&config.deployment) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::StopDeployment(config) => { config.deployment = resources .deployments .get(&config.deployment) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::DestroyDeployment(config) => { config.deployment = resources .deployments .get(&config.deployment) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::BatchDestroyDeployment(_config) => {} Execution::CloneRepo(config) => { config.repo = resources .repos .get(&config.repo) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::BatchCloneRepo(_config) => {} Execution::PullRepo(config) => { config.repo = resources .repos .get(&config.repo) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::BatchPullRepo(_config) => {} Execution::BuildRepo(config) => { config.repo = resources .repos .get(&config.repo) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::BatchBuildRepo(_config) => {} Execution::CancelRepoBuild(config) => { config.repo = resources .repos .get(&config.repo) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::StartContainer(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::RestartContainer(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::PauseContainer(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::UnpauseContainer(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::StopContainer(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::DestroyContainer(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::StartAllContainers(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::RestartAllContainers(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::PauseAllContainers(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::UnpauseAllContainers(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::StopAllContainers(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::PruneContainers(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::DeleteNetwork(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::PruneNetworks(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::DeleteImage(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::PruneImages(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::DeleteVolume(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::PruneVolumes(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::PruneDockerBuilders(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::PruneBuildx(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::PruneSystem(config) => { config.server = resources .servers .get(&config.server) .map(|d| d.name.clone()) .unwrap_or_default(); } Execution::RunSync(config) => { config.sync = resources .syncs .get(&config.sync) .map(|s| s.name.clone()) .unwrap_or_default(); } Execution::CommitSync(config) => { config.sync = resources .syncs .get(&config.sync) .map(|s| s.name.clone()) .unwrap_or_default(); } Execution::DeployStack(config) => { config.stack = resources .stacks .get(&config.stack) .map(|s| s.name.clone()) .unwrap_or_default(); } Execution::BatchDeployStack(_config) => {} Execution::DeployStackIfChanged(config) => { config.stack = resources .stacks .get(&config.stack) .map(|s| s.name.clone()) .unwrap_or_default(); } Execution::BatchDeployStackIfChanged(_config) => {} Execution::PullStack(config) => { config.stack = resources .stacks .get(&config.stack) .map(|s| s.name.clone()) .unwrap_or_default(); } Execution::BatchPullStack(_config) => {} Execution::StartStack(config) => { config.stack = resources .stacks .get(&config.stack) .map(|s| s.name.clone()) .unwrap_or_default(); } Execution::RestartStack(config) => { config.stack = resources .stacks .get(&config.stack) .map(|s| s.name.clone()) .unwrap_or_default(); } Execution::PauseStack(config) => { config.stack = resources .stacks .get(&config.stack) .map(|s| s.name.clone()) .unwrap_or_default(); } Execution::UnpauseStack(config) => { config.stack = resources .stacks .get(&config.stack) .map(|s| s.name.clone()) .unwrap_or_default(); } Execution::StopStack(config) => { config.stack = resources .stacks .get(&config.stack) .map(|s| s.name.clone()) .unwrap_or_default(); } Execution::DestroyStack(config) => { config.stack = resources .stacks .get(&config.stack) .map(|s| s.name.clone()) .unwrap_or_default(); } Execution::RunStackService(config) => { config.stack = resources .stacks .get(&config.stack) .map(|s| s.name.clone()) .unwrap_or_default(); } Execution::BatchDestroyStack(_config) => {} Execution::TestAlerter(config) => { config.alerter = resources .alerters .get(&config.alerter) .map(|a| a.name.clone()) .unwrap_or_default(); } Execution::SendAlert(config) => { config.alerters = config .alerters .iter() .map(|alerter| { resources .alerters .get(alerter) .map(|a| a.name.clone()) .unwrap_or_default() }) .collect(); } Execution::ClearRepoCache(_) => {} Execution::BackupCoreDatabase(_) => {} Execution::GlobalAutoUpdate(_) => {} Execution::Sleep(_) => {} } } } Ok(original.partial_diff(update)) } } impl ExecuteResourceSync for Procedure { async fn execute_sync_updates( SyncDeltas { mut to_create, mut to_update, to_delete, }: SyncDeltas, ) -> Option { if to_create.is_empty() && to_update.is_empty() && to_delete.is_empty() { return None; } let mut has_error = false; let mut log = format!("running updates on {}s", Self::resource_type()); for name in to_delete { if let Err(e) = crate::resource::delete::( &name, &WriteArgs { user: sync_user().to_owned(), }, ) .await { has_error = true; log.push_str(&format!( "\n{}: failed to delete {} '{}' | {e:#}", colored("ERROR", Color::Red), Self::resource_type(), bold(&name), )) } else { log.push_str(&format!( "\n{}: {} {} '{}'", muted("INFO"), colored("deleted", Color::Red), Self::resource_type(), bold(&name) )); } } if to_update.is_empty() && to_create.is_empty() { let stage = "Update Procedures"; return Some(if has_error { Log::error(stage, log) } else { Log::simple(stage, log) }); } for i in 0..10 { let mut to_pull = Vec::new(); for ToUpdateItem { id, resource, update_description, update_template, update_tags, } in &to_update { let name = resource.name.clone(); let meta = ResourceMetaUpdate { description: update_description .then(|| resource.description.clone()), template: update_template.then(|| resource.template), tags: update_tags.then(|| resource.tags.clone()), }; if !meta.is_none() { run_update_meta::( id.clone(), &name, meta, &mut log, &mut has_error, ) .await; } if !resource.config.is_none() && let Err(e) = crate::resource::update::( id, resource.config.clone(), sync_user(), ) .await { if i == 9 { has_error = true; log.push_str(&format!( "\n{}: failed to update {} '{}' | {e:#}", colored("ERROR", Color::Red), Self::resource_type(), bold(&name) )); } continue; } log.push_str(&format!( "\n{}: {} '{}' updated", muted("INFO"), Self::resource_type(), bold(&name) )); // have to clone out so to_update is mutable to_pull.push(id.clone()); } // to_update.retain(|resource| !to_pull.contains(&resource.id)); let mut to_pull = Vec::new(); for resource in &to_create { let name = resource.name.clone(); let id = match crate::resource::create::( &name, resource.config.clone(), sync_user(), ) .await .map_err(|e| e.error) { Ok(resource) => resource.id, Err(e) => { if i == 9 { has_error = true; log.push_str(&format!( "\n{}: failed to create {} '{}' | {e:#}", colored("ERROR", Color::Red), Self::resource_type(), bold(&name) )); } continue; } }; run_update_meta::( id.clone(), &name, ResourceMetaUpdate { description: Some(resource.description.clone()), template: Some(resource.template), tags: Some(resource.tags.clone()), }, &mut log, &mut has_error, ) .await; log.push_str(&format!( "\n{}: {} {} '{}'", muted("INFO"), colored("created", Color::Green), Self::resource_type(), bold(&name) )); to_pull.push(name); } to_create.retain(|resource| !to_pull.contains(&resource.name)); if to_update.is_empty() && to_create.is_empty() { let stage = "Update Procedures"; return Some(if has_error { Log::error(stage, log) } else { Log::simple(stage, log) }); } } warn!("procedure sync loop exited after max iterations"); Some(Log::error( "run procedure", String::from("procedure sync loop exited after max iterations"), )) } } ================================================ FILE: bin/core/src/sync/toml.rs ================================================ use std::collections::HashMap; use anyhow::Context; use indexmap::IndexMap; use komodo_client::{ api::execute::Execution, entities::{ action::Action, alerter::Alerter, build::Build, builder::{Builder, BuilderConfig, PartialBuilderConfig}, deployment::{Deployment, DeploymentImage}, procedure::Procedure, repo::Repo, resource::Resource, server::Server, stack::Stack, sync::ResourceSync, tag::Tag, toml::ResourceToml, }, }; use partial_derive2::{MaybeNone, PartialDiff}; use crate::{resource::KomodoResource, state::all_resources_cache}; pub const TOML_PRETTY_OPTIONS: toml_pretty::Options = toml_pretty::Options { tab: " ", skip_empty_string: true, // Usually we do this, but has to be changed for some cases. skip_empty_object: true, max_inline_array_length: 30, inline_array: false, }; pub trait ToToml: KomodoResource { /// Replace linked ids (server_id, build_id, etc) with the resource name. fn replace_ids(_resource: &mut Resource) { } fn edit_config_object( _resource: &ResourceToml, config: IndexMap, ) -> anyhow::Result> { Ok(config) } fn push_additional( _resource: ResourceToml, _toml: &mut String, ) { } fn push_to_toml_string( mut resource: ResourceToml, toml: &mut String, ) -> anyhow::Result<()> { resource.config = Self::Config::default().minimize_partial(resource.config); let mut resource_map: IndexMap = serde_json::from_str(&serde_json::to_string(&resource)?)?; resource_map.shift_remove("config"); let config = serde_json::from_str(&serde_json::to_string( &resource.config, )?)?; let config = Self::edit_config_object(&resource, config)?; toml.push_str( &toml_pretty::to_string(&resource_map, TOML_PRETTY_OPTIONS) .context("failed to serialize resource to toml")?, ); toml.push_str(&format!( "\n[{}.config]\n", Self::resource_type().toml_header() )); toml.push_str( &toml_pretty::to_string(&config, TOML_PRETTY_OPTIONS) .context("failed to serialize resource config to toml")?, ); Self::push_additional(resource, toml); Ok(()) } } pub fn resource_toml_to_toml_string( resource: ResourceToml, ) -> anyhow::Result { let mut toml = String::new(); toml .push_str(&format!("[[{}]]\n", R::resource_type().toml_header())); R::push_to_toml_string(resource, &mut toml)?; Ok(toml) } pub fn resource_push_to_toml( mut resource: Resource, deploy: bool, after: Vec, toml: &mut String, all_tags: &HashMap, ) -> anyhow::Result<()> { R::replace_ids(&mut resource); if !toml.is_empty() { toml.push_str("\n\n##\n\n"); } toml .push_str(&format!("[[{}]]\n", R::resource_type().toml_header())); R::push_to_toml_string( convert_resource::(resource, deploy, after, all_tags), toml, )?; Ok(()) } pub fn resource_to_toml( resource: Resource, deploy: bool, after: Vec, all_tags: &HashMap, ) -> anyhow::Result { let mut toml = String::new(); resource_push_to_toml::( resource, deploy, after, &mut toml, all_tags, )?; Ok(toml) } pub fn convert_resource( resource: Resource, deploy: bool, after: Vec, all_tags: &HashMap, ) -> ResourceToml { ResourceToml { name: resource.name, description: resource.description, template: resource.template, tags: resource .tags .iter() .filter_map(|t| all_tags.get(t).map(|t| t.name.clone())) .collect(), deploy, after, // The config still needs to be minimized. // This happens in ToToml::push_to_toml config: resource.config.into(), } } // These have no linked resource ids to replace impl ToToml for Alerter {} impl ToToml for Server {} impl ToToml for Action {} impl ToToml for ResourceSync { fn replace_ids(resource: &mut Resource) { let all = all_resources_cache().load(); resource.config.linked_repo.clone_from( all .repos .get(&resource.config.linked_repo) .map(|r| &r.name) .unwrap_or(&String::new()), ); } } impl ToToml for Stack { fn replace_ids(resource: &mut Resource) { let all = all_resources_cache().load(); resource.config.server_id.clone_from( all .servers .get(&resource.config.server_id) .map(|s| &s.name) .unwrap_or(&String::new()), ); resource.config.linked_repo.clone_from( all .repos .get(&resource.config.linked_repo) .map(|r| &r.name) .unwrap_or(&String::new()), ); } fn edit_config_object( _resource: &ResourceToml, config: IndexMap, ) -> anyhow::Result> { config .into_iter() .map(|(key, value)| { #[allow(clippy::single_match)] match key.as_str() { "server_id" => return Ok((String::from("server"), value)), _ => {} } Ok((key, value)) }) .collect() } } impl ToToml for Deployment { fn replace_ids(resource: &mut Resource) { let all = all_resources_cache().load(); resource.config.server_id.clone_from( all .servers .get(&resource.config.server_id) .map(|s| &s.name) .unwrap_or(&String::new()), ); if let DeploymentImage::Build { build_id, .. } = &mut resource.config.image { build_id.clone_from( all .builds .get(build_id) .map(|b| &b.name) .unwrap_or(&String::new()), ); } } fn edit_config_object( resource: &ResourceToml, config: IndexMap, ) -> anyhow::Result> { config .into_iter() .map(|(key, mut value)| { match key.as_str() { "server_id" => return Ok((String::from("server"), value)), "image" => { if let Some(DeploymentImage::Build { version, .. }) = &resource.config.image { let image = value .get_mut("params") .context("deployment image has no params")? .as_object_mut() .context("deployment image params is not object")?; if let Some(build) = image.remove("build_id") { image.insert(String::from("build"), build); } if version.is_none() { image.remove("version"); } else { image.insert( "version".to_string(), serde_json::Value::String(version.to_string()), ); } } } _ => {} } Ok((key, value)) }) .collect() } } impl ToToml for Build { fn replace_ids(resource: &mut Resource) { let all = all_resources_cache().load(); resource.config.builder_id.clone_from( all .builders .get(&resource.config.builder_id) .map(|s| &s.name) .unwrap_or(&String::new()), ); resource.config.linked_repo.clone_from( all .repos .get(&resource.config.linked_repo) .map(|r| &r.name) .unwrap_or(&String::new()), ); } fn edit_config_object( resource: &ResourceToml, config: IndexMap, ) -> anyhow::Result> { config .into_iter() .map(|(key, value)| match key.as_str() { "builder_id" => Ok((String::from("builder"), value)), "version" => { match ( &resource.config.version, resource.config.auto_increment_version, ) { (None, _) => Ok((key, value)), (_, Some(true)) | (_, None) => { // The toml shouldn't have a version attached if auto incrementing. // Empty string will be filtered out in final toml. Ok((key, serde_json::Value::String(String::new()))) } (Some(version), _) => Ok(( key, serde_json::Value::String(version.to_string()), )), } } _ => Ok((key, value)), }) .collect() } } impl ToToml for Repo { fn replace_ids(resource: &mut Resource) { let all = all_resources_cache().load(); resource.config.server_id.clone_from( all .servers .get(&resource.config.server_id) .map(|s| &s.name) .unwrap_or(&String::new()), ); resource.config.builder_id.clone_from( all .builders .get(&resource.config.builder_id) .map(|s| &s.name) .unwrap_or(&String::new()), ); } fn edit_config_object( _resource: &ResourceToml, config: IndexMap, ) -> anyhow::Result> { config .into_iter() .map(|(key, value)| { match key.as_str() { "server_id" => return Ok((String::from("server"), value)), "builder_id" => { return Ok((String::from("builder"), value)); } _ => {} } Ok((key, value)) }) .collect() } } impl ToToml for Builder { fn replace_ids(resource: &mut Resource) { if let BuilderConfig::Server(config) = &mut resource.config { let all = all_resources_cache().load(); config.server_id.clone_from( all .servers .get(&config.server_id) .map(|s| &s.name) .unwrap_or(&String::new()), ) } } fn push_additional( resource: ResourceToml, toml: &mut String, ) { let empty_params = match resource.config { PartialBuilderConfig::Aws(config) => config.is_none(), PartialBuilderConfig::Server(config) => config.is_none(), PartialBuilderConfig::Url(config) => config.is_none(), }; if empty_params { // toml_pretty will remove empty map // but in this case its needed to deserialize the enums. toml.push_str("\nparams = {}"); } } } impl ToToml for Procedure { fn replace_ids(resource: &mut Resource) { let all = all_resources_cache().load(); for stage in &mut resource.config.stages { for execution in &mut stage.executions { match &mut execution.execution { Execution::RunProcedure(exec) => exec.procedure.clone_from( all .procedures .get(&exec.procedure) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::BatchRunProcedure(_exec) => {} Execution::RunAction(exec) => exec.action.clone_from( all .actions .get(&exec.action) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::BatchRunAction(_exec) => {} Execution::RunBuild(exec) => exec.build.clone_from( all .builds .get(&exec.build) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::BatchRunBuild(_exec) => {} Execution::CancelBuild(exec) => exec.build.clone_from( all .builds .get(&exec.build) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::Deploy(exec) => exec.deployment.clone_from( all .deployments .get(&exec.deployment) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::BatchDeploy(_exec) => {} Execution::PullDeployment(exec) => { exec.deployment.clone_from( all .deployments .get(&exec.deployment) .map(|r| &r.name) .unwrap_or(&String::new()), ) } Execution::StartDeployment(exec) => { exec.deployment.clone_from( all .deployments .get(&exec.deployment) .map(|r| &r.name) .unwrap_or(&String::new()), ) } Execution::RestartDeployment(exec) => { exec.deployment.clone_from( all .deployments .get(&exec.deployment) .map(|r| &r.name) .unwrap_or(&String::new()), ) } Execution::PauseDeployment(exec) => { exec.deployment.clone_from( all .deployments .get(&exec.deployment) .map(|r| &r.name) .unwrap_or(&String::new()), ) } Execution::UnpauseDeployment(exec) => { exec.deployment.clone_from( all .deployments .get(&exec.deployment) .map(|r| &r.name) .unwrap_or(&String::new()), ) } Execution::StopDeployment(exec) => { exec.deployment.clone_from( all .deployments .get(&exec.deployment) .map(|r| &r.name) .unwrap_or(&String::new()), ) } Execution::DestroyDeployment(exec) => { exec.deployment.clone_from( all .deployments .get(&exec.deployment) .map(|r| &r.name) .unwrap_or(&String::new()), ) } Execution::BatchDestroyDeployment(_exec) => {} Execution::CloneRepo(exec) => exec.repo.clone_from( all .repos .get(&exec.repo) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::BatchCloneRepo(_exec) => {} Execution::PullRepo(exec) => exec.repo.clone_from( all .repos .get(&exec.repo) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::BatchPullRepo(_exec) => {} Execution::BuildRepo(exec) => exec.repo.clone_from( all .repos .get(&exec.repo) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::BatchBuildRepo(_exec) => {} Execution::CancelRepoBuild(exec) => exec.repo.clone_from( all .repos .get(&exec.repo) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::StartContainer(exec) => exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::RestartContainer(exec) => { exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ) } Execution::PauseContainer(exec) => exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::UnpauseContainer(exec) => { exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ) } Execution::StopContainer(exec) => exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::DestroyContainer(exec) => { exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ) } Execution::StartAllContainers(exec) => { exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ) } Execution::RestartAllContainers(exec) => { exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ) } Execution::PauseAllContainers(exec) => { exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ) } Execution::UnpauseAllContainers(exec) => { exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ) } Execution::StopAllContainers(exec) => { exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ) } Execution::PruneContainers(exec) => exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::DeleteNetwork(exec) => exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::PruneNetworks(exec) => exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::DeleteImage(exec) => exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::PruneImages(exec) => exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::DeleteVolume(exec) => exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::PruneVolumes(exec) => exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::PruneDockerBuilders(exec) => { exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ) } Execution::PruneBuildx(exec) => exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::PruneSystem(exec) => exec.server.clone_from( all .servers .get(&exec.server) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::RunSync(exec) => exec.sync.clone_from( all .syncs .get(&exec.sync) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::CommitSync(exec) => exec.sync.clone_from( all .syncs .get(&exec.sync) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::DeployStack(exec) => exec.stack.clone_from( all .stacks .get(&exec.stack) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::BatchDeployStack(_exec) => {} Execution::DeployStackIfChanged(exec) => { exec.stack.clone_from( all .stacks .get(&exec.stack) .map(|r| &r.name) .unwrap_or(&String::new()), ) } Execution::BatchDeployStackIfChanged(_exec) => {} Execution::PullStack(exec) => exec.stack.clone_from( all .stacks .get(&exec.stack) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::BatchPullStack(_exec) => {} Execution::StartStack(exec) => exec.stack.clone_from( all .stacks .get(&exec.stack) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::RestartStack(exec) => exec.stack.clone_from( all .stacks .get(&exec.stack) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::RunStackService(exec) => exec.stack.clone_from( all .stacks .get(&exec.stack) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::PauseStack(exec) => exec.stack.clone_from( all .stacks .get(&exec.stack) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::UnpauseStack(exec) => exec.stack.clone_from( all .stacks .get(&exec.stack) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::StopStack(exec) => exec.stack.clone_from( all .stacks .get(&exec.stack) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::DestroyStack(exec) => exec.stack.clone_from( all .stacks .get(&exec.stack) .map(|r| &r.name) .unwrap_or(&String::new()), ), Execution::BatchDestroyStack(_exec) => {} Execution::TestAlerter(exec) => exec.alerter.clone_from( all .alerters .get(&exec.alerter) .map(|a| &a.name) .unwrap_or(&String::new()), ), Execution::SendAlert(exec) => { exec.alerters.iter_mut().for_each(|a| { a.clone_from( all .alerters .get(a) .map(|a| &a.name) .unwrap_or(&String::new()), ) }) } Execution::None(_) | Execution::Sleep(_) | Execution::ClearRepoCache(_) | Execution::BackupCoreDatabase(_) | Execution::GlobalAutoUpdate(_) => {} } } } } fn push_to_toml_string( mut resource: ResourceToml, toml: &mut String, ) -> anyhow::Result<()> { resource.config = Self::Config::default().minimize_partial(resource.config); let mut parsed: IndexMap = serde_json::from_str(&serde_json::to_string(&resource)?)?; let config = parsed .get_mut("config") .context("procedure has no config?")? .as_object_mut() .context("config is not object?")?; let stages = config.remove("stages"); toml.push_str( &toml_pretty::to_string(&parsed, TOML_PRETTY_OPTIONS) .context("failed to serialize procedures to toml")?, ); if let Some(stages) = stages { let stages = stages.as_array().context("stages is not array")?; for stage in stages { toml.push_str("\n\n[[procedure.config.stage]]\n"); toml.push_str( &toml_pretty::to_string( stage, // If the execution.params are fully missing, // deserialization will fail. TOML_PRETTY_OPTIONS.skip_empty_object(false), ) .context("failed to serialize procedures to toml")?, ); } } Ok(()) } } ================================================ FILE: bin/core/src/sync/user_groups.rs ================================================ use std::{ cmp::Ordering, collections::HashMap, fmt::Write, sync::OnceLock, }; use anyhow::Context; use database::mungos::find::find_collect; use formatting::{Color, bold, colored, muted}; use indexmap::{IndexMap, IndexSet}; use komodo_client::{ api::{ read::ListUserTargetPermissions, write::{ CreateUserGroup, DeleteUserGroup, SetEveryoneUserGroup, SetUsersInUserGroup, UpdatePermissionOnResourceType, UpdatePermissionOnTarget, }, }, entities::{ ResourceTarget, ResourceTargetVariant, permission::{ PermissionLevel, PermissionLevelAndSpecifics, SpecificPermission, UserTarget, }, sync::DiffData, toml::{PermissionToml, UserGroupToml}, update::Log, user::{User, sync_user}, user_group::UserGroup, }, }; use resolver_api::Resolve; use serde::Serialize; use crate::{ api::{read::ReadArgs, write::WriteArgs}, helpers::matcher::Matcher, state::{all_resources_cache, db_client}, }; use super::toml::TOML_PRETTY_OPTIONS; /// Used to serialize user group #[derive(Serialize)] struct BasicUserGroupToml { name: String, #[serde(skip_serializing_if = "is_false")] everyone: bool, #[serde(skip_serializing_if = "Vec::is_empty")] users: Vec, } fn is_false(b: &bool) -> bool { !b } /// Used to serialize user group #[derive(Serialize)] struct Permissions { permissions: Vec, } pub fn user_group_to_toml( user_group: UserGroupToml, ) -> anyhow::Result { // Start with the basic body let basic = BasicUserGroupToml { name: user_group.name, everyone: user_group.everyone, users: if user_group.everyone { Vec::new() } else { user_group.users }, }; let basic = toml_pretty::to_string(&basic, TOML_PRETTY_OPTIONS) .context("failed to serialize user group to toml")?; let mut res = format!("[[user_group]]\n{basic}"); // Add "all" permissions for (variant, PermissionLevelAndSpecifics { level, specific }) in user_group.all { // skip 'zero' all permissions if level == PermissionLevel::None && specific.is_empty() { continue; } write!(&mut res, "\nall.{variant} = ") .context("failed to serialize user group 'all' to toml")?; if specific.is_empty() { res.push('"'); res.push_str(level.as_ref()); res.push('"'); } else { let specific = serde_json::to_string(&specific) .context( "failed to serialize user group specifics to... json?", )? .replace(",", ", "); write!( &mut res, "{{ level = \"{level}\", specific = {specific} }}" ) .context( "failed to serialize user group 'all' with specifics to toml", )?; } } // End with resource permissions array if !user_group.permissions.is_empty() { res.push('\n'); res.push_str( &toml_pretty::to_string( &Permissions { permissions: user_group.permissions, }, TOML_PRETTY_OPTIONS, ) .context( "failed to serialize user group permissions to toml", )?, ); } Ok(res) } pub struct UpdateItem { user_group: UserGroupToml, update_users: bool, update_everyone: bool, all_diff: IndexMap, } pub struct DeleteItem { id: String, name: String, } pub async fn get_updates_for_view( user_groups: Vec, delete: bool, ) -> anyhow::Result> { let _curr = find_collect(&db_client().user_groups, None, None) .await .context("failed to query db for UserGroups")?; let mut curr = Vec::with_capacity(_curr.capacity()); convert_user_groups(_curr.into_iter(), &mut curr).await?; let map = curr .into_iter() .map(|ug| (ug.1.name.clone(), ug)) .collect::>(); let mut diffs = Vec::::new(); if delete { for (_id, user_group) in map.values() { if !user_groups.iter().any(|ug| ug.name == user_group.name) { diffs.push(DiffData::Delete { current: user_group_to_toml(user_group.clone())?, }); } } } for mut user_group in user_groups { if user_group.everyone { user_group.users.clear(); } user_group .permissions .retain(|p| p.level > PermissionLevel::None); user_group.permissions = expand_user_group_permissions(user_group.permissions) .await .with_context(|| { format!( "failed to expand user group {} permissions", user_group.name ) })?; let (_original_id, original) = match map.get(&user_group.name).cloned() { Some(original) => original, None => { diffs.push(DiffData::Create { name: user_group.name.clone(), proposed: user_group_to_toml(user_group.clone())?, }); continue; } }; user_group.users.sort(); let all_diff = diff_group_all(&original.all, &user_group.all); user_group.permissions.sort_by(sort_permissions); let update_users = user_group.users != original.users; let update_everyone = user_group.everyone != original.everyone; let update_all = !all_diff.is_empty(); let update_permissions = user_group.permissions != original.permissions; // only add log after diff detected if update_users || update_everyone || update_all || update_permissions { diffs.push(DiffData::Update { proposed: user_group_to_toml(user_group.clone())?, current: user_group_to_toml(original.clone())?, }); } } Ok(diffs) } pub async fn get_updates_for_execution( user_groups: Vec, delete: bool, ) -> anyhow::Result<( Vec, Vec, Vec, )> { let map = find_collect(&db_client().user_groups, None, None) .await .context("failed to query db for UserGroups")? .into_iter() .map(|mut ug| { if ug.everyone { ug.users.clear(); } ug.all.retain(|_, p| { p.level > PermissionLevel::None || !p.specific.is_empty() }); (ug.name.clone(), ug) }) .collect::>(); let mut to_create = Vec::::new(); let mut to_update = Vec::::new(); let mut to_delete = Vec::::new(); if delete { for user_group in map.values() { if !user_groups.iter().any(|ug| ug.name == user_group.name) { to_delete.push(DeleteItem { id: user_group.id.clone(), name: user_group.name.clone(), }); } } } if user_groups.is_empty() { return Ok((to_create, to_update, to_delete)); } let id_to_user = find_collect(&db_client().users, None, None) .await .context("failed to query db for Users")? .into_iter() .map(|user| (user.id.clone(), user)) .collect::>(); for mut user_group in user_groups { if user_group.everyone { user_group.users.clear(); } user_group .permissions .retain(|p| p.level > PermissionLevel::None); user_group.permissions = expand_user_group_permissions(user_group.permissions) .await .with_context(|| { format!( "Failed to expand user group {} permissions", user_group.name ) })?; let original = match map.get(&user_group.name).cloned() { Some(original) => original, None => { to_create.push(user_group); continue; } }; let mut original_users = original .users .into_iter() .filter_map(|user_id| { id_to_user.get(&user_id).map(|u| u.username.clone()) }) .collect::>(); let all_resources = all_resources_cache().load(); let mut original_permissions = (ListUserTargetPermissions { user_target: UserTarget::UserGroup(original.id), }) .resolve(&ReadArgs { user: sync_user().to_owned(), }) .await .map_err(|e| e.error) .context("failed to query for existing UserGroup permissions")? .into_iter() .filter(|p| p.level > PermissionLevel::None) .map(|mut p| { // replace the ids with names match &mut p.resource_target { ResourceTarget::System(_) => {} ResourceTarget::Build(id) => { *id = all_resources .builds .get(id) .map(|b| b.name.clone()) .unwrap_or_default() } ResourceTarget::Builder(id) => { *id = all_resources .builders .get(id) .map(|b| b.name.clone()) .unwrap_or_default() } ResourceTarget::Deployment(id) => { *id = all_resources .deployments .get(id) .map(|b| b.name.clone()) .unwrap_or_default() } ResourceTarget::Server(id) => { *id = all_resources .servers .get(id) .map(|b| b.name.clone()) .unwrap_or_default() } ResourceTarget::Repo(id) => { *id = all_resources .repos .get(id) .map(|b| b.name.clone()) .unwrap_or_default() } ResourceTarget::Alerter(id) => { *id = all_resources .alerters .get(id) .map(|b| b.name.clone()) .unwrap_or_default() } ResourceTarget::Procedure(id) => { *id = all_resources .procedures .get(id) .map(|b| b.name.clone()) .unwrap_or_default() } ResourceTarget::Action(id) => { *id = all_resources .actions .get(id) .map(|b| b.name.clone()) .unwrap_or_default() } ResourceTarget::ResourceSync(id) => { *id = all_resources .syncs .get(id) .map(|b| b.name.clone()) .unwrap_or_default() } ResourceTarget::Stack(id) => { *id = all_resources .stacks .get(id) .map(|b| b.name.clone()) .unwrap_or_default() } } PermissionToml { target: p.resource_target, level: p.level, specific: p.specific, } }) .collect::>(); original_users.sort(); user_group.users.sort(); let all_diff = diff_group_all(&original.all, &user_group.all); user_group.permissions.sort_by(sort_permissions); original_permissions.sort_by(sort_permissions); let update_users = user_group.users != original_users; let update_everyone = user_group.everyone != original.everyone; // Extend permissions with any existing that have no target in incoming // This makes sure to set those permissions back to None. let to_remove = original_permissions .iter() .filter(|permission| { !user_group .permissions .iter() .any(|p| p.target == permission.target) }) .map(|permission| PermissionToml { target: permission.target.clone(), level: PermissionLevel::None, specific: IndexSet::new(), }) .collect::>(); user_group.permissions.extend(to_remove); // remove any permissions that already exist on original user_group.permissions.retain(|permission| { let Some(original_permission) = original_permissions .iter() .find(|p| p.target == permission.target) else { // not in original, keep it return true; }; original_permission.level != permission.level || !specific_equal( &original_permission.specific, &permission.specific, ) }); // only push update after diff detected if update_users || update_everyone || !all_diff.is_empty() || !user_group.permissions.is_empty() { to_update.push(UpdateItem { user_group, update_users, update_everyone, all_diff: all_diff .into_iter() .map(|(k, (_, v))| (k, v)) .collect(), }); } } Ok((to_create, to_update, to_delete)) } /// order permissions in deterministic way fn sort_permissions( a: &PermissionToml, b: &PermissionToml, ) -> Ordering { let (a_t, a_id) = a.target.extract_variant_id(); let (b_t, b_id) = b.target.extract_variant_id(); match (a_t.cmp(&b_t), a_id.cmp(b_id)) { (Ordering::Greater, _) => Ordering::Greater, (Ordering::Less, _) => Ordering::Less, (_, Ordering::Greater) => Ordering::Greater, (_, Ordering::Less) => Ordering::Less, _ => Ordering::Equal, } } pub async fn run_updates( to_create: Vec, to_update: Vec, to_delete: Vec, ) -> Option { if to_create.is_empty() && to_update.is_empty() && to_delete.is_empty() { return None; } let mut has_error = false; let mut log = String::from("running updates on UserGroups"); // Create the non-existant user groups for user_group in to_create { // Create the user group if let Err(e) = (CreateUserGroup { name: user_group.name.clone(), }) .resolve(&WriteArgs { user: sync_user().to_owned(), }) .await { has_error = true; log.push_str(&format!( "\n{}: failed to create user group '{}' | {:#}", colored("ERROR", Color::Red), bold(&user_group.name), e.error )); continue; } else { log.push_str(&format!( "\n{}: {} user group '{}'", muted("INFO"), colored("created", Color::Green), bold(&user_group.name) )) }; set_users( user_group.name.clone(), user_group.users, &mut log, &mut has_error, ) .await; set_everyone( user_group.name.clone(), user_group.everyone, &mut log, &mut has_error, ) .await; run_update_all( user_group.name.clone(), user_group.all, &mut log, &mut has_error, ) .await; run_update_permissions( user_group.name, user_group.permissions, &mut log, &mut has_error, ) .await; } // Update the existing user groups for UpdateItem { user_group, update_users, update_everyone, all_diff, } in to_update { if update_users { set_users( user_group.name.clone(), user_group.users, &mut log, &mut has_error, ) .await; } if update_everyone { set_everyone( user_group.name.clone(), user_group.everyone, &mut log, &mut has_error, ) .await; } if !all_diff.is_empty() { run_update_all( user_group.name.clone(), all_diff, &mut log, &mut has_error, ) .await; } if !user_group.permissions.is_empty() { run_update_permissions( user_group.name, user_group.permissions, &mut log, &mut has_error, ) .await; } } for user_group in to_delete { if let Err(e) = (DeleteUserGroup { id: user_group.id }) .resolve(&WriteArgs { user: sync_user().to_owned(), }) .await { has_error = true; log.push_str(&format!( "\n{}: failed to delete user group '{}' | {:#}", colored("ERROR", Color::Red), bold(&user_group.name), e.error )) } else { log.push_str(&format!( "\n{}: {} user group '{}'", muted("INFO"), colored("deleted", Color::Red), bold(&user_group.name) )) } } let stage = "Update UserGroups"; Some(if has_error { Log::error(stage, log) } else { Log::simple(stage, log) }) } async fn set_users( user_group: String, users: Vec, log: &mut String, has_error: &mut bool, ) { if let Err(e) = (SetUsersInUserGroup { user_group: user_group.clone(), users, }) .resolve(&WriteArgs { user: sync_user().to_owned(), }) .await { *has_error = true; log.push_str(&format!( "\n{}: failed to set users in group {} | {:#}", colored("ERROR", Color::Red), bold(&user_group), e.error )) } else { log.push_str(&format!( "\n{}: {} user group '{}' users", muted("INFO"), colored("updated", Color::Blue), bold(&user_group) )) } } async fn set_everyone( user_group: String, everyone: bool, log: &mut String, has_error: &mut bool, ) { if let Err(e) = (SetEveryoneUserGroup { user_group: user_group.clone(), everyone, }) .resolve(&WriteArgs { user: sync_user().to_owned(), }) .await { *has_error = true; log.push_str(&format!( "\n{}: failed to set everyone for group {} | {:#}", colored("ERROR", Color::Red), bold(&user_group), e.error )) } else { log.push_str(&format!( "\n{}: {} user group '{}' everyone", muted("INFO"), colored("updated", Color::Blue), bold(&user_group) )) } } async fn run_update_all( user_group: String, all_diff: IndexMap< ResourceTargetVariant, PermissionLevelAndSpecifics, >, log: &mut String, has_error: &mut bool, ) { for (resource_type, permission) in all_diff { if let Err(e) = (UpdatePermissionOnResourceType { user_target: UserTarget::UserGroup(user_group.clone()), resource_type, permission, }) .resolve(&WriteArgs { user: sync_user().to_owned(), }) .await { *has_error = true; log.push_str(&format!( "\n{}: failed to set base permissions on {resource_type} in group {} | {:#}", colored("ERROR", Color::Red), bold(&user_group), e.error )) } else { log.push_str(&format!( "\n{}: {} user group '{}' base permissions on {resource_type}", muted("INFO"), colored("updated", Color::Blue), bold(&user_group) )) } } } async fn run_update_permissions( user_group: String, permissions: Vec, log: &mut String, has_error: &mut bool, ) { for PermissionToml { target, level, specific, } in permissions { if let Err(e) = (UpdatePermissionOnTarget { user_target: UserTarget::UserGroup(user_group.clone()), resource_target: target.clone(), permission: level.specifics(specific.clone()), }) .resolve(&WriteArgs { user: sync_user().to_owned(), }) .await { *has_error = true; log.push_str(&format!( "\n{}: failed to set permission in group {} | target: {target:?} | {:#}", colored("ERROR", Color::Red), bold(&user_group), e.error )) } else { log.push_str(&format!( "\n{}: {} user group '{}' permissions | {}: {target:?} | {}: {level} | {}: {}", muted("INFO"), colored("updated", Color::Blue), bold(&user_group), muted("target"), muted("level"), muted("specific"), specific.into_iter().map(|s| s.into()).collect::>().join(", ") )) } } } /// Expands any regex defined targets into the full list async fn expand_user_group_permissions( permissions: Vec, ) -> anyhow::Result> { let mut expanded = Vec::::with_capacity(permissions.capacity()); let all_resources = all_resources_cache().load(); for permission in permissions { let (variant, id) = permission.target.extract_variant_id(); if id.is_empty() { continue; } let matcher = Matcher::new(id)?; match variant { ResourceTargetVariant::Build => { let permissions = all_resources .builds .values() .filter(|resource| matcher.is_match(&resource.name)) .map(|resource| PermissionToml { target: ResourceTarget::Build(resource.name.clone()), level: permission.level, specific: permission.specific.clone(), }); expanded.extend(permissions); } ResourceTargetVariant::Builder => { let permissions = all_resources .builders .values() .filter(|resource| matcher.is_match(&resource.name)) .map(|resource| PermissionToml { target: ResourceTarget::Builder(resource.name.clone()), level: permission.level, specific: permission.specific.clone(), }); expanded.extend(permissions); } ResourceTargetVariant::Deployment => { let permissions = all_resources .deployments .values() .filter(|resource| matcher.is_match(&resource.name)) .map(|resource| PermissionToml { target: ResourceTarget::Deployment(resource.name.clone()), level: permission.level, specific: permission.specific.clone(), }); expanded.extend(permissions); } ResourceTargetVariant::Server => { let permissions = all_resources .servers .values() .filter(|resource| matcher.is_match(&resource.name)) .map(|resource| PermissionToml { target: ResourceTarget::Server(resource.name.clone()), level: permission.level, specific: permission.specific.clone(), }); expanded.extend(permissions); } ResourceTargetVariant::Repo => { let permissions = all_resources .repos .values() .filter(|resource| matcher.is_match(&resource.name)) .map(|resource| PermissionToml { target: ResourceTarget::Repo(resource.name.clone()), level: permission.level, specific: permission.specific.clone(), }); expanded.extend(permissions); } ResourceTargetVariant::Alerter => { let permissions = all_resources .alerters .values() .filter(|resource| matcher.is_match(&resource.name)) .map(|resource| PermissionToml { target: ResourceTarget::Alerter(resource.name.clone()), level: permission.level, specific: permission.specific.clone(), }); expanded.extend(permissions); } ResourceTargetVariant::Procedure => { let permissions = all_resources .procedures .values() .filter(|resource| matcher.is_match(&resource.name)) .map(|resource| PermissionToml { target: ResourceTarget::Procedure(resource.name.clone()), level: permission.level, specific: permission.specific.clone(), }); expanded.extend(permissions); } ResourceTargetVariant::Action => { let permissions = all_resources .actions .values() .filter(|resource| matcher.is_match(&resource.name)) .map(|resource| PermissionToml { target: ResourceTarget::Action(resource.name.clone()), level: permission.level, specific: permission.specific.clone(), }); expanded.extend(permissions); } ResourceTargetVariant::ResourceSync => { let permissions = all_resources .syncs .values() .filter(|resource| matcher.is_match(&resource.name)) .map(|resource| PermissionToml { target: ResourceTarget::ResourceSync( resource.name.clone(), ), level: permission.level, specific: permission.specific.clone(), }); expanded.extend(permissions); } ResourceTargetVariant::Stack => { let permissions = all_resources .stacks .values() .filter(|resource| matcher.is_match(&resource.name)) .map(|resource| PermissionToml { target: ResourceTarget::Stack(resource.name.clone()), level: permission.level, specific: permission.specific.clone(), }); expanded.extend(permissions); } ResourceTargetVariant::System => {} } } Ok(expanded) } type AllDiff = IndexMap< ResourceTargetVariant, (PermissionLevelAndSpecifics, PermissionLevelAndSpecifics), >; fn default_permission() -> &'static PermissionLevelAndSpecifics { static DEFAULT_PERMISSION: OnceLock = OnceLock::new(); DEFAULT_PERMISSION.get_or_init(Default::default) } /// diffs user_group.all fn diff_group_all( original: &IndexMap< ResourceTargetVariant, PermissionLevelAndSpecifics, >, incoming: &IndexMap< ResourceTargetVariant, PermissionLevelAndSpecifics, >, ) -> AllDiff { let mut to_update = IndexMap::new(); // need to compare both forward and backward because either hashmap could be sparse. // forward direction for (variant, permission) in incoming { let original_permission = original.get(variant).unwrap_or(default_permission()); if permission.level != original_permission.level || !specific_equal( &original_permission.specific, &permission.specific, ) { to_update.insert( *variant, (original_permission.clone(), permission.clone()), ); } } // backward direction for (variant, permission) in original { let incoming_permission = incoming.get(variant).unwrap_or(default_permission()); if permission.level != incoming_permission.level || !specific_equal( &incoming_permission.specific, &permission.specific, ) { to_update.insert( *variant, (permission.clone(), incoming_permission.clone()), ); } } to_update } fn specific_equal( a: &IndexSet, b: &IndexSet, ) -> bool { for item in a { if !b.contains(item) { return false; } } for item in b { if !a.contains(item) { return false; } } true } pub async fn convert_user_groups( user_groups: impl Iterator, res: &mut Vec<(String, UserGroupToml)>, ) -> anyhow::Result<()> { let db = db_client(); let usernames = find_collect(&db.users, None, None) .await? .into_iter() .map(|user| (user.id, user.username)) .collect::>(); let all = all_resources_cache().load(); for mut user_group in user_groups { user_group.all.retain(|_, p| { p.level > PermissionLevel::None || !p.specific.is_empty() }); // this method is admin only, but we already know user can see user group if above does not return Err let mut permissions = (ListUserTargetPermissions { user_target: UserTarget::UserGroup(user_group.id.clone()), }) .resolve(&ReadArgs { user: User { admin: true, ..Default::default() }, }) .await .map_err(|e| e.error)? .into_iter() .filter(|permission| permission.level > PermissionLevel::None) .map(|mut permission| { match &mut permission.resource_target { ResourceTarget::Build(id) => { *id = all .builds .get(id) .map(|r| r.name.clone()) .unwrap_or_default() } ResourceTarget::Builder(id) => { *id = all .builders .get(id) .map(|r| r.name.clone()) .unwrap_or_default() } ResourceTarget::Deployment(id) => { *id = all .deployments .get(id) .map(|r| r.name.clone()) .unwrap_or_default() } ResourceTarget::Server(id) => { *id = all .servers .get(id) .map(|r| r.name.clone()) .unwrap_or_default() } ResourceTarget::Repo(id) => { *id = all .repos .get(id) .map(|r| r.name.clone()) .unwrap_or_default() } ResourceTarget::Alerter(id) => { *id = all .alerters .get(id) .map(|r| r.name.clone()) .unwrap_or_default() } ResourceTarget::Procedure(id) => { *id = all .procedures .get(id) .map(|r| r.name.clone()) .unwrap_or_default() } ResourceTarget::Action(id) => { *id = all .actions .get(id) .map(|r| r.name.clone()) .unwrap_or_default() } ResourceTarget::ResourceSync(id) => { *id = all .syncs .get(id) .map(|r| r.name.clone()) .unwrap_or_default() } ResourceTarget::Stack(id) => { *id = all .stacks .get(id) .map(|r| r.name.clone()) .unwrap_or_default() } ResourceTarget::System(_) => {} } PermissionToml { target: permission.resource_target, level: permission.level, specific: permission.specific, } }) .collect::>(); let mut users = if user_group.everyone { Vec::new() } else { user_group .users .into_iter() .filter_map(|user_id| usernames.get(&user_id).cloned()) .collect::>() }; permissions.sort_by(sort_permissions); users.sort(); res.push(( user_group.id, UserGroupToml { name: user_group.name, everyone: user_group.everyone, all: user_group.all, users, permissions, }, )); } Ok(()) } ================================================ FILE: bin/core/src/sync/variables.rs ================================================ use std::collections::HashMap; use anyhow::Context; use database::mungos::find::find_collect; use formatting::{Color, bold, colored, muted}; use komodo_client::{ api::write::*, entities::{ sync::DiffData, update::Log, user::sync_user, variable::Variable, }, }; use resolver_api::Resolve; use crate::{api::write::WriteArgs, state::db_client}; use super::toml::TOML_PRETTY_OPTIONS; pub fn variable_to_toml( variable: &Variable, ) -> anyhow::Result { let inner = toml_pretty::to_string(variable, TOML_PRETTY_OPTIONS) .context("failed to serialize variable to toml")?; Ok(format!("[[variable]]\n{inner}")) } pub struct ToUpdateItem { pub variable: Variable, pub update_value: bool, pub update_description: bool, pub update_is_secret: bool, } pub async fn get_updates_for_view( variables: &[Variable], delete: bool, ) -> anyhow::Result> { let map = find_collect(&db_client().variables, None, None) .await .context("failed to query db for variables")? .into_iter() .map(|v| (v.name.clone(), v)) .collect::>(); let mut diffs = Vec::::new(); if delete { for variable in map.values() { if !variables.iter().any(|v| v.name == variable.name) { diffs.push(DiffData::Delete { current: variable_to_toml(variable)?, }); } } } for variable in variables { match map.get(&variable.name) { Some(original) => { if original.value == variable.value && original.description == variable.description { continue; } diffs.push(DiffData::Update { proposed: variable_to_toml(variable)?, current: variable_to_toml(original)?, }); } None => { diffs.push(DiffData::Create { name: variable.name.clone(), proposed: variable_to_toml(variable)?, }); } } } Ok(diffs) } pub async fn get_updates_for_execution( variables: Vec, delete: bool, ) -> anyhow::Result<(Vec, Vec, Vec)> { let map = find_collect(&db_client().variables, None, None) .await .context("failed to query db for variables")? .into_iter() .map(|v| (v.name.clone(), v)) .collect::>(); let mut to_create = Vec::::new(); let mut to_update = Vec::::new(); let mut to_delete = Vec::::new(); if delete { for variable in map.values() { if !variables.iter().any(|v| v.name == variable.name) { to_delete.push(variable.name.clone()); } } } for variable in variables { match map.get(&variable.name) { Some(original) => { let item = ToUpdateItem { update_value: original.value != variable.value, update_description: original.description != variable.description, update_is_secret: original.is_secret != variable.is_secret, variable, }; if !item.update_value && !item.update_description && !item.update_is_secret { continue; } to_update.push(item); } None => to_create.push(variable), } } Ok((to_create, to_update, to_delete)) } pub async fn run_updates( to_create: Vec, to_update: Vec, to_delete: Vec, ) -> Option { if to_create.is_empty() && to_update.is_empty() && to_delete.is_empty() { return None; } let mut has_error = false; let mut log = String::from("running updates on Variables"); for variable in to_create { if let Err(e) = (CreateVariable { name: variable.name.clone(), value: variable.value, description: variable.description, is_secret: variable.is_secret, }) .resolve(&WriteArgs { user: sync_user().to_owned(), }) .await { has_error = true; log.push_str(&format!( "\n{}: failed to create variable '{}' | {:#}", colored("ERROR", Color::Red), bold(&variable.name), e.error )); } else { log.push_str(&format!( "\n{}: {} variable '{}'", muted("INFO"), colored("created", Color::Green), bold(&variable.name) )) }; } for ToUpdateItem { variable, update_value, update_description, update_is_secret, } in to_update { if update_value { if let Err(e) = (UpdateVariableValue { name: variable.name.clone(), value: variable.value, }) .resolve(&WriteArgs { user: sync_user().to_owned(), }) .await { has_error = true; log.push_str(&format!( "\n{}: failed to update variable value for '{}' | {:#}", colored("ERROR", Color::Red), bold(&variable.name), e.error )) } else { log.push_str(&format!( "\n{}: {} variable '{}' value", muted("INFO"), colored("updated", Color::Blue), bold(&variable.name) )) }; } if update_description { if let Err(e) = (UpdateVariableDescription { name: variable.name.clone(), description: variable.description, }) .resolve(&WriteArgs { user: sync_user().to_owned(), }) .await { has_error = true; log.push_str(&format!( "\n{}: failed to update variable description for '{}' | {:#}", colored("ERROR", Color::Red), bold(&variable.name), e.error )) } else { log.push_str(&format!( "\n{}: {} variable '{}' description", muted("INFO"), colored("updated", Color::Blue), bold(&variable.name) )) }; } if update_is_secret { if let Err(e) = (UpdateVariableIsSecret { name: variable.name.clone(), is_secret: variable.is_secret, }) .resolve(&WriteArgs { user: sync_user().to_owned(), }) .await { has_error = true; log.push_str(&format!( "\n{}: failed to update variable is secret for '{}' | {:#}", colored("ERROR", Color::Red), bold(&variable.name), e.error, )) } else { log.push_str(&format!( "\n{}: {} variable '{}' is secret", muted("INFO"), colored("updated", Color::Blue), bold(&variable.name) )) }; } } for variable in to_delete { if let Err(e) = (DeleteVariable { name: variable.clone(), }) .resolve(&WriteArgs { user: sync_user().to_owned(), }) .await { has_error = true; log.push_str(&format!( "\n{}: failed to delete variable '{}' | {:#}", colored("ERROR", Color::Red), bold(&variable), e.error )) } else { log.push_str(&format!( "\n{}: {} variable '{}'", muted("INFO"), colored("deleted", Color::Red), bold(&variable) )) } } let stage = "Update Variables"; Some(if has_error { Log::error(stage, log) } else { Log::simple(stage, log) }) } ================================================ FILE: bin/core/src/sync/view.rs ================================================ use std::collections::HashMap; use anyhow::Context; use database::mungos::find::find_collect; use komodo_client::entities::{ ResourceTargetVariant, sync::{DiffData, ResourceDiff}, tag::Tag, toml::ResourceToml, }; use partial_derive2::MaybeNone; use super::ResourceSyncTrait; #[allow(clippy::too_many_arguments)] pub async fn push_updates_for_view( resources: Vec>, delete: bool, match_resource_type: Option, match_resources: Option<&[String]>, id_to_tags: &HashMap, match_tags: &[String], diffs: &mut Vec, ) -> anyhow::Result<()> { let current_map = find_collect(Resource::coll(), None, None) .await .context("failed to get resources from db")? .into_iter() .filter(|r| { Resource::include_resource( &r.name, &r.config, match_resource_type, match_resources, &r.tags, id_to_tags, match_tags, ) }) .map(|r| (r.name.clone(), r)) .collect::>(); let resources = resources .into_iter() .filter(|r| { Resource::include_resource_partial( &r.name, &r.config, match_resource_type, match_resources, &r.tags, id_to_tags, match_tags, ) }) .collect::>(); if delete { for current_resource in current_map.values() { if !resources.iter().any(|r| r.name == current_resource.name) { diffs.push(ResourceDiff { target: Resource::resource_target( current_resource.id.clone(), ), data: DiffData::Delete { current: super::toml::resource_to_toml::( current_resource.clone(), false, vec![], id_to_tags, )?, }, }); } } } for mut proposed_resource in resources { match current_map.get(&proposed_resource.name) { Some(current_resource) => { // First merge toml resource config (partial) onto default resource config. // Makes sure things that aren't defined in toml (come through as None) actually get removed. let propsed_config: Resource::Config = proposed_resource.config.into(); proposed_resource.config = propsed_config.into(); Resource::validate_partial_config( &mut proposed_resource.config, ); let proposed = super::toml::resource_toml_to_toml_string::< Resource, >(proposed_resource.clone())?; let mut diff = Resource::get_diff( current_resource.config.clone(), proposed_resource.config, )?; Resource::validate_diff(&mut diff); let current_tags = current_resource .tags .iter() .filter_map(|id| id_to_tags.get(id).map(|t| t.name.clone())) .collect::>(); // Only proceed if there are any fields to update, // or a change to tags / description if diff.is_none() && proposed_resource.description == current_resource.description && proposed_resource.tags == current_tags { continue; } diffs.push(ResourceDiff { target: Resource::resource_target( current_resource.id.clone(), ), data: DiffData::Update { current: super::toml::resource_to_toml::( current_resource.clone(), proposed_resource.deploy, proposed_resource.after, id_to_tags, )?, proposed, }, }); } None => { diffs.push(ResourceDiff { // resources to Create don't have ids yet. target: Resource::resource_target(String::new()), data: DiffData::Create { name: proposed_resource.name.clone(), proposed: super::toml::resource_toml_to_toml_string::< Resource, >(proposed_resource)?, }, }); } } } Ok(()) } ================================================ FILE: bin/core/src/ts_client.rs ================================================ use anyhow::{Context, anyhow}; use axum::{ Router, extract::Path, http::{HeaderMap, HeaderValue}, routing::get, }; use reqwest::StatusCode; use serde::Deserialize; use serror::AddStatusCodeError; use tokio::fs; use crate::config::core_config; pub fn router() -> Router { Router::new().route("/{path}", get(serve_client_file)) } const ALLOWED_FILES: &[&str] = &[ "lib.js", "lib.d.ts", "types.js", "types.d.ts", "responses.js", "responses.d.ts", "terminal.js", "terminal.d.ts", ]; #[derive(Deserialize)] struct FilePath { path: String, } #[axum::debug_handler] async fn serve_client_file( Path(FilePath { path }): Path, ) -> serror::Result<(HeaderMap, String)> { if !ALLOWED_FILES.contains(&path.as_str()) { return Err( anyhow!("File {path} not found.") .status_code(StatusCode::NOT_FOUND), ); } let contents = fs::read_to_string(format!( "{}/client/{path}", core_config().frontend_path )) .await .with_context(|| format!("Failed to read file: {path}"))?; let mut headers = HeaderMap::new(); if path.ends_with(".js") { headers.insert( "X-TypeScript-Types", HeaderValue::from_str(&format!( "/client/{}", path.replace(".js", ".d.ts") )) .context("?? Invalid Header Value")?, ); } Ok((headers, contents)) } ================================================ FILE: bin/core/src/ws/container.rs ================================================ use axum::{ extract::{Query, WebSocketUpgrade, ws::Message}, response::IntoResponse, }; use futures::SinkExt; use komodo_client::{ api::terminal::ConnectContainerExecQuery, entities::{permission::PermissionLevel, server::Server}, }; use crate::permission::get_check_permissions; #[instrument(name = "ConnectContainerExec", skip(ws))] pub async fn terminal( Query(ConnectContainerExecQuery { server, container, shell, }): Query, ws: WebSocketUpgrade, ) -> impl IntoResponse { ws.on_upgrade(|socket| async move { let Some((mut client_socket, user)) = super::ws_login(socket).await else { return; }; let server = match get_check_permissions::( &server, &user, PermissionLevel::Read.terminal(), ) .await { Ok(server) => server, Err(e) => { debug!("could not get server | {e:#}"); let _ = client_socket .send(Message::text(format!("ERROR: {e:#}"))) .await; let _ = client_socket.close().await; return; } }; super::handle_container_terminal( client_socket, &server, container, shell, ) .await }) } ================================================ FILE: bin/core/src/ws/deployment.rs ================================================ use axum::{ extract::{Query, WebSocketUpgrade, ws::Message}, response::IntoResponse, }; use futures::SinkExt; use komodo_client::{ api::terminal::ConnectDeploymentExecQuery, entities::{ deployment::Deployment, permission::PermissionLevel, server::Server, }, }; use crate::{permission::get_check_permissions, resource::get}; #[instrument(name = "ConnectDeploymentExec", skip(ws))] pub async fn terminal( Query(ConnectDeploymentExecQuery { deployment, shell }): Query< ConnectDeploymentExecQuery, >, ws: WebSocketUpgrade, ) -> impl IntoResponse { ws.on_upgrade(|socket| async move { let Some((mut client_socket, user)) = super::ws_login(socket).await else { return; }; let deployment = match get_check_permissions::( &deployment, &user, PermissionLevel::Read.terminal(), ) .await { Ok(deployment) => deployment, Err(e) => { debug!("could not get deployment | {e:#}"); let _ = client_socket .send(Message::text(format!("ERROR: {e:#}"))) .await; let _ = client_socket.close().await; return; } }; let server = match get::(&deployment.config.server_id).await { Ok(server) => server, Err(e) => { debug!("could not get server | {e:#}"); let _ = client_socket .send(Message::text(format!("ERROR: {e:#}"))) .await; let _ = client_socket.close().await; return; } }; super::handle_container_terminal( client_socket, &server, deployment.name, shell, ) .await }) } ================================================ FILE: bin/core/src/ws/mod.rs ================================================ use crate::{ auth::{auth_api_key_check_enabled, auth_jwt_check_enabled}, helpers::query::get_user, }; use anyhow::anyhow; use axum::{ Router, extract::ws::{CloseFrame, Message, Utf8Bytes, WebSocket}, routing::get, }; use futures::{SinkExt, StreamExt}; use komodo_client::{ entities::{server::Server, user::User}, ws::WsLoginMessage, }; use tokio::net::TcpStream; use tokio_tungstenite::{ MaybeTlsStream, WebSocketStream, tungstenite, }; use tokio_util::sync::CancellationToken; mod container; mod deployment; mod stack; mod terminal; mod update; pub fn router() -> Router { Router::new() .route("/update", get(update::handler)) .route("/terminal", get(terminal::handler)) .route("/container/terminal", get(container::terminal)) .route("/deployment/terminal", get(deployment::terminal)) .route("/stack/terminal", get(stack::terminal)) } #[instrument(level = "debug")] async fn ws_login( mut socket: WebSocket, ) -> Option<(WebSocket, User)> { let login_msg = match socket.recv().await { Some(Ok(Message::Text(login_msg))) => { LoginMessage::Ok(login_msg.to_string()) } Some(Ok(msg)) => { LoginMessage::Err(format!("invalid login message: {msg:?}")) } Some(Err(e)) => { LoginMessage::Err(format!("failed to get login message: {e:?}")) } None => { LoginMessage::Err("failed to get login message".to_string()) } }; let login_msg = match login_msg { LoginMessage::Ok(login_msg) => login_msg, LoginMessage::Err(msg) => { let _ = socket.send(Message::text(msg)).await; let _ = socket.close().await; return None; } }; match WsLoginMessage::from_json_str(&login_msg) { // Login using a jwt Ok(WsLoginMessage::Jwt { jwt }) => { match auth_jwt_check_enabled(&jwt).await { Ok(user) => { let _ = socket.send(Message::text("LOGGED_IN")).await; Some((socket, user)) } Err(e) => { let _ = socket .send(Message::text(format!( "failed to authenticate user using jwt | {e:#}" ))) .await; let _ = socket.close().await; None } } } // login using api keys Ok(WsLoginMessage::ApiKeys { key, secret }) => { match auth_api_key_check_enabled(&key, &secret).await { Ok(user) => { let _ = socket.send(Message::text("LOGGED_IN")).await; Some((socket, user)) } Err(e) => { let _ = socket .send(Message::text(format!( "failed to authenticate user using api keys | {e:#}" ))) .await; let _ = socket.close().await; None } } } Err(e) => { let _ = socket .send(Message::text(format!( "failed to parse login message: {e:#}" ))) .await; let _ = socket.close().await; None } } } enum LoginMessage { /// The text message Ok(String), /// The err message Err(String), } #[instrument(level = "debug")] async fn check_user_valid(user_id: &str) -> anyhow::Result { let user = get_user(user_id).await?; if !user.enabled { return Err(anyhow!("user not enabled")); } Ok(user) } async fn handle_container_terminal( mut client_socket: WebSocket, server: &Server, container: String, shell: String, ) { let periphery = match crate::helpers::periphery_client(server) { Ok(periphery) => periphery, Err(e) => { debug!("couldn't get periphery | {e:#}"); let _ = client_socket .send(Message::text(format!("ERROR: {e:#}"))) .await; let _ = client_socket.close().await; return; } }; trace!("connecting to periphery container exec websocket"); let periphery_socket = match periphery .connect_container_exec(container, shell) .await { Ok(ws) => ws, Err(e) => { debug!( "Failed connect to periphery container exec websocket | {e:#}" ); let _ = client_socket .send(Message::text(format!("ERROR: {e:#}"))) .await; let _ = client_socket.close().await; return; } }; trace!("connected to periphery container exec websocket"); core_periphery_forward_ws(client_socket, periphery_socket).await } async fn core_periphery_forward_ws( client_socket: axum::extract::ws::WebSocket, periphery_socket: WebSocketStream>, ) { let (mut periphery_send, mut periphery_receive) = periphery_socket.split(); let (mut core_send, mut core_receive) = client_socket.split(); let cancel = CancellationToken::new(); trace!("starting ws exchange"); let core_to_periphery = async { loop { let res = tokio::select! { res = core_receive.next() => res, _ = cancel.cancelled() => { trace!("core to periphery read: cancelled from inside"); break; } }; match res { Some(Ok(msg)) => { if let Err(e) = periphery_send.send(axum_to_tungstenite(msg)).await { debug!("Failed to send terminal message | {e:?}",); cancel.cancel(); break; }; } Some(Err(_e)) => { cancel.cancel(); break; } None => { cancel.cancel(); break; } } } }; let periphery_to_core = async { loop { let res = tokio::select! { res = periphery_receive.next() => res, _ = cancel.cancelled() => { trace!("periphery to core read: cancelled from inside"); break; } }; match res { Some(Ok(msg)) => { if let Err(e) = core_send.send(tungstenite_to_axum(msg)).await { debug!("{e:?}"); cancel.cancel(); break; }; } Some(Err(e)) => { let _ = core_send .send(Message::text(format!( "ERROR: Failed to receive message from periphery | {e:?}" ))) .await; cancel.cancel(); break; } None => { let _ = core_send.send(Message::text("STREAM EOF")).await; cancel.cancel(); break; } } } }; tokio::join!(core_to_periphery, periphery_to_core); } fn axum_to_tungstenite(msg: Message) -> tungstenite::Message { match msg { Message::Text(text) => tungstenite::Message::Text( // TODO: improve this conversion cost from axum ws library tungstenite::Utf8Bytes::from(text.to_string()), ), Message::Binary(bytes) => tungstenite::Message::Binary(bytes), Message::Ping(bytes) => tungstenite::Message::Ping(bytes), Message::Pong(bytes) => tungstenite::Message::Pong(bytes), Message::Close(close_frame) => { tungstenite::Message::Close(close_frame.map(|cf| { tungstenite::protocol::CloseFrame { code: cf.code.into(), reason: tungstenite::Utf8Bytes::from(cf.reason.to_string()), } })) } } } fn tungstenite_to_axum(msg: tungstenite::Message) -> Message { match msg { tungstenite::Message::Text(text) => { Message::Text(Utf8Bytes::from(text.to_string())) } tungstenite::Message::Binary(bytes) => Message::Binary(bytes), tungstenite::Message::Ping(bytes) => Message::Ping(bytes), tungstenite::Message::Pong(bytes) => Message::Pong(bytes), tungstenite::Message::Close(close_frame) => { Message::Close(close_frame.map(|cf| CloseFrame { code: cf.code.into(), reason: Utf8Bytes::from(cf.reason.to_string()), })) } tungstenite::Message::Frame(_) => { unreachable!() } } } ================================================ FILE: bin/core/src/ws/stack.rs ================================================ use axum::{ extract::{Query, WebSocketUpgrade, ws::Message}, response::IntoResponse, }; use futures::SinkExt; use komodo_client::{ api::terminal::ConnectStackExecQuery, entities::{ permission::PermissionLevel, server::Server, stack::Stack, }, }; use crate::{ permission::get_check_permissions, resource::get, state::stack_status_cache, }; #[instrument(name = "ConnectStackExec", skip(ws))] pub async fn terminal( Query(ConnectStackExecQuery { stack, service, shell, }): Query, ws: WebSocketUpgrade, ) -> impl IntoResponse { ws.on_upgrade(|socket| async move { let Some((mut client_socket, user)) = super::ws_login(socket).await else { return; }; let stack = match get_check_permissions::( &stack, &user, PermissionLevel::Read.terminal(), ) .await { Ok(stack) => stack, Err(e) => { debug!("could not get stack | {e:#}"); let _ = client_socket .send(Message::text(format!("ERROR: {e:#}"))) .await; let _ = client_socket.close().await; return; } }; let server = match get::(&stack.config.server_id).await { Ok(server) => server, Err(e) => { debug!("could not get server | {e:#}"); let _ = client_socket .send(Message::text(format!("ERROR: {e:#}"))) .await; let _ = client_socket.close().await; return; } }; let Some(status) = stack_status_cache().get(&stack.id).await else { debug!("could not get stack status"); let _ = client_socket .send(Message::text(String::from( "ERROR: could not get stack status", ))) .await; let _ = client_socket.close().await; return; }; let container = match status .curr .services .iter() .find(|s| s.service == service) .map(|s| s.container.as_ref()) { Some(Some(container)) => container.name.clone(), Some(None) => { let _ = client_socket .send(Message::text(format!( "ERROR: Service {service} container could not be found" ))) .await; let _ = client_socket.close().await; return; } None => { let _ = client_socket .send(Message::text(format!( "ERROR: Service {service} could not be found" ))) .await; let _ = client_socket.close().await; return; } }; super::handle_container_terminal( client_socket, &server, container, shell, ) .await }) } ================================================ FILE: bin/core/src/ws/terminal.rs ================================================ use axum::{ extract::{Query, WebSocketUpgrade, ws::Message}, response::IntoResponse, }; use futures::SinkExt; use komodo_client::{ api::terminal::ConnectTerminalQuery, entities::{permission::PermissionLevel, server::Server}, }; use crate::{ helpers::periphery_client, permission::get_check_permissions, ws::core_periphery_forward_ws, }; #[instrument(name = "ConnectTerminal", skip(ws))] pub async fn handler( Query(ConnectTerminalQuery { server, terminal }): Query< ConnectTerminalQuery, >, ws: WebSocketUpgrade, ) -> impl IntoResponse { ws.on_upgrade(|socket| async move { let Some((mut client_socket, user)) = super::ws_login(socket).await else { return; }; let server = match get_check_permissions::( &server, &user, PermissionLevel::Read.terminal(), ) .await { Ok(server) => server, Err(e) => { debug!("could not get server | {e:#}"); let _ = client_socket .send(Message::text(format!("ERROR: {e:#}"))) .await; let _ = client_socket.close().await; return; } }; let periphery = match periphery_client(&server) { Ok(periphery) => periphery, Err(e) => { debug!("couldn't get periphery | {e:#}"); let _ = client_socket .send(Message::text(format!("ERROR: {e:#}"))) .await; let _ = client_socket.close().await; return; } }; trace!("connecting to periphery terminal websocket"); let periphery_socket = match periphery.connect_terminal(terminal).await { Ok(ws) => ws, Err(e) => { debug!("Failed connect to periphery terminal | {e:#}"); let _ = client_socket .send(Message::text(format!("ERROR: {e:#}"))) .await; let _ = client_socket.close().await; return; } }; trace!("connected to periphery terminal websocket"); core_periphery_forward_ws(client_socket, periphery_socket).await }) } ================================================ FILE: bin/core/src/ws/update.rs ================================================ use anyhow::anyhow; use axum::{ extract::{WebSocketUpgrade, ws::Message}, response::IntoResponse, }; use futures::{SinkExt, StreamExt}; use komodo_client::entities::{ ResourceTarget, permission::PermissionLevel, user::User, }; use serde_json::json; use serror::serialize_error; use tokio::select; use tokio_util::sync::CancellationToken; use crate::helpers::{ channel::update_channel, query::get_user_permission_on_target, }; #[instrument(level = "debug")] pub async fn handler(ws: WebSocketUpgrade) -> impl IntoResponse { // get a reveiver for internal update messages. let mut receiver = update_channel().receiver.resubscribe(); // handle http -> ws updgrade ws.on_upgrade(|socket| async move { let Some((socket, user)) = super::ws_login(socket).await else { return }; let (mut ws_sender, mut ws_reciever) = socket.split(); let cancel = CancellationToken::new(); let cancel_clone = cancel.clone(); tokio::spawn(async move { loop { // poll for updates off the receiver / await cancel. let update = select! { _ = cancel_clone.cancelled() => break, update = receiver.recv() => {update.expect("failed to recv update msg")} }; // before sending every update, verify user is still valid. // kill the connection is user if found to be invalid. let user = super::check_user_valid(&user.id).await; let user = match user { Err(e) => { let _ = ws_sender .send(Message::text(json!({ "type": "INVALID_USER", "msg": serialize_error(&e) }).to_string())) .await; let _ = ws_sender.close().await; return; }, Ok(user) => user, }; // Only send if user has permission on the target resource. if user_can_see_update(&user, &update.target).await.is_ok() { let _ = ws_sender .send(Message::text(serde_json::to_string(&update).unwrap())) .await; } } }); // Handle messages from the client. // After login, only handles close message. while let Some(msg) = ws_reciever.next().await { match msg { Ok(msg) => { if let Message::Close(_) = msg { cancel.cancel(); return; } } Err(_) => { cancel.cancel(); return; } } } }) } #[instrument(level = "debug")] async fn user_can_see_update( user: &User, update_target: &ResourceTarget, ) -> anyhow::Result<()> { if user.admin { return Ok(()); } let permission = get_user_permission_on_target(user, update_target).await?; if permission.level > PermissionLevel::None { Ok(()) } else { Err(anyhow!( "user does not have permissions on {update_target:?}" )) } } ================================================ FILE: bin/core/starship.toml ================================================ ## This is used to customize the shell prompt in Periphery container for Terminals "$schema" = 'https://starship.rs/config-schema.json' add_newline = true format = "$time$hostname$container$memory_usage$all" [character] success_symbol = "[❯](bright-blue bold)" error_symbol = "[❯](bright-red bold)" [package] disabled = true [time] format = "[❯$time](white dimmed) " time_format = "%l:%M %p" utc_time_offset = '-5' disabled = true [username] format = "[❯ $user]($style) " style_user = "bright-green" show_always = true [hostname] format = "[❯ $hostname]($style) " style = "bright-blue" ssh_only = false [directory] format = "[❯ $path]($style)[$read_only]($read_only_style) " style = "bright-cyan" [git_branch] format = "[❯ $symbol$branch(:$remote_branch)]($style) " style = "bright-purple" [git_status] style = "bright-purple" [rust] format = "[❯ $symbol($version )]($style)" symbol = "rustc " style = "bright-red" [nodejs] format = "[❯ $symbol($version )]($style)" symbol = "nodejs " style = "bright-red" [memory_usage] format = "[❯ mem ${ram} ${ram_pct}]($style) " threshold = -1 style = "white" [cmd_duration] format = "[❯ $duration]($style)" style = "bright-yellow" [container] format = "[❯ 🦎 core container ]($style)" style = "bright-green" [aws] disabled = true ================================================ FILE: bin/periphery/Cargo.toml ================================================ [package] name = "komodo_periphery" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true homepage.workspace = true repository.workspace = true [[bin]] name = "periphery" path = "src/main.rs" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] # local komodo_client.workspace = true periphery_client.workspace = true environment_file.workspace = true environment.workspace = true interpolate.workspace = true formatting.workspace = true response.workspace = true command.workspace = true config.workspace = true logger.workspace = true cache.workspace = true git.workspace = true # mogh serror = { workspace = true, features = ["axum"] } async_timing_util.workspace = true derive_variants.workspace = true resolver_api.workspace = true run_command.workspace = true # external pin-project-lite.workspace = true tokio-stream.workspace = true portable-pty.workspace = true axum-server.workspace = true serde_json.workspace = true serde_yaml_ng.workspace = true tokio-util.workspace = true arc-swap.workspace = true colored.workspace = true futures.workspace = true tracing.workspace = true bollard.workspace = true sysinfo.workspace = true dotenvy.workspace = true anyhow.workspace = true rustls.workspace = true tokio.workspace = true serde.workspace = true bytes.workspace = true axum.workspace = true clap.workspace = true envy.workspace = true uuid.workspace = true rand.workspace = true shell-escape.workspace = true ================================================ FILE: bin/periphery/aio.Dockerfile ================================================ ## All in one, multi stage compile + runtime Docker build for your architecture. FROM rust:1.89.0-bullseye AS builder RUN cargo install cargo-strip WORKDIR /builder COPY Cargo.toml Cargo.lock ./ COPY ./lib ./lib COPY ./client/core/rs ./client/core/rs COPY ./client/periphery ./client/periphery COPY ./bin/periphery ./bin/periphery # Compile app RUN cargo build -p komodo_periphery --release && cargo strip # Final Image FROM debian:bullseye-slim COPY ./bin/periphery/starship.toml /starship.toml COPY ./bin/periphery/debian-deps.sh . RUN sh ./debian-deps.sh && rm ./debian-deps.sh COPY --from=builder /builder/target/release/periphery /usr/local/bin/periphery EXPOSE 8120 CMD [ "periphery" ] LABEL org.opencontainers.image.source=https://github.com/moghtech/komodo LABEL org.opencontainers.image.description="Komodo Periphery" LABEL org.opencontainers.image.licenses=GPL-3.0 ================================================ FILE: bin/periphery/debian-deps.sh ================================================ #!/bin/bash ## Periphery deps installer apt-get update apt-get install -y git curl wget ca-certificates install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc chmod a+r /etc/apt/keyrings/docker.asc echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ tee /etc/apt/sources.list.d/docker.list > /dev/null apt-get update # apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin apt-get install -y docker-ce-cli docker-buildx-plugin docker-compose-plugin rm -rf /var/lib/apt/lists/* # Starship prompt curl -sS https://starship.rs/install.sh | sh -s -- --yes --bin-dir /usr/local/bin echo 'export STARSHIP_CONFIG=/starship.toml' >> /root/.bashrc echo 'eval "$(starship init bash)"' >> /root/.bashrc ================================================ FILE: bin/periphery/multi-arch.Dockerfile ================================================ ## Assumes the latest binaries for x86_64 and aarch64 are already built (by binaries.Dockerfile). ## Sets up the necessary runtime container dependencies for Komodo Periphery. ## Since theres no heavy build here, QEMU multi-arch builds are fine for this image. ARG BINARIES_IMAGE=ghcr.io/moghtech/komodo-binaries:latest ARG X86_64_BINARIES=${BINARIES_IMAGE}-x86_64 ARG AARCH64_BINARIES=${BINARIES_IMAGE}-aarch64 # This is required to work with COPY --from FROM ${X86_64_BINARIES} AS x86_64 FROM ${AARCH64_BINARIES} AS aarch64 FROM debian:bullseye-slim COPY ./bin/periphery/starship.toml /starship.toml COPY ./bin/periphery/debian-deps.sh . RUN sh ./debian-deps.sh && rm ./debian-deps.sh WORKDIR /app ## Copy both binaries initially, but only keep appropriate one for the TARGETPLATFORM. COPY --from=x86_64 /periphery /app/arch/linux/amd64 COPY --from=aarch64 /periphery /app/arch/linux/arm64 ARG TARGETPLATFORM RUN mv /app/arch/${TARGETPLATFORM} /usr/local/bin/periphery && rm -r /app/arch EXPOSE 8120 CMD [ "periphery" ] LABEL org.opencontainers.image.source=https://github.com/moghtech/komodo LABEL org.opencontainers.image.description="Komodo Periphery" LABEL org.opencontainers.image.licenses=GPL-3.0 ================================================ FILE: bin/periphery/single-arch.Dockerfile ================================================ ## Assumes the latest binaries for the required arch are already built (by binaries.Dockerfile). ## Sets up the necessary runtime container dependencies for Komodo Periphery. ARG BINARIES_IMAGE=ghcr.io/moghtech/komodo-binaries:latest # This is required to work with COPY --from FROM ${BINARIES_IMAGE} AS binaries FROM debian:bullseye-slim COPY ./bin/periphery/starship.toml /starship.toml COPY ./bin/periphery/debian-deps.sh . RUN sh ./debian-deps.sh && rm ./debian-deps.sh COPY --from=binaries /periphery /usr/local/bin/periphery EXPOSE 8120 CMD [ "periphery" ] LABEL org.opencontainers.image.source=https://github.com/moghtech/komodo LABEL org.opencontainers.image.description="Komodo Periphery" LABEL org.opencontainers.image.licenses=GPL-3.0 ================================================ FILE: bin/periphery/src/api/build.rs ================================================ use std::{ collections::{HashMap, HashSet}, path::PathBuf, }; use anyhow::{Context, anyhow}; use command::{ run_komodo_command, run_komodo_command_with_sanitization, }; use formatting::format_serror; use interpolate::Interpolator; use komodo_client::entities::{ EnvironmentVar, all_logs_success, build::{Build, BuildConfig}, environment_vars_from_str, optional_string, to_path_compatible_name, update::Log, }; use periphery_client::api::build::{ self, GetDockerfileContentsOnHost, GetDockerfileContentsOnHostResponse, PruneBuilders, PruneBuildx, WriteDockerfileContentsToHost, }; use resolver_api::Resolve; use tokio::fs; use crate::{ build::{parse_build_args, parse_secret_args, write_dockerfile}, config::periphery_config, docker::docker_login, helpers::{parse_extra_args, parse_labels}, }; impl Resolve for GetDockerfileContentsOnHost { #[instrument(name = "GetDockerfileContentsOnHost", level = "debug")] async fn resolve( self, _: &super::Args, ) -> serror::Result { let GetDockerfileContentsOnHost { name, build_path, dockerfile_path, } = self; let root = periphery_config() .build_dir() .join(to_path_compatible_name(&name)); let build_dir = root.join(&build_path).components().collect::(); if !build_dir.exists() { fs::create_dir_all(&build_dir) .await .context("Failed to initialize build directory")?; } let full_path = build_dir .join(&dockerfile_path) .components() .collect::(); let contents = fs::read_to_string(&full_path).await.with_context(|| { format!("Failed to read dockerfile contents at {full_path:?}") })?; Ok(GetDockerfileContentsOnHostResponse { contents, path: full_path.display().to_string(), }) } } impl Resolve for WriteDockerfileContentsToHost { #[instrument( name = "WriteDockerfileContentsToHost", skip_all, fields( stack = &self.name, build_path = &self.build_path, dockerfile_path = &self.dockerfile_path, ) )] async fn resolve(self, _: &super::Args) -> serror::Result { let WriteDockerfileContentsToHost { name, build_path, dockerfile_path, contents, } = self; let full_path = periphery_config() .build_dir() .join(to_path_compatible_name(&name)) .join(&build_path) .join(dockerfile_path) .components() .collect::(); // Ensure parent directory exists if let Some(parent) = full_path.parent() && !parent.exists() { tokio::fs::create_dir_all(parent) .await .with_context(|| format!("Failed to initialize dockerfile parent directory {parent:?}"))?; } fs::write(&full_path, contents).await.with_context(|| { format!("Failed to write dockerfile contents to {full_path:?}") })?; Ok(Log::simple( "Write dockerfile to host", format!("dockerfile contents written to {full_path:?}"), )) } } impl Resolve for build::Build { #[instrument(name = "Build", skip_all, fields(build = self.build.name.to_string()))] async fn resolve( self, _: &super::Args, ) -> serror::Result> { let build::Build { mut build, repo: linked_repo, registry_tokens, mut replacers, commit_hash, additional_tags, } = self; let mut logs = Vec::new(); // Periphery side interpolation let mut interpolator = Interpolator::new(None, &periphery_config().secrets); interpolator .interpolate_build(&mut build)? .push_logs(&mut logs); replacers.extend(interpolator.secret_replacers); let Build { name, config: BuildConfig { build_path, dockerfile_path, build_args, secret_args, labels, extra_args, use_buildx, image_registry, repo, files_on_host, dockerfile, pre_build, .. }, .. } = &build; if !*files_on_host && repo.is_empty() && linked_repo.is_none() && dockerfile.is_empty() { return Err(anyhow!("Build must be files on host mode, have a repo attached, or have dockerfile contents set to build").into()); } let registry_tokens = registry_tokens .iter() .map(|(domain, account, token)| { ((domain.as_str(), account.as_str()), token.as_str()) }) .collect::>(); // Maybe docker login let mut should_push = false; for (domain, account) in image_registry .iter() .map(|r| (r.domain.as_str(), r.account.as_str())) // This ensures uniqueness / prevents redundant logins .collect::>() { match docker_login( domain, account, registry_tokens.get(&(domain, account)).copied(), ) .await { Ok(logged_in) if logged_in => should_push = true, Ok(_) => {} Err(e) => { logs.push(Log::error( "Docker Login", format_serror( &e.context("failed to login to docker registry").into(), ), )); return Ok(logs); } }; } let build_path = if let Some(repo) = &linked_repo { periphery_config() .repo_dir() .join(to_path_compatible_name(&repo.name)) .join(build_path) } else { periphery_config() .build_dir() .join(to_path_compatible_name(name)) .join(build_path) } .components() .collect::(); let dockerfile_path = optional_string(dockerfile_path) .unwrap_or("Dockerfile".to_owned()); // Write UI defined Dockerfile to host if !*files_on_host && repo.is_empty() && linked_repo.is_none() && !dockerfile.is_empty() { write_dockerfile( &build_path, &dockerfile_path, dockerfile, &mut logs, ) .await; if !all_logs_success(&logs) { return Ok(logs); } }; // Pre Build if !pre_build.is_none() { let pre_build_path = build_path.join(&pre_build.path); if let Some(log) = run_komodo_command_with_sanitization( "Pre Build", pre_build_path.as_path(), &pre_build.command, true, &replacers, ) .await { let success = log.success; logs.push(log); if !success { return Ok(logs); } } } // Get command parts // Add VERSION to build args (if not already there) let mut build_args = environment_vars_from_str(build_args) .context("Invalid build_args")?; if !build_args.iter().any(|a| a.variable == "VERSION") { build_args.push(EnvironmentVar { variable: String::from("VERSION"), value: build.config.version.to_string(), }); } let build_args = parse_build_args(&build_args); let secret_args = environment_vars_from_str(secret_args) .context("Invalid secret_args")?; let command_secret_args = parse_secret_args(&secret_args, &build_path).await?; let labels = parse_labels( &environment_vars_from_str(labels).context("Invalid labels")?, ); let extra_args = parse_extra_args(extra_args); let buildx = if *use_buildx { " buildx" } else { "" }; let image_tags = build .get_image_tags_as_arg(commit_hash.as_deref(), &additional_tags) .context("Failed to parse image tags into command")?; let maybe_push = if should_push { " --push" } else { "" }; // Construct command let command = format!( "docker{buildx} build{build_args}{command_secret_args}{extra_args}{labels}{image_tags}{maybe_push} -f {dockerfile_path} .", ); if let Some(build_log) = run_komodo_command_with_sanitization( "Docker Build", build_path.as_ref(), command, false, &replacers, ) .await { logs.push(build_log); }; Ok(logs) } } // impl Resolve for PruneBuilders { #[instrument(name = "PruneBuilders", skip_all)] async fn resolve(self, _: &super::Args) -> serror::Result { let command = String::from("docker builder prune -a -f"); Ok(run_komodo_command("Prune Builders", None, command).await) } } // impl Resolve for PruneBuildx { #[instrument(name = "PruneBuildx", skip_all)] async fn resolve(self, _: &super::Args) -> serror::Result { let command = String::from("docker buildx prune -a -f"); Ok(run_komodo_command("Prune Buildx", None, command).await) } } ================================================ FILE: bin/periphery/src/api/compose.rs ================================================ use anyhow::{Context, anyhow}; use command::{ run_komodo_command, run_komodo_command_with_sanitization, }; use formatting::format_serror; use git::write_commit_file; use interpolate::Interpolator; use komodo_client::entities::{ FileContents, RepoExecutionResponse, all_logs_success, stack::{ ComposeFile, ComposeProject, ComposeService, ComposeServiceDeploy, StackRemoteFileContents, StackServiceNames, }, to_path_compatible_name, update::Log, }; use periphery_client::api::compose::*; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use shell_escape::unix::escape; use std::{borrow::Cow, path::PathBuf}; use tokio::fs; use crate::{ compose::{ docker_compose, env_file_args, pull_or_clone_stack, up::{maybe_login_registry, validate_files}, write::write_stack, }, config::periphery_config, helpers::{log_grep, parse_extra_args}, }; impl Resolve for ListComposeProjects { #[instrument(name = "ComposeInfo", level = "debug", skip_all)] async fn resolve( self, _: &super::Args, ) -> serror::Result> { let docker_compose = docker_compose(); let res = run_komodo_command( "List Projects", None, format!("{docker_compose} ls --all --format json"), ) .await; if !res.success { return Err( anyhow!("{}", res.combined()) .context(format!( "failed to list compose projects using {docker_compose} ls" )) .into(), ); } let res = serde_json::from_str::>(&res.stdout) .with_context(|| res.stdout.clone()) .with_context(|| { format!( "failed to parse '{docker_compose} ls' response to json" ) })? .into_iter() .filter(|item| !item.name.is_empty()) .map(|item| ComposeProject { name: item.name, status: item.status, compose_files: item .config_files .split(',') .map(str::to_string) .collect(), }) .collect(); Ok(res) } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DockerComposeLsItem { #[serde(default, alias = "Name")] pub name: String, #[serde(alias = "Status")] pub status: Option, /// Comma seperated list of paths #[serde(default, alias = "ConfigFiles")] pub config_files: String, } // impl Resolve for GetComposeLog { #[instrument(name = "GetComposeLog", level = "debug")] async fn resolve(self, _: &super::Args) -> serror::Result { let GetComposeLog { project, services, tail, timestamps, } = self; let docker_compose = docker_compose(); let timestamps = if timestamps { " --timestamps" } else { Default::default() }; let command = format!( "{docker_compose} -p {project} logs --tail {tail}{timestamps} {}", services.join(" ") ); Ok(run_komodo_command("get stack log", None, command).await) } } impl Resolve for GetComposeLogSearch { #[instrument(name = "GetComposeLogSearch", level = "debug")] async fn resolve(self, _: &super::Args) -> serror::Result { let GetComposeLogSearch { project, services, terms, combinator, invert, timestamps, } = self; let docker_compose = docker_compose(); let grep = log_grep(&terms, combinator, invert); let timestamps = if timestamps { " --timestamps" } else { Default::default() }; let command = format!( "{docker_compose} -p {project} logs --tail 5000{timestamps} {} 2>&1 | {grep}", services.join(" ") ); Ok(run_komodo_command("Get stack log grep", None, command).await) } } // impl Resolve for GetComposeContentsOnHost { #[instrument(name = "GetComposeContentsOnHost", level = "debug")] async fn resolve( self, _: &super::Args, ) -> serror::Result { let GetComposeContentsOnHost { name, run_directory, file_paths, } = self; let root = periphery_config() .stack_dir() .join(to_path_compatible_name(&name)); let run_directory = root.join(&run_directory).components().collect::(); if !run_directory.exists() { fs::create_dir_all(&run_directory) .await .context("Failed to initialize run directory")?; } let mut res = GetComposeContentsOnHostResponse::default(); for file in file_paths { let full_path = run_directory .join(&file.path) .components() .collect::(); match fs::read_to_string(&full_path).await.with_context(|| { format!( "Failed to read compose file contents at {full_path:?}" ) }) { Ok(contents) => { // The path we store here has to be the same as incoming file path in the array, // in order for WriteComposeContentsToHost to write to the correct path. res.contents.push(StackRemoteFileContents { path: file.path, contents, services: file.services, requires: file.requires, }); } Err(e) => { res.errors.push(FileContents { path: file.path, contents: format_serror(&e.into()), }); } } } Ok(res) } } // impl Resolve for WriteComposeContentsToHost { #[instrument( name = "WriteComposeContentsToHost", skip_all, fields( stack = &self.name, run_directory = &self.run_directory, file_path = &self.file_path, ) )] async fn resolve(self, _: &super::Args) -> serror::Result { let WriteComposeContentsToHost { name, run_directory, file_path, contents, } = self; let file_path = periphery_config() .stack_dir() .join(to_path_compatible_name(&name)) .join(&run_directory) .join(file_path) .components() .collect::(); // Ensure parent directory exists if let Some(parent) = file_path.parent() { fs::create_dir_all(&parent) .await .with_context(|| format!("Failed to initialize compose file parent directory {parent:?}"))?; } fs::write(&file_path, contents).await.with_context(|| { format!( "Failed to write compose file contents to {file_path:?}" ) })?; Ok(Log::simple( "Write contents to host", format!("File contents written to {file_path:?}"), )) } } // impl Resolve for WriteCommitComposeContents { #[instrument( name = "WriteCommitComposeContents", skip_all, fields( stack = &self.stack.name, username = &self.username, file_path = &self.file_path, ) )] async fn resolve( self, _: &super::Args, ) -> serror::Result { let WriteCommitComposeContents { stack, repo, username, file_path, contents, git_token, } = self; let root = pull_or_clone_stack(&stack, repo.as_ref(), git_token).await?; let file_path = stack .config .run_directory .parse::() .context("Run directory is not a valid path")? .join(&file_path); let msg = if let Some(username) = username { format!("{username}: Write Compose File") } else { "Write Compose File".to_string() }; write_commit_file( &msg, &root, &file_path, &contents, &stack.config.branch, ) .await .map_err(Into::into) } } // impl Resolve for ComposePull { #[instrument( name = "ComposePull", skip_all, fields( stack = &self.stack.name, services = format!("{:?}", self.services), ) )] async fn resolve( self, _: &super::Args, ) -> serror::Result { let ComposePull { mut stack, repo, services, git_token, registry_token, mut replacers, } = self; let mut res = ComposePullResponse::default(); let mut interpolator = Interpolator::new(None, &periphery_config().secrets); // Only interpolate Stack. Repo interpolation will be handled // by the CloneRepo / PullOrCloneRepo call. interpolator .interpolate_stack(&mut stack)? .push_logs(&mut res.logs); replacers.extend(interpolator.secret_replacers); let (run_directory, env_file_path) = match write_stack( &stack, repo.as_ref(), git_token, replacers.clone(), &mut res, ) .await { Ok(res) => res, Err(e) => { res .logs .push(Log::error("Write Stack", format_serror(&e.into()))); return Ok(res); } }; // Canonicalize the path to ensure it exists, and is the cleanest path to the run directory. let run_directory = run_directory.canonicalize().context( "Failed to validate run directory on host after stack write (canonicalize error)", )?; let file_paths = stack .all_file_paths() .into_iter() .map(|path| { ( // This will remove any intermediate uneeded '/./' in the path run_directory.join(&path).components().collect::(), path, ) }) .collect::>(); // Validate files for (full_path, path) in &file_paths { if !full_path.exists() { return Err(anyhow!("Missing compose file at {path}").into()); } } maybe_login_registry(&stack, registry_token, &mut res.logs).await; if !all_logs_success(&res.logs) { return Ok(res); } let docker_compose = docker_compose(); let service_args = if services.is_empty() { String::new() } else { format!(" {}", services.join(" ")) }; let file_args = stack.compose_file_paths().join(" -f "); let env_file_args = env_file_args( env_file_path, &stack.config.additional_env_files, )?; let project_name = stack.project_name(false); let log = run_komodo_command( "Compose Pull", run_directory.as_ref(), format!( "{docker_compose} -p {project_name} -f {file_args}{env_file_args} pull{service_args}", ), ) .await; res.logs.push(log); Ok(res) } } // impl Resolve for ComposeUp { #[instrument( name = "ComposeUp", skip_all, fields( stack = &self.stack.name, services = format!("{:?}", self.services), ) )] async fn resolve( self, _: &super::Args, ) -> serror::Result { let ComposeUp { mut stack, repo, services, git_token, registry_token, mut replacers, } = self; let mut res = ComposeUpResponse::default(); let mut interpolator = Interpolator::new(None, &periphery_config().secrets); // Only interpolate Stack. Repo interpolation will be handled // by the CloneRepo / PullOrCloneRepo call. interpolator .interpolate_stack(&mut stack)? .push_logs(&mut res.logs); replacers.extend(interpolator.secret_replacers); let (run_directory, env_file_path) = match write_stack( &stack, repo.as_ref(), git_token, replacers.clone(), &mut res, ) .await { Ok(res) => res, Err(e) => { res .logs .push(Log::error("Write Stack", format_serror(&e.into()))); return Ok(res); } }; // Canonicalize the path to ensure it exists, and is the cleanest path to the run directory. let run_directory = run_directory.canonicalize().context( "Failed to validate run directory on host after stack write (canonicalize error)", )?; validate_files(&stack, &run_directory, &mut res).await; if !all_logs_success(&res.logs) { return Ok(res); } maybe_login_registry(&stack, registry_token, &mut res.logs).await; if !all_logs_success(&res.logs) { return Ok(res); } // Pre deploy if !stack.config.pre_deploy.is_none() { let pre_deploy_path = run_directory.join(&stack.config.pre_deploy.path); if let Some(log) = run_komodo_command_with_sanitization( "Pre Deploy", pre_deploy_path.as_path(), &stack.config.pre_deploy.command, true, &replacers, ) .await { res.logs.push(log); if !all_logs_success(&res.logs) { return Ok(res); } }; } let docker_compose = docker_compose(); let service_args = if services.is_empty() { String::new() } else { format!(" {}", services.join(" ")) }; let file_args = stack.compose_file_paths().join(" -f "); // This will be the last project name, which is the one that needs to be destroyed. // Might be different from the current project name, if user renames stack / changes to custom project name. let last_project_name = stack.project_name(false); let project_name = stack.project_name(true); let env_file_args = env_file_args( env_file_path, &stack.config.additional_env_files, )?; // Uses 'docker compose config' command to extract services (including image) // after performing interpolation { let command = format!( "{docker_compose} -p {project_name} -f {file_args}{env_file_args} config", ); let Some(config_log) = run_komodo_command_with_sanitization( "Compose Config", run_directory.as_path(), command, false, &replacers, ) .await else { // Only reachable if command is empty, // not the case since it is provided above. unreachable!() }; if !config_log.success { res.logs.push(config_log); return Ok(res); } let compose = serde_yaml_ng::from_str::(&config_log.stdout) .context("Failed to parse compose contents")?; // Record sanitized compose config output res.compose_config = Some(config_log.stdout); for ( service_name, ComposeService { container_name, deploy, image, }, ) in compose.services { let image = image.unwrap_or_default(); match deploy { Some(ComposeServiceDeploy { replicas: Some(replicas), }) if replicas > 1 => { for i in 1..1 + replicas { res.services.push(StackServiceNames { container_name: format!( "{project_name}-{service_name}-{i}" ), service_name: format!("{service_name}-{i}"), image: image.clone(), }); } } _ => { res.services.push(StackServiceNames { container_name: container_name.unwrap_or_else(|| { format!("{project_name}-{service_name}") }), service_name, image, }); } } } } if stack.config.run_build { let build_extra_args = parse_extra_args(&stack.config.build_extra_args); let command = format!( "{docker_compose} -p {project_name} -f {file_args}{env_file_args} build{build_extra_args}{service_args}", ); let Some(log) = run_komodo_command_with_sanitization( "Compose Build", run_directory.as_path(), command, false, &replacers, ) .await else { unreachable!() }; res.logs.push(log); if !all_logs_success(&res.logs) { return Ok(res); } } // Pull images before deploying if stack.config.auto_pull { // Pull images before destroying to minimize downtime. // If this fails, do not continue. let command = format!( "{docker_compose} -p {project_name} -f {file_args}{env_file_args} pull{service_args}", ); let log = run_komodo_command( "Compose Pull", run_directory.as_ref(), command, ) .await; res.logs.push(log); if !all_logs_success(&res.logs) { return Ok(res); } } if stack.config.destroy_before_deploy // Also check if project name changed, which also requires taking down. || last_project_name != project_name { // Take down the existing containers. // This one tries to use the previously deployed service name, to ensure the right stack is taken down. crate::compose::down(&last_project_name, &services, &mut res) .await .context("failed to destroy existing containers")?; } // Run compose up let extra_args = parse_extra_args(&stack.config.extra_args); let command = format!( "{docker_compose} -p {project_name} -f {file_args}{env_file_args} up -d{extra_args}{service_args}", ); let Some(log) = run_komodo_command_with_sanitization( "Compose Up", run_directory.as_path(), command, false, &replacers, ) .await else { unreachable!() }; res.deployed = log.success; res.logs.push(log); if res.deployed && !stack.config.post_deploy.is_none() { let post_deploy_path = run_directory.join(&stack.config.post_deploy.path); if let Some(log) = run_komodo_command_with_sanitization( "Post Deploy", post_deploy_path.as_path(), &stack.config.post_deploy.command, true, &replacers, ) .await { res.logs.push(log); }; } Ok(res) } } // impl Resolve for ComposeExecution { #[instrument(name = "ComposeExecution")] async fn resolve(self, _: &super::Args) -> serror::Result { let ComposeExecution { project, command } = self; let docker_compose = docker_compose(); let log = run_komodo_command( "Compose Command", None, format!("{docker_compose} -p {project} {command}"), ) .await; Ok(log) } } // impl Resolve for ComposeRun { #[instrument(name = "ComposeRun", level = "debug", skip_all, fields(stack = &self.stack.name, service = &self.service))] async fn resolve(self, _: &super::Args) -> serror::Result { let ComposeRun { mut stack, repo, git_token, registry_token, mut replacers, service, command, no_tty, no_deps, detach, service_ports, env, workdir, user, entrypoint, pull, } = self; let mut interpolator = Interpolator::new(None, &periphery_config().secrets); interpolator .interpolate_stack(&mut stack)? .push_logs(&mut Vec::new()); replacers.extend(interpolator.secret_replacers); let mut res = ComposeRunResponse::default(); let (run_directory, env_file_path) = match write_stack( &stack, repo.as_ref(), git_token, replacers.clone(), &mut res, ) .await { Ok(res) => res, Err(e) => { return Ok(Log::error( "Write Stack", format_serror(&e.into()), )); } }; let run_directory = run_directory.canonicalize().context( "Failed to validate run directory on host after stack write (canonicalize error)", )?; maybe_login_registry(&stack, registry_token, &mut Vec::new()) .await; let docker_compose = docker_compose(); let file_args = if stack.config.file_paths.is_empty() { String::from("compose.yaml") } else { stack.config.file_paths.join(" -f ") }; let env_file_args = env_file_args( env_file_path, &stack.config.additional_env_files, )?; let project_name = stack.project_name(true); if pull.unwrap_or_default() { let pull_log = run_komodo_command( "Compose Pull", run_directory.as_ref(), format!( "{docker_compose} -p {project_name} -f {file_args}{env_file_args} pull {service}", ), ) .await; if !pull_log.success { return Ok(pull_log); } } let mut run_flags = String::from(" --rm"); if detach.unwrap_or_default() { run_flags.push_str(" -d"); } if no_tty.unwrap_or_default() { run_flags.push_str(" --no-tty"); } if no_deps.unwrap_or_default() { run_flags.push_str(" --no-deps"); } if service_ports.unwrap_or_default() { run_flags.push_str(" --service-ports"); } if let Some(dir) = workdir.as_ref() { run_flags.push_str(&format!(" --workdir {dir}")); } if let Some(user) = user.as_ref() { run_flags.push_str(&format!(" --user {user}")); } if let Some(entrypoint) = entrypoint.as_ref() { run_flags.push_str(&format!(" --entrypoint {entrypoint}")); } if let Some(env) = env { for (k, v) in env { run_flags.push_str(&format!(" -e {}={} ", k, v)); } } let command_args = command .as_ref() .filter(|v| !v.is_empty()) .map(|argv| { let joined = argv .iter() .map(|s| escape(Cow::Borrowed(s)).into_owned()) .collect::>() .join(" "); format!(" {joined}") }) .unwrap_or_default(); let command = format!( "{docker_compose} -p {project_name} -f {file_args}{env_file_args} run{run_flags} {service}{command_args}", ); let Some(log) = run_komodo_command_with_sanitization( "Compose Run", run_directory.as_path(), command, false, &replacers, ) .await else { unreachable!() }; Ok(log) } } ================================================ FILE: bin/periphery/src/api/container.rs ================================================ use anyhow::Context; use command::run_komodo_command; use futures::future::join_all; use komodo_client::entities::{ docker::{ container::{Container, ContainerListItem, ContainerStats}, stats::FullContainerStats, }, update::Log, }; use periphery_client::api::container::*; use resolver_api::Resolve; use crate::{ docker::{ docker_client, stats::get_container_stats, stop_container_command, }, helpers::log_grep, }; // ====== // READ // ====== // impl Resolve for InspectContainer { #[instrument(name = "InspectContainer", level = "debug")] async fn resolve( self, _: &super::Args, ) -> serror::Result { Ok(docker_client().inspect_container(&self.name).await?) } } // impl Resolve for GetContainerLog { #[instrument(name = "GetContainerLog", level = "debug")] async fn resolve(self, _: &super::Args) -> serror::Result { let GetContainerLog { name, tail, timestamps, } = self; let timestamps = if timestamps { " --timestamps" } else { Default::default() }; let command = format!("docker logs {name} --tail {tail}{timestamps}"); Ok(run_komodo_command("Get container log", None, command).await) } } // impl Resolve for GetContainerLogSearch { #[instrument(name = "GetContainerLogSearch", level = "debug")] async fn resolve(self, _: &super::Args) -> serror::Result { let GetContainerLogSearch { name, terms, combinator, invert, timestamps, } = self; let grep = log_grep(&terms, combinator, invert); let timestamps = if timestamps { " --timestamps" } else { Default::default() }; let command = format!( "docker logs {name} --tail 5000{timestamps} 2>&1 | {grep}" ); Ok( run_komodo_command("Get container log grep", None, command) .await, ) } } // impl Resolve for GetContainerStats { #[instrument(name = "GetContainerStats", level = "debug")] async fn resolve( self, _: &super::Args, ) -> serror::Result { let mut stats = get_container_stats(Some(self.name)).await?; let stats = stats.pop().context("No stats found for container")?; Ok(stats) } } // impl Resolve for GetFullContainerStats { #[instrument(name = "GetFullContainerStats", level = "debug")] async fn resolve( self, _: &super::Args, ) -> serror::Result { docker_client() .full_container_stats(&self.name) .await .map_err(Into::into) } } // impl Resolve for GetContainerStatsList { #[instrument(name = "GetContainerStatsList", level = "debug")] async fn resolve( self, _: &super::Args, ) -> serror::Result> { Ok(get_container_stats(None).await?) } } // ========= // ACTIONS // ========= impl Resolve for StartContainer { #[instrument(name = "StartContainer")] async fn resolve(self, _: &super::Args) -> serror::Result { Ok( run_komodo_command( "Docker Start", None, format!("docker start {}", self.name), ) .await, ) } } // impl Resolve for RestartContainer { #[instrument(name = "RestartContainer")] async fn resolve(self, _: &super::Args) -> serror::Result { Ok( run_komodo_command( "Docker Restart", None, format!("docker restart {}", self.name), ) .await, ) } } // impl Resolve for PauseContainer { #[instrument(name = "PauseContainer")] async fn resolve(self, _: &super::Args) -> serror::Result { Ok( run_komodo_command( "Docker Pause", None, format!("docker pause {}", self.name), ) .await, ) } } impl Resolve for UnpauseContainer { #[instrument(name = "UnpauseContainer")] async fn resolve(self, _: &super::Args) -> serror::Result { Ok( run_komodo_command( "Docker Unpause", None, format!("docker unpause {}", self.name), ) .await, ) } } // impl Resolve for StopContainer { #[instrument(name = "StopContainer")] async fn resolve(self, _: &super::Args) -> serror::Result { let StopContainer { name, signal, time } = self; let command = stop_container_command(&name, signal, time); let log = run_komodo_command("Docker Stop", None, command).await; if log.stderr.contains("unknown flag: --signal") { let command = stop_container_command(&name, None, time); let mut log = run_komodo_command("Docker Stop", None, command).await; log.stderr = format!( "old docker version: unable to use --signal flag{}", if !log.stderr.is_empty() { format!("\n\n{}", log.stderr) } else { String::new() } ); Ok(log) } else { Ok(log) } } } // impl Resolve for RemoveContainer { #[instrument(name = "RemoveContainer")] async fn resolve(self, _: &super::Args) -> serror::Result { let RemoveContainer { name, signal, time } = self; let stop_command = stop_container_command(&name, signal, time); let command = format!("{stop_command} && docker container rm {name}"); let log = run_komodo_command("Docker Stop and Remove", None, command) .await; if log.stderr.contains("unknown flag: --signal") { let stop_command = stop_container_command(&name, None, time); let command = format!("{stop_command} && docker container rm {name}"); let mut log = run_komodo_command("Docker Stop and Remove", None, command) .await; log.stderr = format!( "Old docker version: unable to use --signal flag{}", if !log.stderr.is_empty() { format!("\n\n{}", log.stderr) } else { String::new() } ); Ok(log) } else { Ok(log) } } } // impl Resolve for RenameContainer { #[instrument(name = "RenameContainer")] async fn resolve(self, _: &super::Args) -> serror::Result { let RenameContainer { curr_name, new_name, } = self; let command = format!("docker rename {curr_name} {new_name}"); Ok(run_komodo_command("Docker Rename", None, command).await) } } // impl Resolve for PruneContainers { #[instrument(name = "PruneContainers", skip_all)] async fn resolve(self, _: &super::Args) -> serror::Result { let command = String::from("docker container prune -f"); Ok(run_komodo_command("Prune Containers", None, command).await) } } // impl Resolve for StartAllContainers { #[instrument(name = "StartAllContainers", skip_all)] async fn resolve( self, _: &super::Args, ) -> serror::Result> { let containers = docker_client() .list_containers() .await .context("failed to list all containers on host")?; let futures = containers.iter().filter_map( |ContainerListItem { name, labels, .. }| { if labels.contains_key("komodo.skip") { return None; } let command = format!("docker start {name}"); Some(async move { run_komodo_command(&command.clone(), None, command).await }) }, ); Ok(join_all(futures).await) } } // impl Resolve for RestartAllContainers { #[instrument(name = "RestartAllContainers", skip_all)] async fn resolve( self, _: &super::Args, ) -> serror::Result> { let containers = docker_client() .list_containers() .await .context("failed to list all containers on host")?; let futures = containers.iter().filter_map( |ContainerListItem { name, labels, .. }| { if labels.contains_key("komodo.skip") { return None; } let command = format!("docker restart {name}"); Some(async move { run_komodo_command(&command.clone(), None, command).await }) }, ); Ok(join_all(futures).await) } } // impl Resolve for PauseAllContainers { #[instrument(name = "PauseAllContainers", skip_all)] async fn resolve( self, _: &super::Args, ) -> serror::Result> { let containers = docker_client() .list_containers() .await .context("failed to list all containers on host")?; let futures = containers.iter().filter_map( |ContainerListItem { name, labels, .. }| { if labels.contains_key("komodo.skip") { return None; } let command = format!("docker pause {name}"); Some(async move { run_komodo_command(&command.clone(), None, command).await }) }, ); Ok(join_all(futures).await) } } // impl Resolve for UnpauseAllContainers { #[instrument(name = "UnpauseAllContainers", skip_all)] async fn resolve( self, _: &super::Args, ) -> serror::Result> { let containers = docker_client() .list_containers() .await .context("failed to list all containers on host")?; let futures = containers.iter().filter_map( |ContainerListItem { name, labels, .. }| { if labels.contains_key("komodo.skip") { return None; } let command = format!("docker unpause {name}"); Some(async move { run_komodo_command(&command.clone(), None, command).await }) }, ); Ok(join_all(futures).await) } } // impl Resolve for StopAllContainers { #[instrument(name = "StopAllContainers", skip_all)] async fn resolve( self, _: &super::Args, ) -> serror::Result> { let containers = docker_client() .list_containers() .await .context("failed to list all containers on host")?; let futures = containers.iter().filter_map( |ContainerListItem { name, labels, .. }| { if labels.contains_key("komodo.skip") { return None; } Some(async move { run_komodo_command( &format!("docker stop {name}"), None, stop_container_command(name, None, None), ) .await }) }, ); Ok(join_all(futures).await) } } ================================================ FILE: bin/periphery/src/api/deploy.rs ================================================ use anyhow::Context; use command::run_komodo_command_with_sanitization; use formatting::format_serror; use interpolate::Interpolator; use komodo_client::{ entities::{ EnvironmentVar, deployment::{ Conversion, Deployment, DeploymentConfig, DeploymentImage, RestartMode, conversions_from_str, extract_registry_domain, }, environment_vars_from_str, update::Log, }, parsers::QUOTE_PATTERN, }; use periphery_client::api::container::{Deploy, RemoveContainer}; use resolver_api::Resolve; use crate::{ config::periphery_config, docker::{docker_login, pull_image}, helpers::{parse_extra_args, parse_labels}, }; impl Resolve for Deploy { #[instrument( name = "Deploy", skip_all, fields( stack = &self.deployment.name, stop_signal = format!("{:?}", self.stop_signal), stop_time = self.stop_time, ) )] async fn resolve(self, _: &super::Args) -> serror::Result { let Deploy { mut deployment, stop_signal, stop_time, registry_token, mut replacers, } = self; let mut interpolator = Interpolator::new(None, &periphery_config().secrets); interpolator.interpolate_deployment(&mut deployment)?; replacers.extend(interpolator.secret_replacers); let image = if let DeploymentImage::Image { image } = &deployment.config.image { if image.is_empty() { return Ok(Log::error( "get image", String::from("deployment does not have image attached"), )); } image } else { return Ok(Log::error( "get image", String::from("deployment does not have image attached"), )); }; if let Err(e) = docker_login( &extract_registry_domain(image)?, &deployment.config.image_registry_account, registry_token.as_deref(), ) .await { return Ok(Log::error( "docker login", format_serror( &e.context("failed to login to docker registry").into(), ), )); } let _ = pull_image(image).await; debug!("image pulled"); let _ = (RemoveContainer { name: deployment.name.clone(), signal: stop_signal, time: stop_time, }) .resolve(&super::Args) .await; debug!("container stopped and removed"); let command = docker_run_command(&deployment, image) .context("Unable to generate valid docker run command")?; let Some(log) = run_komodo_command_with_sanitization( "Docker Run", None, command, false, &replacers, ) .await else { // The none case is only for empty command, // this won't be the case given it is populated above. unreachable!() }; Ok(log) } } fn docker_run_command( Deployment { name, config: DeploymentConfig { volumes, ports, network, command, restart, environment, labels, extra_args, .. }, .. }: &Deployment, image: &str, ) -> anyhow::Result { let ports = parse_conversions( &conversions_from_str(ports).context("Invalid ports")?, "-p", ); let volumes = parse_conversions( &conversions_from_str(volumes).context("Invalid volumes")?, "-v", ); let network = parse_network(network); let restart = parse_restart(restart); let environment = parse_environment( &environment_vars_from_str(environment) .context("Invalid environment")?, ); let labels = parse_labels( &environment_vars_from_str(labels).context("Invalid labels")?, ); let command = parse_command(command); let extra_args = parse_extra_args(extra_args); let command = format!( "docker run -d --name {name}{ports}{volumes}{network}{restart}{environment}{labels}{extra_args} {image}{command}" ); Ok(command) } fn parse_conversions( conversions: &[Conversion], flag: &str, ) -> String { conversions .iter() .map(|p| format!(" {flag} {}:{}", p.local, p.container)) .collect::>() .join("") } fn parse_environment(environment: &[EnvironmentVar]) -> String { environment .iter() .map(|p| { if p.value.starts_with(QUOTE_PATTERN) && p.value.ends_with(QUOTE_PATTERN) { // If the value already wrapped in quotes, don't wrap it again format!(" --env {}={}", p.variable, p.value) } else { format!(" --env {}=\"{}\"", p.variable, p.value) } }) .collect::>() .join("") } fn parse_network(network: &str) -> String { format!(" --network {network}") } fn parse_restart(restart: &RestartMode) -> String { let restart = match restart { RestartMode::OnFailure => "on-failure:10".to_string(), _ => restart.to_string(), }; format!(" --restart {restart}") } fn parse_command(command: &str) -> String { if command.is_empty() { String::new() } else { format!(" {command}") } } ================================================ FILE: bin/periphery/src/api/git.rs ================================================ use anyhow::{Context, anyhow}; use axum::http::StatusCode; use formatting::format_serror; use komodo_client::entities::{ DefaultRepoFolder, LatestCommit, update::Log, }; use periphery_client::api::git::{ CloneRepo, DeleteRepo, GetLatestCommit, PeripheryRepoExecutionResponse, PullOrCloneRepo, PullRepo, RenameRepo, }; use resolver_api::Resolve; use serror::AddStatusCodeError; use std::path::PathBuf; use tokio::fs; use crate::{ config::periphery_config, git::handle_post_repo_execution, }; impl Resolve for GetLatestCommit { #[instrument(name = "GetLatestCommit", level = "debug")] async fn resolve( self, _: &super::Args, ) -> serror::Result> { let repo_path = match self.path { Some(p) => PathBuf::from(p), None => periphery_config().repo_dir().join(self.name), }; // Make sure its a repo, or return null to avoid log spam if !repo_path.is_dir() || !repo_path.join(".git").is_dir() { return Ok(None); } Ok(Some(git::get_commit_hash_info(&repo_path).await?)) } } impl Resolve for CloneRepo { #[instrument( name = "CloneRepo", skip_all, fields( args = format!("{:?}", self.args), skip_secret_interp = self.skip_secret_interp, ) )] async fn resolve( self, _: &super::Args, ) -> serror::Result { let CloneRepo { args, git_token, environment, env_file_path, on_clone, on_pull, skip_secret_interp, replacers, } = self; let token = crate::helpers::git_token(git_token, &args)?; let root_repo_dir = default_folder(args.default_folder)?; let res = git::clone(args, &root_repo_dir, token).await?; handle_post_repo_execution( res, environment, &env_file_path, on_clone, on_pull, skip_secret_interp, replacers, ) .await .map_err(Into::into) } } // impl Resolve for PullRepo { #[instrument( name = "PullRepo", skip_all, fields( args = format!("{:?}", self.args), skip_secret_interp = self.skip_secret_interp, ) )] async fn resolve( self, _: &super::Args, ) -> serror::Result { let PullRepo { args, git_token, environment, env_file_path, on_pull, skip_secret_interp, replacers, } = self; let token = crate::helpers::git_token(git_token, &args)?; let parent_dir = default_folder(args.default_folder)?; let res = git::pull(args, &parent_dir, token).await?; handle_post_repo_execution( res, environment, &env_file_path, None, on_pull, skip_secret_interp, replacers, ) .await .map_err(Into::into) } } // impl Resolve for PullOrCloneRepo { #[instrument( name = "PullOrCloneRepo", skip_all, fields( args = format!("{:?}", self.args), skip_secret_interp = self.skip_secret_interp, ) )] async fn resolve( self, _: &super::Args, ) -> serror::Result { let PullOrCloneRepo { args, git_token, environment, env_file_path, on_clone, on_pull, skip_secret_interp, replacers, } = self; let token = crate::helpers::git_token(git_token, &args)?; let parent_dir = default_folder(args.default_folder)?; let (res, cloned) = git::pull_or_clone(args, &parent_dir, token).await?; handle_post_repo_execution( res, environment, &env_file_path, cloned.then_some(on_clone).flatten(), on_pull, skip_secret_interp, replacers, ) .await .map_err(Into::into) } } // impl Resolve for RenameRepo { #[instrument(name = "RenameRepo")] async fn resolve(self, _: &super::Args) -> serror::Result { let RenameRepo { curr_name, new_name, } = self; let repo_dir = periphery_config().repo_dir(); let renamed = fs::rename(repo_dir.join(&curr_name), repo_dir.join(&new_name)) .await; let msg = match renamed { Ok(_) => String::from("Renamed Repo directory on Server"), Err(_) => format!("No Repo cloned at {curr_name} to rename"), }; Ok(Log::simple("Rename Repo on Server", msg)) } } // impl Resolve for DeleteRepo { #[instrument(name = "DeleteRepo")] async fn resolve(self, _: &super::Args) -> serror::Result { let DeleteRepo { name, is_build } = self; // If using custom clone path, it will be passed by core instead of name. // So the join will resolve to just the absolute path. let root = if is_build { periphery_config().build_dir() } else { periphery_config().repo_dir() }; let full_path = root.join(&name); let deleted = fs::remove_dir_all(&full_path).await.with_context(|| { format!("Failed to delete repo at {full_path:?}") }); let log = match deleted { Ok(_) => { Log::simple("Delete repo", format!("Deleted Repo {name}")) } Err(e) => Log::error("Delete repo", format_serror(&e.into())), }; Ok(log) } } // fn default_folder( default_folder: DefaultRepoFolder, ) -> serror::Result { match default_folder { DefaultRepoFolder::Stacks => Ok(periphery_config().stack_dir()), DefaultRepoFolder::Builds => Ok(periphery_config().build_dir()), DefaultRepoFolder::Repos => Ok(periphery_config().repo_dir()), DefaultRepoFolder::NotApplicable => { Err( anyhow!("The clone args should not have a default_folder of NotApplicable using this method.") .status_code(StatusCode::BAD_REQUEST) ) } } } ================================================ FILE: bin/periphery/src/api/image.rs ================================================ use std::sync::OnceLock; use cache::TimeoutCache; use command::run_komodo_command; use komodo_client::entities::{ deployment::extract_registry_domain, docker::image::{Image, ImageHistoryResponseItem}, komodo_timestamp, update::Log, }; use periphery_client::api::image::*; use resolver_api::Resolve; use crate::docker::{docker_client, docker_login}; // impl Resolve for InspectImage { #[instrument(name = "InspectImage", level = "debug")] async fn resolve(self, _: &super::Args) -> serror::Result { Ok(docker_client().inspect_image(&self.name).await?) } } // impl Resolve for ImageHistory { #[instrument(name = "ImageHistory", level = "debug")] async fn resolve( self, _: &super::Args, ) -> serror::Result> { Ok(docker_client().image_history(&self.name).await?) } } // /// Wait this long after a pull to allow another pull through const PULL_TIMEOUT: i64 = 5_000; fn pull_cache() -> &'static TimeoutCache { static PULL_CACHE: OnceLock> = OnceLock::new(); PULL_CACHE.get_or_init(Default::default) } impl Resolve for PullImage { #[instrument(name = "PullImage", skip_all, fields(name = &self.name))] async fn resolve(self, _: &super::Args) -> serror::Result { let PullImage { name, account, token, } = self; // Acquire the image lock let lock = pull_cache().get_lock(name.clone()).await; // Lock the image lock, prevents simultaneous pulls by // ensuring simultaneous pulls will wait for first to finish // and checking cached results. let mut locked = lock.lock().await; // Early return from cache if lasted pulled with PULL_TIMEOUT if locked.last_ts + PULL_TIMEOUT > komodo_timestamp() { return locked.clone_res().map_err(Into::into); } let res = async { docker_login( &extract_registry_domain(&name)?, account.as_deref().unwrap_or_default(), token.as_deref(), ) .await?; anyhow::Ok( run_komodo_command( "Docker Pull", None, format!("docker pull {name}"), ) .await, ) } .await; // Set the cache with results. Any other calls waiting on the lock will // then immediately also use this same result. locked.set(&res, komodo_timestamp()); res.map_err(Into::into) } } // impl Resolve for DeleteImage { #[instrument(name = "DeleteImage")] async fn resolve(self, _: &super::Args) -> serror::Result { let command = format!("docker image rm {}", self.name); Ok(run_komodo_command("Delete Image", None, command).await) } } // impl Resolve for PruneImages { #[instrument(name = "PruneImages")] async fn resolve(self, _: &super::Args) -> serror::Result { let command = String::from("docker image prune -a -f"); Ok(run_komodo_command("Prune Images", None, command).await) } } ================================================ FILE: bin/periphery/src/api/mod.rs ================================================ use anyhow::Context; use command::run_komodo_command; use derive_variants::EnumVariants; use futures::TryFutureExt; use komodo_client::entities::{ SystemCommand, config::{DockerRegistry, GitProvider}, update::Log, }; use periphery_client::api::{ build::*, compose::*, container::*, git::*, image::*, network::*, stats::*, terminal::*, volume::*, *, }; use resolver_api::Resolve; use response::Response; use serde::{Deserialize, Serialize}; use crate::{config::periphery_config, docker::docker_client}; mod build; mod compose; mod container; mod deploy; mod git; mod image; mod network; mod router; mod stats; mod terminal; mod volume; pub use router::router; pub struct Args; #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EnumVariants, )] #[args(Args)] #[response(Response)] #[error(serror::Error)] #[variant_derive(Debug)] #[serde(tag = "type", content = "params")] #[allow(clippy::enum_variant_names, clippy::large_enum_variant)] pub enum PeripheryRequest { GetVersion(GetVersion), GetHealth(GetHealth), // Config (Read) ListGitProviders(ListGitProviders), ListDockerRegistries(ListDockerRegistries), ListSecrets(ListSecrets), // Stats / Info (Read) GetSystemInformation(GetSystemInformation), GetSystemStats(GetSystemStats), GetSystemProcesses(GetSystemProcesses), GetLatestCommit(GetLatestCommit), // Generic shell execution RunCommand(RunCommand), // Repo (Write) CloneRepo(CloneRepo), PullRepo(PullRepo), PullOrCloneRepo(PullOrCloneRepo), RenameRepo(RenameRepo), DeleteRepo(DeleteRepo), // Build GetDockerfileContentsOnHost(GetDockerfileContentsOnHost), WriteDockerfileContentsToHost(WriteDockerfileContentsToHost), Build(Build), PruneBuilders(PruneBuilders), PruneBuildx(PruneBuildx), // Compose (Read) GetComposeContentsOnHost(GetComposeContentsOnHost), GetComposeLog(GetComposeLog), GetComposeLogSearch(GetComposeLogSearch), // Compose (Write) WriteComposeContentsToHost(WriteComposeContentsToHost), WriteCommitComposeContents(WriteCommitComposeContents), ComposePull(ComposePull), ComposeUp(ComposeUp), ComposeExecution(ComposeExecution), ComposeRun(ComposeRun), // Container (Read) InspectContainer(InspectContainer), GetContainerLog(GetContainerLog), GetContainerLogSearch(GetContainerLogSearch), GetContainerStats(GetContainerStats), GetContainerStatsList(GetContainerStatsList), GetFullContainerStats(GetFullContainerStats), // Container (Write) Deploy(Deploy), StartContainer(StartContainer), RestartContainer(RestartContainer), PauseContainer(PauseContainer), UnpauseContainer(UnpauseContainer), StopContainer(StopContainer), StartAllContainers(StartAllContainers), RestartAllContainers(RestartAllContainers), PauseAllContainers(PauseAllContainers), UnpauseAllContainers(UnpauseAllContainers), StopAllContainers(StopAllContainers), RemoveContainer(RemoveContainer), RenameContainer(RenameContainer), PruneContainers(PruneContainers), // Networks (Read) InspectNetwork(InspectNetwork), // Networks (Write) CreateNetwork(CreateNetwork), DeleteNetwork(DeleteNetwork), PruneNetworks(PruneNetworks), // Image (Read) InspectImage(InspectImage), ImageHistory(ImageHistory), // Image (Write) PullImage(PullImage), DeleteImage(DeleteImage), PruneImages(PruneImages), // Volume (Read) InspectVolume(InspectVolume), // Volume (Write) DeleteVolume(DeleteVolume), PruneVolumes(PruneVolumes), // All in one (Read) GetDockerLists(GetDockerLists), // All in one (Write) PruneSystem(PruneSystem), // Terminal ListTerminals(ListTerminals), CreateTerminal(CreateTerminal), DeleteTerminal(DeleteTerminal), DeleteAllTerminals(DeleteAllTerminals), CreateTerminalAuthToken(CreateTerminalAuthToken), } // impl Resolve for GetHealth { #[instrument(name = "GetHealth", level = "debug", skip_all)] async fn resolve( self, _: &Args, ) -> serror::Result { Ok(GetHealthResponse {}) } } // impl Resolve for GetVersion { #[instrument(name = "GetVersion", level = "debug", skip(self))] async fn resolve( self, _: &Args, ) -> serror::Result { Ok(GetVersionResponse { version: env!("CARGO_PKG_VERSION").to_string(), }) } } // impl Resolve for ListGitProviders { #[instrument(name = "ListGitProviders", level = "debug", skip_all)] async fn resolve( self, _: &Args, ) -> serror::Result> { Ok(periphery_config().git_providers.0.clone()) } } impl Resolve for ListDockerRegistries { #[instrument( name = "ListDockerRegistries", level = "debug", skip_all )] async fn resolve( self, _: &Args, ) -> serror::Result> { Ok(periphery_config().docker_registries.0.clone()) } } // impl Resolve for ListSecrets { #[instrument(name = "ListSecrets", level = "debug", skip_all)] async fn resolve(self, _: &Args) -> serror::Result> { Ok( periphery_config() .secrets .keys() .cloned() .collect::>(), ) } } impl Resolve for GetDockerLists { #[instrument(name = "GetDockerLists", level = "debug", skip_all)] async fn resolve( self, _: &Args, ) -> serror::Result { let docker = docker_client(); let containers = docker.list_containers().await.map_err(Into::into); // Should still try to retrieve other docker lists, but "in_use" will be false for images, networks, volumes let _containers = match &containers { Ok(containers) => containers.as_slice(), Err(_) => &[], }; let (networks, images, volumes, projects) = tokio::join!( docker.list_networks(_containers).map_err(Into::into), docker.list_images(_containers).map_err(Into::into), docker.list_volumes(_containers).map_err(Into::into), ListComposeProjects {} .resolve(&Args) .map_err(|e| e.error.into()) ); Ok(GetDockerListsResponse { containers, networks, images, volumes, projects, }) } } impl Resolve for RunCommand { #[instrument(name = "RunCommand")] async fn resolve(self, _: &Args) -> serror::Result { let RunCommand { command: SystemCommand { path, command }, } = self; let res = tokio::spawn(async move { let command = if path.is_empty() { command } else { format!("cd {path} && {command}") }; run_komodo_command("run command", None, command).await }) .await .context("failure in spawned task")?; Ok(res) } } impl Resolve for PruneSystem { #[instrument(name = "PruneSystem", skip_all)] async fn resolve(self, _: &Args) -> serror::Result { let command = String::from("docker system prune -a -f --volumes"); Ok(run_komodo_command("Prune System", None, command).await) } } ================================================ FILE: bin/periphery/src/api/network.rs ================================================ use command::run_komodo_command; use komodo_client::entities::{ docker::network::Network, update::Log, }; use periphery_client::api::network::*; use resolver_api::Resolve; use crate::docker::docker_client; // impl Resolve for InspectNetwork { #[instrument(name = "InspectNetwork", level = "debug")] async fn resolve(self, _: &super::Args) -> serror::Result { Ok(docker_client().inspect_network(&self.name).await?) } } // impl Resolve for CreateNetwork { #[instrument(name = "CreateNetwork", skip(self))] async fn resolve(self, _: &super::Args) -> serror::Result { let CreateNetwork { name, driver } = self; let driver = match driver { Some(driver) => format!(" -d {driver}"), None => String::new(), }; let command = format!("docker network create{driver} {name}"); Ok(run_komodo_command("Create Network", None, command).await) } } // impl Resolve for DeleteNetwork { #[instrument(name = "DeleteNetwork", skip(self))] async fn resolve(self, _: &super::Args) -> serror::Result { let command = format!("docker network rm {}", self.name); Ok(run_komodo_command("Delete Network", None, command).await) } } // impl Resolve for PruneNetworks { #[instrument(name = "PruneNetworks", skip(self))] async fn resolve(self, _: &super::Args) -> serror::Result { let command = String::from("docker network prune -f"); Ok(run_komodo_command("Prune Networks", None, command).await) } } ================================================ FILE: bin/periphery/src/api/router.rs ================================================ use anyhow::{Context, anyhow}; use axum::{ Router, body::Body, extract::ConnectInfo, http::{Request, StatusCode}, middleware::{self, Next}, response::Response, routing::{get, post}, }; use derive_variants::ExtractVariant; use resolver_api::Resolve; use serror::{AddStatusCode, AddStatusCodeError, Json}; use std::net::{IpAddr, SocketAddr}; use uuid::Uuid; use crate::config::periphery_config; pub fn router() -> Router { Router::new() .merge( Router::new() .route("/", post(handler)) .layer(middleware::from_fn(guard_request_by_passkey)), ) .nest( "/terminal", Router::new() .route("/", get(super::terminal::connect_terminal)) .route( "/container", get(super::terminal::connect_container_exec), ) .nest( "/execute", Router::new() .route("/", post(super::terminal::execute_terminal)) .route( "/container", post(super::terminal::execute_container_exec), ) .layer(middleware::from_fn(guard_request_by_passkey)), ), ) .layer(middleware::from_fn(guard_request_by_ip)) } async fn handler( Json(request): Json, ) -> serror::Result { let req_id = Uuid::new_v4(); let res = tokio::spawn(task(req_id, request)) .await .context("task handler spawn error"); if let Err(e) = &res { warn!("request {req_id} spawn error: {e:#}"); } res? } async fn task( req_id: Uuid, request: crate::api::PeripheryRequest, ) -> serror::Result { let variant = request.extract_variant(); let res = request.resolve(&crate::api::Args).await.map(|res| res.0); if let Err(e) = &res { warn!( "request {req_id} | type: {variant:?} | error: {:#}", e.error ); } res } async fn guard_request_by_passkey( req: Request, next: Next, ) -> serror::Result { if periphery_config().passkeys.is_empty() { return Ok(next.run(req).await); } let Some(req_passkey) = req.headers().get("authorization") else { return Err( anyhow!("request was not sent with passkey") .status_code(StatusCode::UNAUTHORIZED), ); }; let req_passkey = req_passkey .to_str() .context("failed to convert passkey to str") .status_code(StatusCode::UNAUTHORIZED)?; if periphery_config() .passkeys .iter() .any(|passkey| passkey == req_passkey) { Ok(next.run(req).await) } else { Err( anyhow!("request passkey invalid") .status_code(StatusCode::UNAUTHORIZED), ) } } async fn guard_request_by_ip( req: Request, next: Next, ) -> serror::Result { if periphery_config().allowed_ips.is_empty() { return Ok(next.run(req).await); } let ConnectInfo(socket_addr) = req .extensions() .get::>() .context("could not get ConnectionInfo of request") .status_code(StatusCode::UNAUTHORIZED)?; let ip = socket_addr.ip(); let ip_match = periphery_config().allowed_ips.iter().any(|net| { net.contains(ip) || match ip { IpAddr::V4(ipv4) => { net.contains(IpAddr::V6(ipv4.to_ipv6_mapped())) } IpAddr::V6(_) => net.contains(ip.to_canonical()), } }); if ip_match { Ok(next.run(req).await) } else { Err( anyhow!("requesting ip {ip} not allowed") .status_code(StatusCode::UNAUTHORIZED), ) } } ================================================ FILE: bin/periphery/src/api/stats.rs ================================================ use komodo_client::entities::stats::{ SystemInformation, SystemProcess, SystemStats, }; use periphery_client::api::stats::{ GetSystemInformation, GetSystemProcesses, GetSystemStats, }; use resolver_api::Resolve; use crate::stats::stats_client; impl Resolve for GetSystemInformation { #[instrument( name = "GetSystemInformation", level = "debug", skip_all )] async fn resolve( self, _: &super::Args, ) -> serror::Result { Ok(stats_client().read().await.info.clone()) } } // impl Resolve for GetSystemStats { #[instrument(name = "GetSystemStats", level = "debug", skip_all)] async fn resolve( self, _: &super::Args, ) -> serror::Result { Ok(stats_client().read().await.stats.clone()) } } // impl Resolve for GetSystemProcesses { #[instrument(name = "GetSystemProcesses", level = "debug")] async fn resolve( self, _: &super::Args, ) -> serror::Result> { Ok(stats_client().read().await.get_processes()) } } ================================================ FILE: bin/periphery/src/api/terminal.rs ================================================ use anyhow::{Context, anyhow}; use axum::{ extract::{ Query, WebSocketUpgrade, ws::{Message, Utf8Bytes}, }, http::StatusCode, response::Response, }; use bytes::Bytes; use futures::{SinkExt, StreamExt, TryStreamExt}; use komodo_client::{ api::write::TerminalRecreateMode, entities::{KOMODO_EXIT_CODE, NoData, server::TerminalInfo}, }; use periphery_client::api::terminal::*; use resolver_api::Resolve; use serror::{AddStatusCodeError, Json}; use tokio_util::sync::CancellationToken; use crate::{config::periphery_config, terminal::*}; impl Resolve for ListTerminals { #[instrument(name = "ListTerminals", level = "debug")] async fn resolve( self, _: &super::Args, ) -> serror::Result> { clean_up_terminals().await; Ok(list_terminals().await) } } impl Resolve for CreateTerminal { #[instrument(name = "CreateTerminal", level = "debug")] async fn resolve(self, _: &super::Args) -> serror::Result { if periphery_config().disable_terminals { return Err( anyhow!("Terminals are disabled in the periphery config") .status_code(StatusCode::FORBIDDEN), ); } create_terminal(self.name, self.command, self.recreate) .await .map(|_| NoData {}) .map_err(Into::into) } } impl Resolve for DeleteTerminal { #[instrument(name = "DeleteTerminal", level = "debug")] async fn resolve(self, _: &super::Args) -> serror::Result { delete_terminal(&self.terminal).await; Ok(NoData {}) } } impl Resolve for DeleteAllTerminals { #[instrument(name = "DeleteAllTerminals", level = "debug")] async fn resolve(self, _: &super::Args) -> serror::Result { delete_all_terminals().await; Ok(NoData {}) } } impl Resolve for CreateTerminalAuthToken { #[instrument(name = "CreateTerminalAuthToken", level = "debug")] async fn resolve( self, _: &super::Args, ) -> serror::Result { Ok(CreateTerminalAuthTokenResponse { token: auth_tokens().create_auth_token(), }) } } pub async fn connect_terminal( Query(query): Query, ws: WebSocketUpgrade, ) -> serror::Result { if periphery_config().disable_terminals { return Err( anyhow!("Terminals are disabled in the periphery config") .status_code(StatusCode::FORBIDDEN), ); } handle_terminal_websocket(query, ws).await } pub async fn connect_container_exec( Query(ConnectContainerExecQuery { token, container, shell, }): Query, ws: WebSocketUpgrade, ) -> serror::Result { if periphery_config().disable_container_exec { return Err( anyhow!("Container exec is disabled in the periphery config") .into(), ); } if container.contains("&&") || shell.contains("&&") { return Err( anyhow!( "The use of '&&' is forbidden in the container name or shell" ) .into(), ); } // Create (recreate if shell changed) create_terminal( container.clone(), format!("docker exec -it {container} {shell}"), TerminalRecreateMode::DifferentCommand, ) .await .context("Failed to create terminal for container exec")?; handle_terminal_websocket( ConnectTerminalQuery { token, terminal: container, }, ws, ) .await } async fn handle_terminal_websocket( ConnectTerminalQuery { token, terminal }: ConnectTerminalQuery, ws: WebSocketUpgrade, ) -> serror::Result { // Auth the connection with single use token auth_tokens().check_token(token)?; clean_up_terminals().await; let terminal = get_terminal(&terminal).await?; Ok(ws.on_upgrade(|mut socket| async move { let init_res = async { let (a, b) = terminal.history.bytes_parts(); if !a.is_empty() { socket.send(Message::Binary(a)).await.context("Failed to send history part a")?; } if !b.is_empty() { socket.send(Message::Binary(b)).await.context("Failed to send history part b")?; } anyhow::Ok(()) }.await; if let Err(e) = init_res { let _ = socket.send(Message::Text(format!("ERROR: {e:#}").into())).await; let _ = socket.close().await; return; } let (mut ws_write, mut ws_read) = socket.split(); let cancel = CancellationToken::new(); let ws_read = async { loop { let res = tokio::select! { res = ws_read.next() => res, _ = terminal.cancel.cancelled() => { trace!("ws read: cancelled from outside"); break }, _ = cancel.cancelled() => { trace!("ws read: cancelled from inside"); break; } }; match res { Some(Ok(Message::Binary(bytes))) if bytes.first() == Some(&0x00) => { // println!("Got ws read bytes - for stdin"); if let Err(e) = terminal.stdin.send(StdinMsg::Bytes( Bytes::copy_from_slice(&bytes[1..]), )).await { debug!("WS -> PTY channel send error: {e:}"); terminal.cancel(); break; }; } Some(Ok(Message::Binary(bytes))) if bytes.first() == Some(&0xFF) => { // println!("Got ws read bytes - for resize"); if let Ok(dimensions) = serde_json::from_slice::(&bytes[1..]) && let Err(e) = terminal.stdin.send(StdinMsg::Resize(dimensions)).await { debug!("WS -> PTY channel send error: {e:}"); terminal.cancel(); break; } } Some(Ok(Message::Text(text))) => { trace!("Got ws read text"); if let Err(e) = terminal.stdin.send(StdinMsg::Bytes(Bytes::from(text))).await { debug!("WS -> PTY channel send error: {e:?}"); terminal.cancel(); break; }; } Some(Ok(Message::Close(_))) => { debug!("got ws read close"); cancel.cancel(); break; } Some(Ok(_)) => { // Do nothing (ping, non-prefixed bytes, etc.) } Some(Err(e)) => { debug!("Got ws read error: {e:?}"); cancel.cancel(); break; } None => { debug!("Got ws read none"); cancel.cancel(); break; } } } }; let ws_write = async { let mut stdout = terminal.stdout.resubscribe(); loop { let res = tokio::select! { res = stdout.recv() => res.context("Failed to get message over stdout receiver"), _ = terminal.cancel.cancelled() => { trace!("ws write: cancelled from outside"); let _ = ws_write.send(Message::Text(Utf8Bytes::from_static("PTY KILLED"))).await; if let Err(e) = ws_write.close().await { debug!("Failed to close ws: {e:?}"); }; break }, _ = cancel.cancelled() => { let _ = ws_write.send(Message::Text(Utf8Bytes::from_static("WS KILLED"))).await; if let Err(e) = ws_write.close().await { debug!("Failed to close ws: {e:?}"); }; break } }; match res { Ok(bytes) => { if let Err(e) = ws_write.send(Message::Binary(bytes)).await { debug!("Failed to send to WS: {e:?}"); cancel.cancel(); break; } } Err(e) => { debug!("PTY -> WS channel read error: {e:?}"); let _ = ws_write.send(Message::Text(Utf8Bytes::from(format!("ERROR: {e:#}")))).await; let _ = ws_write.close().await; terminal.cancel(); break; } } } }; tokio::join!(ws_read, ws_write); clean_up_terminals().await; })) } pub async fn execute_terminal( Json(ExecuteTerminalBody { terminal, command }): Json< ExecuteTerminalBody, >, ) -> serror::Result { if periphery_config().disable_terminals { return Err( anyhow!("Terminals are disabled in the periphery config") .status_code(StatusCode::FORBIDDEN), ); } execute_command_on_terminal(&terminal, &command).await } pub async fn execute_container_exec( Json(ExecuteContainerExecBody { container, shell, command, }): Json, ) -> serror::Result { if periphery_config().disable_container_exec { return Err( anyhow!("Container exec is disabled in the periphery config") .into(), ); } if container.contains("&&") || shell.contains("&&") { return Err( anyhow!( "The use of '&&' is forbidden in the container name or shell" ) .into(), ); } // Create terminal (recreate if shell changed) create_terminal( container.clone(), format!("docker exec -it {container} {shell}"), TerminalRecreateMode::DifferentCommand, ) .await .context("Failed to create terminal for container exec")?; execute_command_on_terminal(&container, &command).await } async fn execute_command_on_terminal( terminal_name: &str, command: &str, ) -> serror::Result { let terminal = get_terminal(terminal_name).await?; // Read the bytes into lines // This is done to check the lines for the EOF sentinal let mut stdout = tokio_util::codec::FramedRead::new( tokio_util::io::StreamReader::new( tokio_stream::wrappers::BroadcastStream::new( terminal.stdout.resubscribe(), ) .map(|res| res.map_err(std::io::Error::other)), ), tokio_util::codec::LinesCodec::new(), ); let full_command = format!( "printf '\n{START_OF_OUTPUT}\n\n'; {command}; rc=$?; printf '\n{KOMODO_EXIT_CODE}%d\n{END_OF_OUTPUT}\n' \"$rc\"\n" ); terminal .stdin .send(StdinMsg::Bytes(Bytes::from(full_command))) .await .context("Failed to send command to terminal stdin")?; // Only start the response AFTER the start sentinel is printed loop { match stdout .try_next() .await .context("Failed to read stdout line")? { Some(line) if line == START_OF_OUTPUT => break, // Keep looping until the start sentinel received. Some(_) => {} None => { return Err( anyhow!( "Stdout stream terminated before start sentinel received" ) .into(), ); } } } Ok(axum::body::Body::from_stream(TerminalStream { stdout })) } ================================================ FILE: bin/periphery/src/api/volume.rs ================================================ use command::run_komodo_command; use komodo_client::entities::{docker::volume::Volume, update::Log}; use periphery_client::api::volume::*; use resolver_api::Resolve; use crate::docker::docker_client; // impl Resolve for InspectVolume { #[instrument(name = "InspectVolume", level = "debug")] async fn resolve(self, _: &super::Args) -> serror::Result { Ok(docker_client().inspect_volume(&self.name).await?) } } // impl Resolve for DeleteVolume { #[instrument(name = "DeleteVolume")] async fn resolve(self, _: &super::Args) -> serror::Result { let command = format!("docker volume rm {}", self.name); Ok(run_komodo_command("Delete Volume", None, command).await) } } // impl Resolve for PruneVolumes { #[instrument(name = "PruneVolumes")] async fn resolve(self, _: &super::Args) -> serror::Result { let command = String::from("docker volume prune -a -f"); Ok(run_komodo_command("Prune Volumes", None, command).await) } } ================================================ FILE: bin/periphery/src/build.rs ================================================ use std::{ fmt::Write, path::{Path, PathBuf}, }; use anyhow::{Context, anyhow}; use formatting::format_serror; use komodo_client::{ entities::{EnvironmentVar, update::Log}, parsers::QUOTE_PATTERN, }; pub async fn write_dockerfile( build_path: &Path, dockerfile_path: &str, dockerfile: &str, logs: &mut Vec, ) { if let Err(e) = async { if dockerfile.is_empty() { return Err(anyhow!("UI Defined dockerfile is empty")); } let full_dockerfile_path = build_path .join(dockerfile_path) .components() .collect::(); // Ensure parent directory exists if let Some(parent) = full_dockerfile_path.parent() && !parent.exists() { tokio::fs::create_dir_all(parent) .await .with_context(|| format!("Failed to initialize dockerfile parent directory {parent:?}"))?; } tokio::fs::write(&full_dockerfile_path, dockerfile).await.with_context(|| { format!( "Failed to write dockerfile contents to {full_dockerfile_path:?}" ) })?; logs.push(Log::simple( "Write Dockerfile", format!( "Dockerfile contents written to {full_dockerfile_path:?}" ), )); anyhow::Ok(()) }.await { logs.push(Log::error("Write Dockerfile", format_serror(&e.into()))); } } pub fn parse_build_args(build_args: &[EnvironmentVar]) -> String { build_args .iter() .map(|p| { if p.value.starts_with(QUOTE_PATTERN) && p.value.ends_with(QUOTE_PATTERN) { // If the value already wrapped in quotes, don't wrap it again format!(" --build-arg {}={}", p.variable, p.value) } else { format!(" --build-arg {}=\"{}\"", p.variable, p.value) } }) .collect::>() .join("") } /// pub async fn parse_secret_args( secret_args: &[EnvironmentVar], build_dir: &Path, ) -> anyhow::Result { let mut res = String::new(); for EnvironmentVar { variable, value } in secret_args { // Check edge cases if variable.is_empty() { return Err(anyhow!("secret variable cannot be empty string")); } else if variable.contains('=') { return Err(anyhow!( "invalid variable {variable}. variable cannot contain '='" )); } // Write the value to file to mount let path = build_dir.join(variable); tokio::fs::write(&path, value).await.with_context(|| { format!( "Failed to write build secret {variable} to {}", path.display() ) })?; // Extend the command write!( &mut res, " --secret id={variable},src={}", path.display() ) .with_context(|| { format!( "Failed to format build secret arguments for {variable}" ) })?; } Ok(res) } ================================================ FILE: bin/periphery/src/compose/mod.rs ================================================ use std::{fmt::Write, path::PathBuf}; use anyhow::{Context, anyhow}; use command::run_komodo_command; use komodo_client::entities::{ RepoExecutionArgs, repo::Repo, stack::Stack, to_path_compatible_name, }; use periphery_client::api::{ compose::ComposeUpResponse, git::PullOrCloneRepo, }; use resolver_api::Resolve; use crate::config::periphery_config; pub mod up; pub mod write; pub fn docker_compose() -> &'static str { if periphery_config().legacy_compose_cli { "docker-compose" } else { "docker compose" } } pub fn env_file_args( env_file_path: Option<&str>, additional_env_files: &[String], ) -> anyhow::Result { let mut res = String::new(); for file in additional_env_files.iter().filter(|&path| { let Some(komodo_path) = env_file_path else { return true; }; // Filter komodo env out of additional env file if its also in there. // It will be always be added last / have highest priority. path != komodo_path }) { write!(res, " --env-file {file}").with_context(|| { format!("Failed to write --env-file arg for {file}") })?; } // Add this last, so it is applied on top if let Some(file) = env_file_path { write!(res, " --env-file {file}").with_context(|| { format!("Failed to write --env-file arg for {file}") })?; } Ok(res) } pub async fn down( project: &str, services: &[String], res: &mut ComposeUpResponse, ) -> anyhow::Result<()> { let docker_compose = docker_compose(); let service_args = if services.is_empty() { String::new() } else { format!(" {}", services.join(" ")) }; let log = run_komodo_command( "Compose Down", None, format!("{docker_compose} -p {project} down{service_args}"), ) .await; let success = log.success; res.logs.push(log); if !success { return Err(anyhow!( "Failed to bring down existing container(s) with docker compose down. Stopping run." )); } Ok(()) } /// Only for git repo based Stacks. /// Returns path to root directory of the stack repo. /// /// Both Stack and Repo environment, on clone, on pull are ignored. pub async fn pull_or_clone_stack( stack: &Stack, repo: Option<&Repo>, git_token: Option, ) -> anyhow::Result { if stack.config.files_on_host { return Err(anyhow!( "Wrong method called for files on host stack" )); } if repo.is_none() && stack.config.repo.is_empty() { return Err(anyhow!("Repo is not configured")); } let (root, mut args) = if let Some(repo) = repo { let root = periphery_config() .repo_dir() .join(to_path_compatible_name(&repo.name)) .join(&repo.config.path) .components() .collect::(); let args: RepoExecutionArgs = repo.into(); (root, args) } else { let root = periphery_config() .stack_dir() .join(to_path_compatible_name(&stack.name)) .join(&stack.config.clone_path) .components() .collect::(); let args: RepoExecutionArgs = stack.into(); (root, args) }; args.destination = Some(root.display().to_string()); let git_token = crate::helpers::git_token(git_token, &args)?; PullOrCloneRepo { args, git_token, // All the extra pull functions // (env, on clone, on pull) // are disabled with this method. environment: Default::default(), env_file_path: Default::default(), on_clone: Default::default(), on_pull: Default::default(), skip_secret_interp: Default::default(), replacers: Default::default(), } .resolve(&crate::api::Args) .await .map_err(|e| e.error)?; Ok(root) } ================================================ FILE: bin/periphery/src/compose/up.rs ================================================ use std::path::{Path, PathBuf}; use anyhow::{Context, anyhow}; use formatting::format_serror; use komodo_client::entities::{ FileContents, stack::{Stack, StackRemoteFileContents}, update::Log, }; use periphery_client::api::compose::ComposeUpResponse; use tokio::fs; use crate::docker::docker_login; pub async fn validate_files( stack: &Stack, run_directory: &Path, res: &mut ComposeUpResponse, ) { let file_paths = stack .all_file_dependencies() .into_iter() .map(|file| { ( // This will remove any intermediate uneeded '/./' in the path run_directory .join(&file.path) .components() .collect::(), file, ) }) .collect::>(); // First validate no missing files for (full_path, file) in &file_paths { if !full_path.exists() { res.missing_files.push(file.path.clone()); } } if !res.missing_files.is_empty() { res.logs.push(Log::error( "Validate Files", format_serror( &anyhow!( "Missing files: {}", res.missing_files.join(", ") ) .context("Ensure the run_directory and all file paths are correct.") .context("A file doesn't exist after writing stack.") .into(), ), )); return; } for (full_path, file) in file_paths { let file_contents = match fs::read_to_string(&full_path).await.with_context(|| { format!("Failed to read file contents at {full_path:?}") }) { Ok(res) => res, Err(e) => { let error = format_serror(&e.into()); res .logs .push(Log::error("Read Compose File", error.clone())); // This should only happen for repo stacks, ie remote error res.remote_errors.push(FileContents { path: file.path, contents: error, }); return; } }; res.file_contents.push(StackRemoteFileContents { path: file.path, contents: file_contents, services: file.services, requires: file.requires, }); } } pub async fn maybe_login_registry( stack: &Stack, registry_token: Option, logs: &mut Vec, ) { if !stack.config.registry_provider.is_empty() && !stack.config.registry_account.is_empty() && let Err(e) = docker_login( &stack.config.registry_provider, &stack.config.registry_account, registry_token.as_deref(), ) .await .with_context(|| { format!( "Domain: '{}' | Account: '{}'", stack.config.registry_provider, stack.config.registry_account ) }) .context("Failed to login to image registry") { logs.push(Log::error( "Login to Registry", format_serror(&e.into()), )); } } ================================================ FILE: bin/periphery/src/compose/write.rs ================================================ use std::path::PathBuf; use anyhow::{Context, anyhow}; use formatting::format_serror; use komodo_client::entities::{ FileContents, RepoExecutionArgs, all_logs_success, repo::Repo, stack::Stack, to_path_compatible_name, update::Log, }; use periphery_client::api::{ compose::{ ComposePullResponse, ComposeRunResponse, ComposeUpResponse, }, git::{CloneRepo, PullOrCloneRepo}, }; use resolver_api::Resolve; use tokio::fs; use crate::{config::periphery_config, helpers}; pub trait WriteStackRes { fn logs(&mut self) -> &mut Vec; fn add_remote_error(&mut self, _contents: FileContents) {} fn set_commit_hash(&mut self, _hash: Option) {} fn set_commit_message(&mut self, _message: Option) {} } impl WriteStackRes for &mut ComposeUpResponse { fn logs(&mut self) -> &mut Vec { &mut self.logs } fn add_remote_error(&mut self, contents: FileContents) { self.remote_errors.push(contents); } fn set_commit_hash(&mut self, hash: Option) { self.commit_hash = hash; } fn set_commit_message(&mut self, message: Option) { self.commit_message = message; } } impl WriteStackRes for &mut ComposePullResponse { fn logs(&mut self) -> &mut Vec { &mut self.logs } } impl WriteStackRes for &mut ComposeRunResponse { fn logs(&mut self) -> &mut Vec { &mut self.logs } } /// Either writes the stack file_contents to a file, or clones the repo. /// Asssumes all interpolation is already complete. /// Returns (run_directory, env_file_path, periphery_replacers) pub async fn write_stack<'a>( stack: &'a Stack, repo: Option<&Repo>, git_token: Option, replacers: Vec<(String, String)>, res: impl WriteStackRes, ) -> anyhow::Result<( // run_directory PathBuf, // env_file_path Option<&'a str>, )> { if stack.config.files_on_host { write_stack_files_on_host(stack, res).await } else if let Some(repo) = repo { write_stack_linked_repo(stack, repo, git_token, replacers, res) .await } else if !stack.config.repo.is_empty() { write_stack_inline_repo(stack, git_token, res).await } else { write_stack_ui_defined(stack, res).await } } async fn write_stack_files_on_host( stack: &Stack, mut res: impl WriteStackRes, ) -> anyhow::Result<( // run_directory PathBuf, // env_file_path Option<&str>, )> { let run_directory = periphery_config() .stack_dir() .join(to_path_compatible_name(&stack.name)) .join(&stack.config.run_directory) .components() .collect::(); let env_file_path = environment::write_env_file( &stack.config.env_vars()?, run_directory.as_path(), &stack.config.env_file_path, res.logs(), ) .await; if all_logs_success(res.logs()) { Ok(( run_directory, // Env file paths are expected to be already relative to run directory, // so need to pass original env_file_path here. env_file_path .is_some() .then_some(&stack.config.env_file_path), )) } else { Err(anyhow!("Failed to write env file, stopping run.")) } } async fn write_stack_linked_repo<'a>( stack: &'a Stack, repo: &Repo, git_token: Option, replacers: Vec<(String, String)>, mut res: impl WriteStackRes, ) -> anyhow::Result<( // run_directory PathBuf, // env_file_path Option<&'a str>, )> { let root = periphery_config() .repo_dir() .join(to_path_compatible_name(&repo.name)) .join(&repo.config.path) .components() .collect::(); let mut args: RepoExecutionArgs = repo.into(); // Set the clone destination to the one created for this run args.destination = Some(root.display().to_string()); let git_token = stack_git_token(git_token, &args, &mut res)?; let env_file_path = root .join(&repo.config.env_file_path) .components() .collect::() .display() .to_string(); let on_clone = (!repo.config.on_clone.is_none()) .then_some(repo.config.on_clone.clone()); let on_pull = (!repo.config.on_pull.is_none()) .then_some(repo.config.on_pull.clone()); let clone_res = if stack.config.reclone { CloneRepo { args, git_token, environment: repo.config.env_vars()?, env_file_path, on_clone, on_pull, skip_secret_interp: repo.config.skip_secret_interp, replacers, } .resolve(&crate::api::Args) .await .map_err(|e| e.error)? } else { PullOrCloneRepo { args, git_token, environment: repo.config.env_vars()?, env_file_path, on_clone, on_pull, skip_secret_interp: repo.config.skip_secret_interp, replacers, } .resolve(&crate::api::Args) .await .map_err(|e| e.error)? }; res.logs().extend(clone_res.res.logs); res.set_commit_hash(clone_res.res.commit_hash); res.set_commit_message(clone_res.res.commit_message); if !all_logs_success(res.logs()) { return Ok((root, None)); } let run_directory = root .join(&stack.config.run_directory) .components() .collect::(); let env_file_path = environment::write_env_file( &stack.config.env_vars()?, run_directory.as_path(), &stack.config.env_file_path, res.logs(), ) .await; if !all_logs_success(res.logs()) { return Err(anyhow!("Failed to write env file, stopping run")); } Ok(( run_directory, env_file_path .is_some() .then_some(&stack.config.env_file_path), )) } async fn write_stack_inline_repo( stack: &Stack, git_token: Option, mut res: impl WriteStackRes, ) -> anyhow::Result<( // run_directory PathBuf, // env_file_path Option<&str>, )> { let root = periphery_config() .stack_dir() .join(to_path_compatible_name(&stack.name)) .join(&stack.config.clone_path) .components() .collect::(); let mut args: RepoExecutionArgs = stack.into(); // Set the clone destination to the one created for this run args.destination = Some(root.display().to_string()); let git_token = stack_git_token(git_token, &args, &mut res)?; let clone_res = if stack.config.reclone { CloneRepo { args, git_token, environment: Default::default(), env_file_path: Default::default(), on_clone: Default::default(), on_pull: Default::default(), skip_secret_interp: Default::default(), replacers: Default::default(), } .resolve(&crate::api::Args) .await .map_err(|e| e.error)? } else { PullOrCloneRepo { args, git_token, environment: Default::default(), env_file_path: Default::default(), on_clone: Default::default(), on_pull: Default::default(), skip_secret_interp: Default::default(), replacers: Default::default(), } .resolve(&crate::api::Args) .await .map_err(|e| e.error)? }; res.logs().extend(clone_res.res.logs); res.set_commit_hash(clone_res.res.commit_hash); res.set_commit_message(clone_res.res.commit_message); if !all_logs_success(res.logs()) { return Ok((root, None)); } let run_directory = root .join(&stack.config.run_directory) .components() .collect::(); let env_file_path = environment::write_env_file( &stack.config.env_vars()?, run_directory.as_path(), &stack.config.env_file_path, res.logs(), ) .await; if !all_logs_success(res.logs()) { return Err(anyhow!("Failed to write env file, stopping run")); } Ok(( run_directory, env_file_path .is_some() .then_some(&stack.config.env_file_path), )) } async fn write_stack_ui_defined( stack: &Stack, mut res: impl WriteStackRes, ) -> anyhow::Result<( // run_directory PathBuf, // env_file_path Option<&str>, )> { if stack.config.file_contents.trim().is_empty() { return Err(anyhow!( "Must either input compose file contents directly, or use files on host / git repo options." )); } let run_directory = periphery_config() .stack_dir() .join(to_path_compatible_name(&stack.name)) .components() .collect::(); // Ensure run directory exists fs::create_dir_all(&run_directory).await.with_context(|| { format!( "failed to create stack run directory at {run_directory:?}" ) })?; let env_file_path = environment::write_env_file( &stack.config.env_vars()?, run_directory.as_path(), &stack.config.env_file_path, res.logs(), ) .await; if !all_logs_success(res.logs()) { return Err(anyhow!("Failed to write env file, stopping run")); } let file_path = run_directory .join( stack .config .file_paths // only need the first one, or default .first() .map(String::as_str) .unwrap_or("compose.yaml"), ) .components() .collect::(); fs::write(&file_path, &stack.config.file_contents) .await .with_context(|| { format!("Failed to write compose file to {file_path:?}") })?; Ok(( run_directory, env_file_path .is_some() .then_some(&stack.config.env_file_path), )) } fn stack_git_token( core_token: Option, args: &RepoExecutionArgs, res: &mut R, ) -> anyhow::Result> { helpers::git_token(core_token, args).map_err(|e| { let error = format_serror(&e.into()); res .logs() .push(Log::error("Missing git token", error.clone())); res.add_remote_error(FileContents { path: Default::default(), contents: error, }); anyhow!("failed to find required git token, stopping run") }) } ================================================ FILE: bin/periphery/src/config.rs ================================================ use std::{path::PathBuf, sync::OnceLock}; use clap::Parser; use colored::Colorize; use config::ConfigLoader; use environment_file::maybe_read_list_from_file; use komodo_client::entities::{ config::periphery::{CliArgs, Env, PeripheryConfig}, logger::{LogConfig, LogLevel}, }; pub fn periphery_config() -> &'static PeripheryConfig { static PERIPHERY_CONFIG: OnceLock = OnceLock::new(); PERIPHERY_CONFIG.get_or_init(|| { let env: Env = envy::from_env() .expect("failed to parse periphery environment"); let args = CliArgs::parse(); let config_paths = args.config_path.unwrap_or(env.periphery_config_paths); let config = if config_paths.is_empty() { println!( "{}: No config paths found, using default config", "INFO".green(), ); PeripheryConfig::default() } else { (ConfigLoader { paths: &config_paths .iter() .map(PathBuf::as_path) .collect::>(), match_wildcards: &args .config_keyword .unwrap_or(env.periphery_config_keywords) .iter() .map(String::as_str) .collect::>(), include_file_name: ".peripheryinclude", merge_nested: args .merge_nested_config .unwrap_or(env.periphery_merge_nested_config), extend_array: args .extend_config_arrays .unwrap_or(env.periphery_extend_config_arrays), debug_print: args .log_level .map(|level| { level == tracing::Level::DEBUG || level == tracing::Level::TRACE }) .unwrap_or_default(), }) .load() .expect("failed at parsing config from paths") }; PeripheryConfig { port: env.periphery_port.unwrap_or(config.port), bind_ip: env.periphery_bind_ip.unwrap_or(config.bind_ip), root_directory: env .periphery_root_directory .unwrap_or(config.root_directory), repo_dir: env.periphery_repo_dir.or(config.repo_dir), stack_dir: env.periphery_stack_dir.or(config.stack_dir), build_dir: env.periphery_build_dir.or(config.build_dir), disable_terminals: env .periphery_disable_terminals .unwrap_or(config.disable_terminals), disable_container_exec: env .periphery_disable_container_exec .unwrap_or(config.disable_container_exec), stats_polling_rate: env .periphery_stats_polling_rate .unwrap_or(config.stats_polling_rate), container_stats_polling_rate: env .periphery_container_stats_polling_rate .unwrap_or(config.container_stats_polling_rate), legacy_compose_cli: env .periphery_legacy_compose_cli .unwrap_or(config.legacy_compose_cli), logging: LogConfig { level: args .log_level .map(LogLevel::from) .or(env.periphery_logging_level) .unwrap_or(config.logging.level), stdio: env .periphery_logging_stdio .unwrap_or(config.logging.stdio), pretty: env .periphery_logging_pretty .unwrap_or(config.logging.pretty), location: env .periphery_logging_location .unwrap_or(config.logging.location), otlp_endpoint: env .periphery_logging_otlp_endpoint .unwrap_or(config.logging.otlp_endpoint), opentelemetry_service_name: env .periphery_logging_opentelemetry_service_name .unwrap_or(config.logging.opentelemetry_service_name), }, pretty_startup_config: env .periphery_pretty_startup_config .unwrap_or(config.pretty_startup_config), allowed_ips: env .periphery_allowed_ips .unwrap_or(config.allowed_ips), passkeys: maybe_read_list_from_file( env.periphery_passkeys_file, env.periphery_passkeys, ) .unwrap_or(config.passkeys), include_disk_mounts: env .periphery_include_disk_mounts .unwrap_or(config.include_disk_mounts), exclude_disk_mounts: env .periphery_exclude_disk_mounts .unwrap_or(config.exclude_disk_mounts), ssl_enabled: env .periphery_ssl_enabled .unwrap_or(config.ssl_enabled), ssl_key_file: env .periphery_ssl_key_file .or(config.ssl_key_file), ssl_cert_file: env .periphery_ssl_cert_file .or(config.ssl_cert_file), secrets: config.secrets, git_providers: config.git_providers, docker_registries: config.docker_registries, } }) } ================================================ FILE: bin/periphery/src/docker/containers.rs ================================================ use std::collections::HashMap; use anyhow::Context; use bollard::query_parameters::{ InspectContainerOptions, ListContainersOptions, }; use komodo_client::entities::docker::{ ContainerConfig, GraphDriverData, HealthConfig, PortBinding, container::*, }; use super::{DockerClient, stats::container_stats}; impl DockerClient { pub async fn list_containers( &self, ) -> anyhow::Result> { let containers = self .docker .list_containers(Some(ListContainersOptions { all: true, ..Default::default() })) .await?; let stats = container_stats().load(); let mut containers = containers .into_iter() .flat_map(|container| { let name = container .names .context("no names on container")? .pop() .context("no names on container (empty vec)")? .replace('/', ""); let stats = stats.get(&name).cloned(); anyhow::Ok(ContainerListItem { server_id: None, name, stats, id: container.id, image: container.image, image_id: container.image_id, created: container.created, size_rw: container.size_rw, size_root_fs: container.size_root_fs, state: convert_summary_container_state( container.state.context("no container state")?, ), status: container.status, network_mode: container .host_config .and_then(|config| config.network_mode), networks: container .network_settings .and_then(|settings| { settings.networks.map(|networks| { let mut keys = networks.into_keys().collect::>(); keys.sort(); keys }) }) .unwrap_or_default(), ports: container .ports .map(|ports| { ports.into_iter().map(convert_port).collect() }) .unwrap_or_default(), volumes: container .mounts .map(|settings| { settings .into_iter() .filter_map(|mount| mount.name) .collect() }) .unwrap_or_default(), labels: container.labels.unwrap_or_default(), }) }) .collect::>(); let container_id_to_network = containers .iter() .filter_map(|c| Some((c.id.clone()?, c.network_mode.clone()?))) .collect::>(); // Fix containers which use `container:container_id` network_mode, // by replacing with the referenced network mode. containers.iter_mut().for_each(|container| { let Some(network_name) = &container.network_mode else { return; }; let Some(container_id) = network_name.strip_prefix("container:") else { return; }; container.network_mode = container_id_to_network.get(container_id).cloned(); }); Ok(containers) } pub async fn inspect_container( &self, container_name: &str, ) -> anyhow::Result { let container = self .docker .inspect_container( container_name, InspectContainerOptions { size: true }.into(), ) .await?; Ok(Container { id: container.id, created: container.created, path: container.path, args: container.args.unwrap_or_default(), state: container.state.map(|state| ContainerState { status: state .status .map(convert_container_state_status) .unwrap_or_default(), running: state.running, paused: state.paused, restarting: state.restarting, oom_killed: state.oom_killed, dead: state.dead, pid: state.pid, exit_code: state.exit_code, error: state.error, started_at: state.started_at, finished_at: state.finished_at, health: state.health.map(|health| ContainerHealth { status: health .status .map(convert_health_status) .unwrap_or_default(), failing_streak: health.failing_streak, log: health .log .map(|log| { log .into_iter() .map(convert_health_check_result) .collect() }) .unwrap_or_default(), }), }), image: container.image, resolv_conf_path: container.resolv_conf_path, hostname_path: container.hostname_path, hosts_path: container.hosts_path, log_path: container.log_path, name: container.name, restart_count: container.restart_count, driver: container.driver, platform: container.platform, mount_label: container.mount_label, process_label: container.process_label, app_armor_profile: container.app_armor_profile, exec_ids: container.exec_ids.unwrap_or_default(), host_config: container.host_config.map(|config| HostConfig { cpu_shares: config.cpu_shares, memory: config.memory, cgroup_parent: config.cgroup_parent, blkio_weight: config.blkio_weight, blkio_weight_device: config .blkio_weight_device .unwrap_or_default() .into_iter() .map(|device| ResourcesBlkioWeightDevice { path: device.path, weight: device.weight, }) .collect(), blkio_device_read_bps: config .blkio_device_read_bps .unwrap_or_default() .into_iter() .map(|bp| ThrottleDevice { path: bp.path, rate: bp.rate, }) .collect(), blkio_device_write_bps: config .blkio_device_write_bps .unwrap_or_default() .into_iter() .map(|bp| ThrottleDevice { path: bp.path, rate: bp.rate, }) .collect(), blkio_device_read_iops: config .blkio_device_read_iops .unwrap_or_default() .into_iter() .map(|iops| ThrottleDevice { path: iops.path, rate: iops.rate, }) .collect(), blkio_device_write_iops: config .blkio_device_write_iops .unwrap_or_default() .into_iter() .map(|iops| ThrottleDevice { path: iops.path, rate: iops.rate, }) .collect(), cpu_period: config.cpu_period, cpu_quota: config.cpu_quota, cpu_realtime_period: config.cpu_realtime_period, cpu_realtime_runtime: config.cpu_realtime_runtime, cpuset_cpus: config.cpuset_cpus, cpuset_mems: config.cpuset_mems, devices: config .devices .unwrap_or_default() .into_iter() .map(|device| DeviceMapping { path_on_host: device.path_on_host, path_in_container: device.path_in_container, cgroup_permissions: device.cgroup_permissions, }) .collect(), device_cgroup_rules: config .device_cgroup_rules .unwrap_or_default(), device_requests: config .device_requests .unwrap_or_default() .into_iter() .map(|request| DeviceRequest { driver: request.driver, count: request.count, device_ids: request.device_ids.unwrap_or_default(), capabilities: request.capabilities.unwrap_or_default(), options: request.options.unwrap_or_default(), }) .collect(), kernel_memory_tcp: config.kernel_memory_tcp, memory_reservation: config.memory_reservation, memory_swap: config.memory_swap, memory_swappiness: config.memory_swappiness, nano_cpus: config.nano_cpus, oom_kill_disable: config.oom_kill_disable, init: config.init, pids_limit: config.pids_limit, ulimits: config .ulimits .unwrap_or_default() .into_iter() .map(|ulimit| ResourcesUlimits { name: ulimit.name, soft: ulimit.soft, hard: ulimit.hard, }) .collect(), cpu_count: config.cpu_count, cpu_percent: config.cpu_percent, io_maximum_iops: config.io_maximum_iops, io_maximum_bandwidth: config.io_maximum_bandwidth, binds: config.binds.unwrap_or_default(), container_id_file: config.container_id_file, log_config: config.log_config.map(|config| { HostConfigLogConfig { typ: config.typ, config: config.config.unwrap_or_default(), } }), network_mode: config.network_mode, port_bindings: config .port_bindings .unwrap_or_default() .into_iter() .map(|(k, v)| { ( k, v.unwrap_or_default() .into_iter() .map(|v| PortBinding { host_ip: v.host_ip, host_port: v.host_port, }) .collect(), ) }) .collect(), restart_policy: config.restart_policy.map(|policy| { RestartPolicy { name: policy .name .map(convert_restart_policy) .unwrap_or_default(), maximum_retry_count: policy.maximum_retry_count, } }), auto_remove: config.auto_remove, volume_driver: config.volume_driver, volumes_from: config.volumes_from.unwrap_or_default(), mounts: config .mounts .unwrap_or_default() .into_iter() .map(|mount| ContainerMount { target: mount.target, source: mount.source, typ: mount .typ .map(convert_mount_type) .unwrap_or_default(), read_only: mount.read_only, consistency: mount.consistency, bind_options: mount.bind_options.map(|options| { MountBindOptions { propagation: options .propagation .map(convert_mount_propogation) .unwrap_or_default(), non_recursive: options.non_recursive, create_mountpoint: options.create_mountpoint, read_only_non_recursive: options .read_only_non_recursive, read_only_force_recursive: options .read_only_force_recursive, } }), volume_options: mount.volume_options.map(|options| { MountVolumeOptions { no_copy: options.no_copy, labels: options.labels.unwrap_or_default(), driver_config: options.driver_config.map(|config| { MountVolumeOptionsDriverConfig { name: config.name, options: config.options.unwrap_or_default(), } }), subpath: options.subpath, } }), tmpfs_options: mount.tmpfs_options.map(|options| { MountTmpfsOptions { size_bytes: options.size_bytes, mode: options.mode, } }), }) .collect(), console_size: config .console_size .map(|v| v.into_iter().map(|s| s as i32).collect()) .unwrap_or_default(), annotations: config.annotations.unwrap_or_default(), cap_add: config.cap_add.unwrap_or_default(), cap_drop: config.cap_drop.unwrap_or_default(), cgroupns_mode: config .cgroupns_mode .map(convert_cgroupns_mode), dns: config.dns.unwrap_or_default(), dns_options: config.dns_options.unwrap_or_default(), dns_search: config.dns_search.unwrap_or_default(), extra_hosts: config.extra_hosts.unwrap_or_default(), group_add: config.group_add.unwrap_or_default(), ipc_mode: config.ipc_mode, cgroup: config.cgroup, links: config.links.unwrap_or_default(), oom_score_adj: config.oom_score_adj, pid_mode: config.pid_mode, privileged: config.privileged, publish_all_ports: config.publish_all_ports, readonly_rootfs: config.readonly_rootfs, security_opt: config.security_opt.unwrap_or_default(), storage_opt: config.storage_opt.unwrap_or_default(), tmpfs: config.tmpfs.unwrap_or_default(), uts_mode: config.uts_mode, userns_mode: config.userns_mode, shm_size: config.shm_size, sysctls: config.sysctls.unwrap_or_default(), runtime: config.runtime, isolation: config .isolation .map(convert_isolation_mode) .unwrap_or_default(), masked_paths: config.masked_paths.unwrap_or_default(), readonly_paths: config.readonly_paths.unwrap_or_default(), }), graph_driver: container.graph_driver.map(|driver| { GraphDriverData { name: driver.name, data: driver.data, } }), size_rw: container.size_rw, size_root_fs: container.size_root_fs, mounts: container .mounts .unwrap_or_default() .into_iter() .map(|mount| MountPoint { typ: mount .typ .map(convert_mount_point_type) .unwrap_or_default(), name: mount.name, source: mount.source, destination: mount.destination, driver: mount.driver, mode: mount.mode, rw: mount.rw, propagation: mount.propagation, }) .collect(), config: container.config.map(|config| ContainerConfig { hostname: config.hostname, domainname: config.domainname, user: config.user, attach_stdin: config.attach_stdin, attach_stdout: config.attach_stdout, attach_stderr: config.attach_stderr, exposed_ports: config .exposed_ports .unwrap_or_default() .into_keys() .map(|k| (k, Default::default())) .collect(), tty: config.tty, open_stdin: config.open_stdin, stdin_once: config.stdin_once, env: config.env.unwrap_or_default(), cmd: config.cmd.unwrap_or_default(), healthcheck: config.healthcheck.map(|health| HealthConfig { test: health.test.unwrap_or_default(), interval: health.interval, timeout: health.timeout, retries: health.retries, start_period: health.start_period, start_interval: health.start_interval, }), args_escaped: config.args_escaped, image: config.image, volumes: config .volumes .unwrap_or_default() .into_keys() .map(|k| (k, Default::default())) .collect(), working_dir: config.working_dir, entrypoint: config.entrypoint.unwrap_or_default(), network_disabled: config.network_disabled, mac_address: config.mac_address, on_build: config.on_build.unwrap_or_default(), labels: config.labels.unwrap_or_default(), stop_signal: config.stop_signal, stop_timeout: config.stop_timeout, shell: config.shell.unwrap_or_default(), }), network_settings: container.network_settings.map(|settings| { NetworkSettings { bridge: settings.bridge, sandbox_id: settings.sandbox_id, ports: settings .ports .unwrap_or_default() .into_iter() .map(|(k, v)| { ( k, v.unwrap_or_default() .into_iter() .map(|v| PortBinding { host_ip: v.host_ip, host_port: v.host_port, }) .collect(), ) }) .collect(), sandbox_key: settings.sandbox_key, networks: settings .networks .unwrap_or_default() .into_iter() .map(|(k, v)| { ( k, EndpointSettings { ipam_config: v.ipam_config.map(|ipam| { EndpointIpamConfig { ipv4_address: ipam.ipv4_address, ipv6_address: ipam.ipv6_address, link_local_ips: ipam .link_local_ips .unwrap_or_default(), } }), links: v.links.unwrap_or_default(), mac_address: v.mac_address, aliases: v.aliases.unwrap_or_default(), network_id: v.network_id, endpoint_id: v.endpoint_id, gateway: v.gateway, ip_address: v.ip_address, ip_prefix_len: v.ip_prefix_len, ipv6_gateway: v.ipv6_gateway, global_ipv6_address: v.global_ipv6_address, global_ipv6_prefix_len: v.global_ipv6_prefix_len, driver_opts: v.driver_opts.unwrap_or_default(), dns_names: v.dns_names.unwrap_or_default(), }, ) }) .collect(), } }), }) } } fn convert_summary_container_state( state: bollard::secret::ContainerSummaryStateEnum, ) -> ContainerStateStatusEnum { match state { bollard::secret::ContainerSummaryStateEnum::EMPTY => { ContainerStateStatusEnum::Empty } bollard::secret::ContainerSummaryStateEnum::CREATED => { ContainerStateStatusEnum::Created } bollard::secret::ContainerSummaryStateEnum::RUNNING => { ContainerStateStatusEnum::Running } bollard::secret::ContainerSummaryStateEnum::PAUSED => { ContainerStateStatusEnum::Paused } bollard::secret::ContainerSummaryStateEnum::RESTARTING => { ContainerStateStatusEnum::Restarting } bollard::secret::ContainerSummaryStateEnum::EXITED => { ContainerStateStatusEnum::Exited } bollard::secret::ContainerSummaryStateEnum::REMOVING => { ContainerStateStatusEnum::Removing } bollard::secret::ContainerSummaryStateEnum::DEAD => { ContainerStateStatusEnum::Dead } } } fn convert_container_state_status( state: bollard::secret::ContainerStateStatusEnum, ) -> ContainerStateStatusEnum { match state { bollard::secret::ContainerStateStatusEnum::EMPTY => { ContainerStateStatusEnum::Empty } bollard::secret::ContainerStateStatusEnum::CREATED => { ContainerStateStatusEnum::Created } bollard::secret::ContainerStateStatusEnum::RUNNING => { ContainerStateStatusEnum::Running } bollard::secret::ContainerStateStatusEnum::PAUSED => { ContainerStateStatusEnum::Paused } bollard::secret::ContainerStateStatusEnum::RESTARTING => { ContainerStateStatusEnum::Restarting } bollard::secret::ContainerStateStatusEnum::EXITED => { ContainerStateStatusEnum::Exited } bollard::secret::ContainerStateStatusEnum::REMOVING => { ContainerStateStatusEnum::Removing } bollard::secret::ContainerStateStatusEnum::DEAD => { ContainerStateStatusEnum::Dead } } } fn convert_port_type( typ: bollard::secret::PortTypeEnum, ) -> PortTypeEnum { match typ { bollard::secret::PortTypeEnum::EMPTY => PortTypeEnum::EMPTY, bollard::secret::PortTypeEnum::TCP => PortTypeEnum::TCP, bollard::secret::PortTypeEnum::UDP => PortTypeEnum::UDP, bollard::secret::PortTypeEnum::SCTP => PortTypeEnum::SCTP, } } fn convert_port(port: bollard::secret::Port) -> Port { Port { ip: port.ip, private_port: port.private_port, public_port: port.public_port, typ: port.typ.map(convert_port_type).unwrap_or_default(), } } fn convert_health_status( status: bollard::secret::HealthStatusEnum, ) -> HealthStatusEnum { match status { bollard::secret::HealthStatusEnum::EMPTY => { HealthStatusEnum::Empty } bollard::secret::HealthStatusEnum::NONE => HealthStatusEnum::None, bollard::secret::HealthStatusEnum::STARTING => { HealthStatusEnum::Starting } bollard::secret::HealthStatusEnum::HEALTHY => { HealthStatusEnum::Healthy } bollard::secret::HealthStatusEnum::UNHEALTHY => { HealthStatusEnum::Unhealthy } } } fn convert_health_check_result( check: bollard::secret::HealthcheckResult, ) -> HealthcheckResult { HealthcheckResult { start: check.start, end: check.end, exit_code: check.exit_code, output: check.output, } } fn convert_restart_policy( policy: bollard::secret::RestartPolicyNameEnum, ) -> RestartPolicyNameEnum { match policy { bollard::secret::RestartPolicyNameEnum::EMPTY => { RestartPolicyNameEnum::Empty } bollard::secret::RestartPolicyNameEnum::NO => { RestartPolicyNameEnum::No } bollard::secret::RestartPolicyNameEnum::ALWAYS => { RestartPolicyNameEnum::Always } bollard::secret::RestartPolicyNameEnum::UNLESS_STOPPED => { RestartPolicyNameEnum::UnlessStopped } bollard::secret::RestartPolicyNameEnum::ON_FAILURE => { RestartPolicyNameEnum::OnFailure } } } fn convert_mount_type( typ: bollard::secret::MountTypeEnum, ) -> MountTypeEnum { match typ { bollard::secret::MountTypeEnum::EMPTY => MountTypeEnum::Empty, bollard::secret::MountTypeEnum::BIND => MountTypeEnum::Bind, bollard::secret::MountTypeEnum::VOLUME => MountTypeEnum::Volume, bollard::secret::MountTypeEnum::IMAGE => MountTypeEnum::Image, bollard::secret::MountTypeEnum::TMPFS => MountTypeEnum::Tmpfs, bollard::secret::MountTypeEnum::NPIPE => MountTypeEnum::Npipe, bollard::secret::MountTypeEnum::CLUSTER => MountTypeEnum::Cluster, } } fn convert_mount_point_type( typ: bollard::secret::MountPointTypeEnum, ) -> MountTypeEnum { match typ { bollard::secret::MountPointTypeEnum::EMPTY => { MountTypeEnum::Empty } bollard::secret::MountPointTypeEnum::BIND => MountTypeEnum::Bind, bollard::secret::MountPointTypeEnum::VOLUME => { MountTypeEnum::Volume } bollard::secret::MountPointTypeEnum::IMAGE => { MountTypeEnum::Image } bollard::secret::MountPointTypeEnum::TMPFS => { MountTypeEnum::Tmpfs } bollard::secret::MountPointTypeEnum::NPIPE => { MountTypeEnum::Npipe } bollard::secret::MountPointTypeEnum::CLUSTER => { MountTypeEnum::Cluster } } } fn convert_mount_propogation( propogation: bollard::secret::MountBindOptionsPropagationEnum, ) -> MountBindOptionsPropagationEnum { match propogation { bollard::secret::MountBindOptionsPropagationEnum::EMPTY => { MountBindOptionsPropagationEnum::Empty } bollard::secret::MountBindOptionsPropagationEnum::PRIVATE => { MountBindOptionsPropagationEnum::Private } bollard::secret::MountBindOptionsPropagationEnum::RPRIVATE => { MountBindOptionsPropagationEnum::Rprivate } bollard::secret::MountBindOptionsPropagationEnum::SHARED => { MountBindOptionsPropagationEnum::Shared } bollard::secret::MountBindOptionsPropagationEnum::RSHARED => { MountBindOptionsPropagationEnum::Rshared } bollard::secret::MountBindOptionsPropagationEnum::SLAVE => { MountBindOptionsPropagationEnum::Slave } bollard::secret::MountBindOptionsPropagationEnum::RSLAVE => { MountBindOptionsPropagationEnum::Rslave } } } fn convert_cgroupns_mode( mode: bollard::secret::HostConfigCgroupnsModeEnum, ) -> HostConfigCgroupnsModeEnum { match mode { bollard::secret::HostConfigCgroupnsModeEnum::EMPTY => { HostConfigCgroupnsModeEnum::Empty } bollard::secret::HostConfigCgroupnsModeEnum::PRIVATE => { HostConfigCgroupnsModeEnum::Private } bollard::secret::HostConfigCgroupnsModeEnum::HOST => { HostConfigCgroupnsModeEnum::Host } } } fn convert_isolation_mode( isolation: bollard::secret::HostConfigIsolationEnum, ) -> HostConfigIsolationEnum { match isolation { bollard::secret::HostConfigIsolationEnum::EMPTY => { HostConfigIsolationEnum::Empty } bollard::secret::HostConfigIsolationEnum::DEFAULT => { HostConfigIsolationEnum::Default } bollard::secret::HostConfigIsolationEnum::PROCESS => { HostConfigIsolationEnum::Process } bollard::secret::HostConfigIsolationEnum::HYPERV => { HostConfigIsolationEnum::Hyperv } } } ================================================ FILE: bin/periphery/src/docker/images.rs ================================================ use bollard::query_parameters::ListImagesOptions; use komodo_client::entities::docker::{ ContainerConfig, GraphDriverData, HealthConfig, container::ContainerListItem, image::*, }; use super::DockerClient; impl DockerClient { pub async fn list_images( &self, containers: &[ContainerListItem], ) -> anyhow::Result> { let images = self .docker .list_images(Option::::None) .await? .into_iter() .map(|image| { let in_use = containers.iter().any(|container| { container .image_id .as_ref() .map(|id| id == &image.id) .unwrap_or_default() }); ImageListItem { name: image .repo_tags .into_iter() .next() .unwrap_or_else(|| image.id.clone()), id: image.id, parent_id: image.parent_id, created: image.created, size: image.size, in_use, } }) .collect(); Ok(images) } pub async fn inspect_image( &self, image_name: &str, ) -> anyhow::Result { let image = self.docker.inspect_image(image_name).await?; Ok(Image { id: image.id, repo_tags: image.repo_tags.unwrap_or_default(), repo_digests: image.repo_digests.unwrap_or_default(), parent: image.parent, comment: image.comment, created: image.created, docker_version: image.docker_version, author: image.author, architecture: image.architecture, variant: image.variant, os: image.os, os_version: image.os_version, size: image.size, graph_driver: image.graph_driver.map(|driver| { GraphDriverData { name: driver.name, data: driver.data, } }), root_fs: image.root_fs.map(|fs| ImageInspectRootFs { typ: fs.typ, layers: fs.layers.unwrap_or_default(), }), metadata: image.metadata.map(|metadata| ImageInspectMetadata { last_tag_time: metadata.last_tag_time, }), config: image.config.map(|config| ContainerConfig { hostname: config.hostname, domainname: config.domainname, user: config.user, attach_stdin: config.attach_stdin, attach_stdout: config.attach_stdout, attach_stderr: config.attach_stderr, exposed_ports: config .exposed_ports .unwrap_or_default() .into_keys() .map(|k| (k, Default::default())) .collect(), tty: config.tty, open_stdin: config.open_stdin, stdin_once: config.stdin_once, env: config.env.unwrap_or_default(), cmd: config.cmd.unwrap_or_default(), healthcheck: config.healthcheck.map(|health| HealthConfig { test: health.test.unwrap_or_default(), interval: health.interval, timeout: health.timeout, retries: health.retries, start_period: health.start_period, start_interval: health.start_interval, }), args_escaped: config.args_escaped, image: config.image, volumes: config .volumes .unwrap_or_default() .into_keys() .map(|k| (k, Default::default())) .collect(), working_dir: config.working_dir, entrypoint: config.entrypoint.unwrap_or_default(), network_disabled: config.network_disabled, mac_address: config.mac_address, on_build: config.on_build.unwrap_or_default(), labels: config.labels.unwrap_or_default(), stop_signal: config.stop_signal, stop_timeout: config.stop_timeout, shell: config.shell.unwrap_or_default(), }), }) } pub async fn image_history( &self, image_name: &str, ) -> anyhow::Result> { let res = self .docker .image_history(image_name) .await? .into_iter() .map(|image| ImageHistoryResponseItem { id: image.id, created: image.created, created_by: image.created_by, tags: image.tags, size: image.size, comment: image.comment, }) .collect(); Ok(res) } } ================================================ FILE: bin/periphery/src/docker/mod.rs ================================================ use std::sync::OnceLock; use anyhow::anyhow; use bollard::Docker; use command::run_komodo_command; use komodo_client::entities::{TerminationSignal, update::Log}; use run_command::async_run_command; pub mod stats; mod containers; mod images; mod networks; mod volumes; pub fn docker_client() -> &'static DockerClient { static DOCKER_CLIENT: OnceLock = OnceLock::new(); DOCKER_CLIENT.get_or_init(Default::default) } pub struct DockerClient { docker: Docker, } impl Default for DockerClient { fn default() -> DockerClient { DockerClient { docker: Docker::connect_with_defaults() .expect("failed to connect to docker daemon"), } } } /// Returns whether build result should be pushed after build #[instrument(skip(registry_token))] pub async fn docker_login( domain: &str, account: &str, // For local token override from core. registry_token: Option<&str>, ) -> anyhow::Result { if domain.is_empty() || account.is_empty() { return Ok(false); } let registry_token = match registry_token { Some(token) => token, None => crate::helpers::registry_token(domain, account)?, }; let log = async_run_command(&format!( "echo {registry_token} | docker login {domain} --username '{account}' --password-stdin", )) .await; if log.success() { Ok(true) } else { let mut e = anyhow!("End of trace"); for line in log.stderr.split('\n').filter(|line| !line.is_empty()).rev() { e = e.context(line.to_string()); } for line in log.stdout.split('\n').filter(|line| !line.is_empty()).rev() { e = e.context(line.to_string()); } Err(e.context(format!("Registry {domain} login error"))) } } #[instrument] pub async fn pull_image(image: &str) -> Log { let command = format!("docker pull {image}"); run_komodo_command("Docker Pull", None, command).await } pub fn stop_container_command( container_name: &str, signal: Option, time: Option, ) -> String { let signal = signal .map(|signal| format!(" --signal {signal}")) .unwrap_or_default(); let time = time .map(|time| format!(" --time {time}")) .unwrap_or_default(); format!("docker stop{signal}{time} {container_name}") } ================================================ FILE: bin/periphery/src/docker/networks.rs ================================================ use bollard::query_parameters::{ InspectNetworkOptions, ListNetworksOptions, }; use komodo_client::entities::docker::{ container::ContainerListItem, network::*, }; use super::DockerClient; impl DockerClient { pub async fn list_networks( &self, containers: &[ContainerListItem], ) -> anyhow::Result> { let networks = self .docker .list_networks(Option::::None) .await? .into_iter() .map(|network| { let (ipam_driver, ipam_subnet, ipam_gateway) = if let Some(ipam) = network.ipam { let (subnet, gateway) = if let Some(config) = ipam .config .and_then(|configs| configs.into_iter().next()) { (config.subnet, config.gateway) } else { (None, None) }; (ipam.driver, subnet, gateway) } else { (None, None, None) }; let in_use = match &network.name { Some(name) => containers.iter().any(|container| { container.networks.iter().any(|_name| name == _name) }), None => false, }; NetworkListItem { name: network.name, id: network.id, created: network.created, scope: network.scope, driver: network.driver, enable_ipv6: network.enable_ipv6, ipam_driver, ipam_subnet, ipam_gateway, internal: network.internal, attachable: network.attachable, ingress: network.ingress, in_use, } }) .collect(); Ok(networks) } pub async fn inspect_network( &self, network_name: &str, ) -> anyhow::Result { let network = self .docker .inspect_network( network_name, InspectNetworkOptions { verbose: true, ..Default::default() } .into(), ) .await?; Ok(Network { name: network.name, id: network.id, created: network.created, scope: network.scope, driver: network.driver, enable_ipv6: network.enable_ipv6, ipam: network.ipam.map(|ipam| Ipam { driver: ipam.driver, config: ipam .config .unwrap_or_default() .into_iter() .map(|config| IpamConfig { subnet: config.subnet, ip_range: config.ip_range, gateway: config.gateway, auxiliary_addresses: config .auxiliary_addresses .unwrap_or_default(), }) .collect(), options: ipam.options.unwrap_or_default(), }), internal: network.internal, attachable: network.attachable, ingress: network.ingress, containers: network .containers .unwrap_or_default() .into_iter() .map(|(container_id, container)| NetworkContainer { container_id, name: container.name, endpoint_id: container.endpoint_id, mac_address: container.mac_address, ipv4_address: container.ipv4_address, ipv6_address: container.ipv6_address, }) .collect(), options: network.options.unwrap_or_default(), labels: network.labels.unwrap_or_default(), }) } } ================================================ FILE: bin/periphery/src/docker/stats.rs ================================================ use std::{ collections::HashMap, sync::{Arc, OnceLock}, }; use anyhow::{Context, anyhow}; use arc_swap::ArcSwap; use async_timing_util::wait_until_timelength; use bollard::{models, query_parameters::StatsOptionsBuilder}; use futures::StreamExt; use komodo_client::entities::docker::{ container::ContainerStats, stats::{ ContainerBlkioStatEntry, ContainerBlkioStats, ContainerCpuStats, ContainerCpuUsage, ContainerMemoryStats, ContainerNetworkStats, ContainerPidsStats, ContainerStorageStats, ContainerThrottlingData, FullContainerStats, }, }; use run_command::async_run_command; use crate::{config::periphery_config, docker::DockerClient}; pub type ContainerStatsMap = HashMap; pub fn container_stats() -> &'static ArcSwap { static CONTAINER_STATS: OnceLock> = OnceLock::new(); CONTAINER_STATS.get_or_init(Default::default) } pub fn spawn_polling_thread() { tokio::spawn(async move { let polling_rate = periphery_config() .container_stats_polling_rate .to_string() .parse() .expect("invalid stats polling rate"); update_container_stats().await; loop { let _ts = wait_until_timelength(polling_rate, 200).await; update_container_stats().await; } }); } async fn update_container_stats() { match get_container_stats(None).await { Ok(stats) => { container_stats().store(Arc::new( stats.into_iter().map(|s| (s.name.clone(), s)).collect(), )); } Err(e) => { error!("Failed to refresh container stats cache | {e:#}"); } } } pub async fn get_container_stats( container_name: Option, ) -> anyhow::Result> { let format = "--format '{\"BlockIO\":\"{{ .BlockIO }}\", \ \"CPUPerc\":\"{{ .CPUPerc }}\", \ \"ID\":\"{{ .ID }}\", \ \"MemPerc\":\"{{ .MemPerc }}\", \ \"MemUsage\":\"{{ .MemUsage }}\", \ \"Name\":\"{{ .Name }}\", \ \"NetIO\":\"{{ .NetIO }}\",\ \"PIDs\":\"{{ .PIDs }}\"}'"; let container_name = match container_name { Some(name) => format!(" {name}"), None => "".to_string(), }; let command = format!("docker stats{container_name} --no-stream {format}"); let output = async_run_command(&command).await; if output.success() { output .stdout .split('\n') .filter(|e| !e.is_empty()) .map(|e| { let parsed = serde_json::from_str(e) .context(format!("failed at parsing entry {e}"))?; Ok(parsed) }) .collect() } else { Err(anyhow!("{}", output.stderr.replace('\n', " | "))) } } impl DockerClient { /// Calls for stats once, similar to --no-stream on the cli pub async fn full_container_stats( &self, container_name: &str, ) -> anyhow::Result { let mut res = self.docker.stats( container_name, StatsOptionsBuilder::new().stream(false).build().into(), ); let stats = res .next() .await .with_context(|| format!("Unable to get container stats for {container_name} (got None)"))? .with_context(|| format!("Unable to get container stats for {container_name}"))?; Ok(FullContainerStats { name: stats.name.unwrap_or(container_name.to_string()), id: stats.id, read: stats.read, preread: stats.preread, pids_stats: stats.pids_stats.map(convert_pids_stats), blkio_stats: stats.blkio_stats.map(convert_blkio_stats), num_procs: stats.num_procs, storage_stats: stats.storage_stats.map(convert_storage_stats), cpu_stats: stats.cpu_stats.map(convert_cpu_stats), precpu_stats: stats.precpu_stats.map(convert_cpu_stats), memory_stats: stats.memory_stats.map(convert_memory_stats), networks: stats.networks.map(convert_network_stats), }) } } fn convert_pids_stats( pids_stats: models::ContainerPidsStats, ) -> ContainerPidsStats { ContainerPidsStats { current: pids_stats.current, limit: pids_stats.limit, } } fn convert_blkio_stats( blkio_stats: models::ContainerBlkioStats, ) -> ContainerBlkioStats { ContainerBlkioStats { io_service_bytes_recursive: blkio_stats .io_service_bytes_recursive .map(convert_blkio_stat_entries), io_serviced_recursive: blkio_stats .io_serviced_recursive .map(convert_blkio_stat_entries), io_queue_recursive: blkio_stats .io_queue_recursive .map(convert_blkio_stat_entries), io_service_time_recursive: blkio_stats .io_service_time_recursive .map(convert_blkio_stat_entries), io_wait_time_recursive: blkio_stats .io_wait_time_recursive .map(convert_blkio_stat_entries), io_merged_recursive: blkio_stats .io_merged_recursive .map(convert_blkio_stat_entries), io_time_recursive: blkio_stats .io_time_recursive .map(convert_blkio_stat_entries), sectors_recursive: blkio_stats .sectors_recursive .map(convert_blkio_stat_entries), } } fn convert_blkio_stat_entries( blkio_stat_entries: Vec, ) -> Vec { blkio_stat_entries .into_iter() .map(|blkio_stat_entry| ContainerBlkioStatEntry { major: blkio_stat_entry.major, minor: blkio_stat_entry.minor, op: blkio_stat_entry.op, value: blkio_stat_entry.value, }) .collect() } fn convert_storage_stats( storage_stats: models::ContainerStorageStats, ) -> ContainerStorageStats { ContainerStorageStats { read_count_normalized: storage_stats.read_count_normalized, read_size_bytes: storage_stats.read_size_bytes, write_count_normalized: storage_stats.write_count_normalized, write_size_bytes: storage_stats.write_size_bytes, } } fn convert_cpu_stats( cpu_stats: models::ContainerCpuStats, ) -> ContainerCpuStats { ContainerCpuStats { cpu_usage: cpu_stats.cpu_usage.map(convert_cpu_usage), system_cpu_usage: cpu_stats.system_cpu_usage, online_cpus: cpu_stats.online_cpus, throttling_data: cpu_stats .throttling_data .map(convert_cpu_throttling_data), } } fn convert_cpu_usage( cpu_usage: models::ContainerCpuUsage, ) -> ContainerCpuUsage { ContainerCpuUsage { total_usage: cpu_usage.total_usage, percpu_usage: cpu_usage.percpu_usage, usage_in_kernelmode: cpu_usage.usage_in_kernelmode, usage_in_usermode: cpu_usage.usage_in_usermode, } } fn convert_cpu_throttling_data( cpu_throttling_data: models::ContainerThrottlingData, ) -> ContainerThrottlingData { ContainerThrottlingData { periods: cpu_throttling_data.periods, throttled_periods: cpu_throttling_data.throttled_periods, throttled_time: cpu_throttling_data.throttled_time, } } fn convert_memory_stats( memory_stats: models::ContainerMemoryStats, ) -> ContainerMemoryStats { ContainerMemoryStats { usage: memory_stats.usage, max_usage: memory_stats.max_usage, stats: memory_stats.stats, failcnt: memory_stats.failcnt, limit: memory_stats.limit, commitbytes: memory_stats.commitbytes, commitpeakbytes: memory_stats.commitpeakbytes, privateworkingset: memory_stats.privateworkingset, } } fn convert_network_stats( network_stats: HashMap< std::string::String, models::ContainerNetworkStats, >, ) -> HashMap { network_stats .into_iter() .map(|(name, network_stats)| { ( name, ContainerNetworkStats { rx_bytes: network_stats.rx_bytes, rx_packets: network_stats.rx_packets, rx_errors: network_stats.rx_errors, rx_dropped: network_stats.rx_dropped, tx_bytes: network_stats.tx_bytes, tx_packets: network_stats.tx_packets, tx_errors: network_stats.tx_errors, tx_dropped: network_stats.tx_dropped, endpoint_id: network_stats.endpoint_id, instance_id: network_stats.instance_id, }, ) }) .collect() } ================================================ FILE: bin/periphery/src/docker/volumes.rs ================================================ use bollard::query_parameters::ListVolumesOptions; use komodo_client::entities::docker::{ PortBinding, container::ContainerListItem, volume::*, }; use crate::docker::DockerClient; impl DockerClient { pub async fn list_volumes( &self, containers: &[ContainerListItem], ) -> anyhow::Result> { let volumes = self .docker .list_volumes(Option::::None) .await? .volumes .unwrap_or_default() .into_iter() .map(|volume| { let scope = volume .scope .map(|scope| match scope { bollard::secret::VolumeScopeEnum::EMPTY => { VolumeScopeEnum::Empty } bollard::secret::VolumeScopeEnum::LOCAL => { VolumeScopeEnum::Local } bollard::secret::VolumeScopeEnum::GLOBAL => { VolumeScopeEnum::Global } }) .unwrap_or(VolumeScopeEnum::Empty); let in_use = containers.iter().any(|container| { container.volumes.iter().any(|name| &volume.name == name) }); VolumeListItem { name: volume.name, driver: volume.driver, mountpoint: volume.mountpoint, created: volume.created_at, size: volume.usage_data.map(|data| data.size), scope, in_use, } }) .collect(); Ok(volumes) } pub async fn inspect_volume( &self, volume_name: &str, ) -> anyhow::Result { let volume = self.docker.inspect_volume(volume_name).await?; Ok(Volume { name: volume.name, driver: volume.driver, mountpoint: volume.mountpoint, created_at: volume.created_at, status: volume.status.unwrap_or_default().into_keys().map(|k| (k, Default::default())).collect(), labels: volume.labels, scope: volume .scope .map(|scope| match scope { bollard::secret::VolumeScopeEnum::EMPTY => { VolumeScopeEnum::Empty } bollard::secret::VolumeScopeEnum::LOCAL => { VolumeScopeEnum::Local } bollard::secret::VolumeScopeEnum::GLOBAL => { VolumeScopeEnum::Global } }) .unwrap_or_default(), cluster_volume: volume.cluster_volume.map(|volume| { ClusterVolume { id: volume.id, version: volume.version.map(|version| ObjectVersion { index: version.index, }), created_at: volume.created_at, updated_at: volume.updated_at, spec: volume.spec.map(|spec| ClusterVolumeSpec { group: spec.group, access_mode: spec.access_mode.map(|mode| { ClusterVolumeSpecAccessMode { scope: mode.scope.map(|scope| match scope { bollard::secret::ClusterVolumeSpecAccessModeScopeEnum::EMPTY => ClusterVolumeSpecAccessModeScopeEnum::Empty, bollard::secret::ClusterVolumeSpecAccessModeScopeEnum::SINGLE => ClusterVolumeSpecAccessModeScopeEnum::Single, bollard::secret::ClusterVolumeSpecAccessModeScopeEnum::MULTI => ClusterVolumeSpecAccessModeScopeEnum::Multi, }).unwrap_or_default(), sharing: mode.sharing.map(|sharing| match sharing { bollard::secret::ClusterVolumeSpecAccessModeSharingEnum::EMPTY => ClusterVolumeSpecAccessModeSharingEnum::Empty, bollard::secret::ClusterVolumeSpecAccessModeSharingEnum::NONE => ClusterVolumeSpecAccessModeSharingEnum::None, bollard::secret::ClusterVolumeSpecAccessModeSharingEnum::READONLY => ClusterVolumeSpecAccessModeSharingEnum::Readonly, bollard::secret::ClusterVolumeSpecAccessModeSharingEnum::ONEWRITER => ClusterVolumeSpecAccessModeSharingEnum::Onewriter, bollard::secret::ClusterVolumeSpecAccessModeSharingEnum::ALL => ClusterVolumeSpecAccessModeSharingEnum::All, }).unwrap_or_default(), secrets: mode.secrets.unwrap_or_default().into_iter().map(|secret| ClusterVolumeSpecAccessModeSecrets { key: secret.key, secret: secret.secret, }).collect(), accessibility_requirements: mode .accessibility_requirements.map(|req| ClusterVolumeSpecAccessModeAccessibilityRequirements { requisite: req.requisite.unwrap_or_default().into_iter().map(|map| map.into_iter().map(|(k, v)| (k, v.unwrap_or_default().into_iter().map(|p| PortBinding { host_ip: p.host_ip, host_port: p.host_port }).collect())).collect()).collect(), preferred: req.preferred.unwrap_or_default().into_iter().map(|map| map.into_iter().map(|(k, v)| (k, v.unwrap_or_default().into_iter().map(|p| PortBinding { host_ip: p.host_ip, host_port: p.host_port }).collect())).collect()).collect(), }), capacity_range: mode.capacity_range.map(|range| ClusterVolumeSpecAccessModeCapacityRange { required_bytes: range.required_bytes, limit_bytes: range.limit_bytes, }), availability: mode.availability.map(|availability| match availability { bollard::secret::ClusterVolumeSpecAccessModeAvailabilityEnum::EMPTY => ClusterVolumeSpecAccessModeAvailabilityEnum::Empty, bollard::secret::ClusterVolumeSpecAccessModeAvailabilityEnum::ACTIVE => ClusterVolumeSpecAccessModeAvailabilityEnum::Active, bollard::secret::ClusterVolumeSpecAccessModeAvailabilityEnum::PAUSE => ClusterVolumeSpecAccessModeAvailabilityEnum::Pause, bollard::secret::ClusterVolumeSpecAccessModeAvailabilityEnum::DRAIN => ClusterVolumeSpecAccessModeAvailabilityEnum::Drain, }).unwrap_or_default(), } }), }), info: volume.info.map(|info| ClusterVolumeInfo { capacity_bytes: info.capacity_bytes, volume_context: info.volume_context.unwrap_or_default(), volume_id: info.volume_id, accessible_topology: info.accessible_topology.unwrap_or_default().into_iter().map(|map| map.into_iter().map(|(k, v)| (k, v.unwrap_or_default().into_iter().map(|p| PortBinding { host_ip: p.host_ip, host_port: p.host_port }).collect())).collect()).collect(), }), publish_status: volume .publish_status .unwrap_or_default() .into_iter() .map(|status| ClusterVolumePublishStatus { node_id: status.node_id, state: status.state.map(|state| match state { bollard::secret::ClusterVolumePublishStatusStateEnum::EMPTY => ClusterVolumePublishStatusStateEnum::Empty, bollard::secret::ClusterVolumePublishStatusStateEnum::PENDING_PUBLISH => ClusterVolumePublishStatusStateEnum::PendingPublish, bollard::secret::ClusterVolumePublishStatusStateEnum::PUBLISHED => ClusterVolumePublishStatusStateEnum::Published, bollard::secret::ClusterVolumePublishStatusStateEnum::PENDING_NODE_UNPUBLISH => ClusterVolumePublishStatusStateEnum::PendingNodeUnpublish, bollard::secret::ClusterVolumePublishStatusStateEnum::PENDING_CONTROLLER_UNPUBLISH => ClusterVolumePublishStatusStateEnum::PendingControllerUnpublish, }).unwrap_or_default(), publish_context: status.publish_context.unwrap_or_default(), }) .collect(), } }), options: volume.options, usage_data: volume.usage_data.map(|data| VolumeUsageData { size: data.size, ref_count: data.ref_count, }), }) } } ================================================ FILE: bin/periphery/src/git.rs ================================================ use std::path::PathBuf; use command::run_komodo_command_with_sanitization; use environment::write_env_file; use interpolate::Interpolator; use komodo_client::entities::{ EnvironmentVar, RepoExecutionResponse, SystemCommand, all_logs_success, }; use periphery_client::api::git::PeripheryRepoExecutionResponse; use crate::config::periphery_config; pub async fn handle_post_repo_execution( mut res: RepoExecutionResponse, mut environment: Vec, env_file_path: &str, mut on_clone: Option, mut on_pull: Option, skip_secret_interp: bool, mut replacers: Vec<(String, String)>, ) -> anyhow::Result { if !skip_secret_interp { let mut interpolotor = Interpolator::new(None, &periphery_config().secrets); interpolotor.interpolate_env_vars(&mut environment)?; if let Some(on_clone) = on_clone.as_mut() { interpolotor.interpolate_string(&mut on_clone.command)?; } if let Some(on_pull) = on_pull.as_mut() { interpolotor.interpolate_string(&mut on_pull.command)?; } replacers.extend(interpolotor.secret_replacers); } let env_file_path = write_env_file( &environment, &res.path, env_file_path, &mut res.logs, ) .await; let mut res = PeripheryRepoExecutionResponse { res, env_file_path }; if let Some(on_clone) = on_clone && !on_clone.is_none() { let path = res .res .path .join(on_clone.path) .components() .collect::(); if let Some(log) = run_komodo_command_with_sanitization( "On Clone", path.as_path(), on_clone.command, true, &replacers, ) .await { res.res.logs.push(log); if !all_logs_success(&res.res.logs) { return Ok(res); } } } if let Some(on_pull) = on_pull && !on_pull.is_none() { let path = res .res .path .join(on_pull.path) .components() .collect::(); if let Some(log) = run_komodo_command_with_sanitization( "On Pull", path.as_path(), on_pull.command, true, &replacers, ) .await { res.res.logs.push(log); } } Ok(res) } ================================================ FILE: bin/periphery/src/helpers.rs ================================================ use anyhow::Context; use komodo_client::{ entities::{EnvironmentVar, RepoExecutionArgs, SearchCombinator}, parsers::QUOTE_PATTERN, }; use crate::config::periphery_config; pub fn git_token_simple( domain: &str, account_username: &str, ) -> anyhow::Result<&'static str> { periphery_config() .git_providers .iter() .find(|provider| provider.domain == domain) .and_then(|provider| { provider.accounts.iter().find(|account| account.username == account_username).map(|account| account.token.as_str()) }) .with_context(|| format!("Did not find token in config for git account {account_username} | domain {domain}")) } pub fn git_token( core_token: Option, args: &RepoExecutionArgs, ) -> anyhow::Result> { if core_token.is_some() { return Ok(core_token); } let Some(account) = &args.account else { return Ok(None); }; let token = git_token_simple(&args.provider, account)?; Ok(Some(token.to_string())) } pub fn registry_token( domain: &str, account_username: &str, ) -> anyhow::Result<&'static str> { periphery_config() .docker_registries .iter() .find(|registry| registry.domain == domain) .and_then(|registry| { registry.accounts.iter().find(|account| account.username == account_username).map(|account| account.token.as_str()) }) .with_context(|| format!("did not find token in config for docker registry account {account_username} | domain {domain}")) } pub fn parse_extra_args(extra_args: &[String]) -> String { let args = extra_args.join(" "); if !args.is_empty() { format!(" {args}") } else { args } } pub fn parse_labels(labels: &[EnvironmentVar]) -> String { labels .iter() .map(|p| { if p.value.starts_with(QUOTE_PATTERN) && p.value.ends_with(QUOTE_PATTERN) { // If the value already wrapped in quotes, don't wrap it again format!(" --label {}={}", p.variable, p.value) } else { format!(" --label {}=\"{}\"", p.variable, p.value) } }) .collect::>() .join("") } pub fn log_grep( terms: &[String], combinator: SearchCombinator, invert: bool, ) -> String { let maybe_invert = if invert { " -v" } else { Default::default() }; match combinator { SearchCombinator::Or => { format!("grep{maybe_invert} -E '{}'", terms.join("|")) } SearchCombinator::And => { format!( "grep{maybe_invert} -P '^(?=.*{})'", terms.join(")(?=.*") ) } } } ================================================ FILE: bin/periphery/src/main.rs ================================================ #[macro_use] extern crate tracing; // use std::{net::SocketAddr, str::FromStr}; use anyhow::Context; use axum_server::tls_rustls::RustlsConfig; use config::periphery_config; mod api; mod build; mod compose; mod config; mod docker; mod git; mod helpers; mod ssl; mod stats; mod terminal; async fn app() -> anyhow::Result<()> { dotenvy::dotenv().ok(); let config = config::periphery_config(); logger::init(&config.logging)?; info!("Komodo Periphery version: v{}", env!("CARGO_PKG_VERSION")); if periphery_config().pretty_startup_config { info!("{:#?}", config.sanitized()); } else { info!("{:?}", config.sanitized()); } stats::spawn_polling_thread(); docker::stats::spawn_polling_thread(); let addr = format!( "{}:{}", config::periphery_config().bind_ip, config::periphery_config().port ); let socket_addr = SocketAddr::from_str(&addr) .context("failed to parse listen address")?; let app = api::router().into_make_service_with_connect_info::(); if config.ssl_enabled { info!("🔒 Periphery SSL Enabled"); rustls::crypto::ring::default_provider() .install_default() .expect("failed to install default rustls CryptoProvider"); ssl::ensure_certs().await; info!("Komodo Periphery starting on https://{}", socket_addr); let ssl_config = RustlsConfig::from_pem_file( config.ssl_cert_file(), config.ssl_key_file(), ) .await .context("Invalid ssl cert / key")?; axum_server::bind_rustls(socket_addr, ssl_config) .serve(app) .await? } else { info!("🔓 Periphery SSL Disabled"); info!("Komodo Periphery starting on http://{}", socket_addr); axum_server::bind(socket_addr).serve(app).await? } Ok(()) } #[tokio::main] async fn main() -> anyhow::Result<()> { let mut term_signal = tokio::signal::unix::signal( tokio::signal::unix::SignalKind::terminate(), )?; let app = tokio::spawn(app()); tokio::select! { res = app => return res?, _ = term_signal.recv() => { info!("Exiting all active Terminals for shutdown"); terminal::delete_all_terminals().await; }, } Ok(()) } ================================================ FILE: bin/periphery/src/ssl.rs ================================================ use crate::config::periphery_config; pub async fn ensure_certs() { let config = periphery_config(); if !config.ssl_cert_file().is_file() || !config.ssl_key_file().is_file() { generate_self_signed_ssl_certs().await } } #[instrument] async fn generate_self_signed_ssl_certs() { info!("Generating certs..."); let config = periphery_config(); let ssl_key_file = config.ssl_key_file(); let ssl_cert_file = config.ssl_cert_file(); // ensure cert folders exist if let Some(parent) = ssl_key_file.parent() { let _ = std::fs::create_dir_all(parent); } if let Some(parent) = ssl_cert_file.parent() { let _ = std::fs::create_dir_all(parent); } let key_path = ssl_key_file.display(); let cert_path = ssl_cert_file.display(); let command = format!( "openssl req -x509 -newkey rsa:4096 -keyout {key_path} -out {cert_path} -sha256 -days 3650 -nodes -subj \"/C=XX/CN=periphery\"" ); let log = run_command::async_run_command(&command).await; if log.success() { info!("✅ SSL Certs generated"); } else { panic!( "🚨 Failed to generate SSL Certs | stdout: {} | stderr: {}", log.stdout, log.stderr ); } } ================================================ FILE: bin/periphery/src/stats.rs ================================================ use std::{cmp::Ordering, sync::OnceLock}; use async_timing_util::wait_until_timelength; use komodo_client::entities::stats::{ SingleDiskUsage, SystemInformation, SystemLoadAverage, SystemProcess, SystemStats, }; use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System}; use tokio::sync::RwLock; use crate::config::periphery_config; pub fn stats_client() -> &'static RwLock { static STATS_CLIENT: OnceLock> = OnceLock::new(); STATS_CLIENT.get_or_init(|| RwLock::new(StatsClient::default())) } /// This should be called before starting the server in main.rs. /// Keeps the cached stats up to date pub fn spawn_polling_thread() { tokio::spawn(async move { let polling_rate = periphery_config() .stats_polling_rate .to_string() .parse() .expect("invalid stats polling rate"); let client = stats_client(); loop { let ts = wait_until_timelength(polling_rate, 1).await; let mut client = client.write().await; client.refresh(); client.stats = client.get_system_stats(); client.stats.refresh_ts = ts as i64; } }); } pub struct StatsClient { /// Cached system stats pub stats: SystemStats, /// Cached system information pub info: SystemInformation, // the handles used to get the stats system: sysinfo::System, disks: sysinfo::Disks, networks: sysinfo::Networks, } const BYTES_PER_GB: f64 = 1073741824.0; const BYTES_PER_MB: f64 = 1048576.0; const BYTES_PER_KB: f64 = 1024.0; impl Default for StatsClient { fn default() -> Self { let system = sysinfo::System::new_all(); let disks = sysinfo::Disks::new_with_refreshed_list(); let networks = sysinfo::Networks::new_with_refreshed_list(); let stats = SystemStats { polling_rate: periphery_config().stats_polling_rate, ..Default::default() }; StatsClient { info: get_system_information(&system), system, disks, networks, stats, } } } impl StatsClient { fn refresh(&mut self) { self.system.refresh_cpu_all(); self.system.refresh_memory(); self.system.refresh_processes_specifics( ProcessesToUpdate::All, true, ProcessRefreshKind::everything().without_tasks(), ); self.disks.refresh(true); self.networks.refresh(true); } pub fn get_system_stats(&self) -> SystemStats { let total_mem = self.system.total_memory(); let available_mem = self.system.available_memory(); let mut network_ingress_bytes: u64 = 0; let mut network_egress_bytes: u64 = 0; for (_, network) in self.networks.iter() { network_ingress_bytes += network.received(); network_egress_bytes += network.transmitted(); } let load_avg = System::load_average(); SystemStats { cpu_perc: self.system.global_cpu_usage(), load_average: SystemLoadAverage { one: load_avg.one, five: load_avg.five, fifteen: load_avg.fifteen, }, mem_free_gb: self.system.free_memory() as f64 / BYTES_PER_GB, mem_used_gb: (total_mem - available_mem) as f64 / BYTES_PER_GB, mem_total_gb: total_mem as f64 / BYTES_PER_GB, network_ingress_bytes: network_ingress_bytes as f64, network_egress_bytes: network_egress_bytes as f64, disks: self.get_disks(), polling_rate: self.stats.polling_rate, refresh_ts: self.stats.refresh_ts, refresh_list_ts: self.stats.refresh_list_ts, } } fn get_disks(&self) -> Vec { let config = periphery_config(); self .disks .list() .iter() .filter(|d| { if d.file_system() == "overlay" { return false; } let path = d.mount_point(); for mount in config.exclude_disk_mounts.iter() { if path == mount { return false; } } if config.include_disk_mounts.is_empty() { return true; } for mount in config.include_disk_mounts.iter() { if path == mount { return true; } } false }) .map(|disk| { let file_system = disk.file_system().to_string_lossy().to_string(); let disk_total = disk.total_space() as f64 / BYTES_PER_GB; let disk_free = disk.available_space() as f64 / BYTES_PER_GB; SingleDiskUsage { mount: disk.mount_point().to_owned(), used_gb: disk_total - disk_free, total_gb: disk_total, file_system, } }) .collect() } pub fn get_processes(&self) -> Vec { let mut procs: Vec<_> = self .system .processes() .iter() .map(|(pid, p)| { let disk_usage = p.disk_usage(); SystemProcess { pid: pid.as_u32(), name: p.name().to_string_lossy().to_string(), exe: p .exe() .map(|exe| exe.to_str().unwrap_or_default()) .unwrap_or_default() .to_string(), cmd: p .cmd() .iter() .map(|cmd| cmd.to_string_lossy().to_string()) .collect(), start_time: (p.start_time() * 1000) as f64, cpu_perc: p.cpu_usage(), mem_mb: p.memory() as f64 / BYTES_PER_MB, disk_read_kb: disk_usage.read_bytes as f64 / BYTES_PER_KB, disk_write_kb: disk_usage.written_bytes as f64 / BYTES_PER_KB, } }) .collect(); procs.sort_by(|a, b| { if a.cpu_perc > b.cpu_perc { Ordering::Less } else { Ordering::Greater } }); procs } } fn get_system_information( sys: &sysinfo::System, ) -> SystemInformation { let config = periphery_config(); SystemInformation { name: System::name(), os: System::long_os_version(), kernel: System::kernel_version(), host_name: System::host_name(), core_count: System::physical_core_count().map(|c| c as u32), cpu_brand: sys .cpus() .iter() .next() .map(|cpu| cpu.brand().to_string()) .unwrap_or_default(), terminals_disabled: config.disable_terminals, container_exec_disabled: config.disable_container_exec, } } ================================================ FILE: bin/periphery/src/terminal.rs ================================================ use std::{ collections::{HashMap, VecDeque}, pin::Pin, sync::{Arc, OnceLock}, task::Poll, time::Duration, }; use anyhow::{Context, anyhow}; use axum::http::StatusCode; use bytes::Bytes; use futures::Stream; use komodo_client::{ api::write::TerminalRecreateMode, entities::{komodo_timestamp, server::TerminalInfo}, }; use pin_project_lite::pin_project; use portable_pty::{CommandBuilder, PtySize, native_pty_system}; use rand::Rng; use serror::AddStatusCodeError; use tokio::sync::{broadcast, mpsc}; use tokio_util::sync::CancellationToken; type PtyName = String; type PtyMap = tokio::sync::RwLock>>; type StdinSender = mpsc::Sender; type StdoutReceiver = broadcast::Receiver; pub async fn create_terminal( name: String, command: String, recreate: TerminalRecreateMode, ) -> anyhow::Result<()> { trace!( "CreateTerminal: {name} | command: {command} | recreate: {recreate:?}" ); let mut terminals = terminals().write().await; use TerminalRecreateMode::*; if matches!(recreate, Never | DifferentCommand) && let Some(terminal) = terminals.get(&name) { if terminal.command == command { return Ok(()); } else if matches!(recreate, Never) { return Err(anyhow!( "Terminal {name} already exists, but has command {} instead of {command}", terminal.command )); } } if let Some(prev) = terminals.insert( name, Terminal::new(command) .await .context("Failed to init terminal")? .into(), ) { prev.cancel(); } Ok(()) } pub async fn delete_terminal(name: &str) { if let Some(terminal) = terminals().write().await.remove(name) { terminal.cancel.cancel(); } } pub async fn list_terminals() -> Vec { let mut terminals = terminals() .read() .await .iter() .map(|(name, terminal)| TerminalInfo { name: name.to_string(), command: terminal.command.clone(), stored_size_kb: terminal.history.size_kb(), }) .collect::>(); terminals.sort_by(|a, b| a.name.cmp(&b.name)); terminals } pub async fn get_terminal( name: &str, ) -> anyhow::Result> { terminals() .read() .await .get(name) .cloned() .with_context(|| format!("No terminal at {name}")) } pub async fn clean_up_terminals() { terminals() .write() .await .retain(|_, terminal| !terminal.cancel.is_cancelled()); } pub async fn delete_all_terminals() { terminals() .write() .await .drain() .for_each(|(_, terminal)| terminal.cancel()); // The terminals poll cancel every 500 millis, need to wait for them // to finish cancelling. tokio::time::sleep(Duration::from_millis(100)).await; } fn terminals() -> &'static PtyMap { static TERMINALS: OnceLock = OnceLock::new(); TERMINALS.get_or_init(Default::default) } #[derive(Clone, serde::Deserialize)] pub struct ResizeDimensions { rows: u16, cols: u16, } #[derive(Clone)] pub enum StdinMsg { Bytes(Bytes), Resize(ResizeDimensions), } pub struct Terminal { /// The command that was used as the root command, eg `shell` command: String, pub cancel: CancellationToken, pub stdin: StdinSender, pub stdout: StdoutReceiver, pub history: Arc, } impl Terminal { async fn new(command: String) -> anyhow::Result { trace!("Creating terminal with command: {command}"); let terminal = native_pty_system() .openpty(PtySize::default()) .context("Failed to open terminal")?; let mut command_split = command.split(' ').map(|arg| arg.trim()); let cmd = command_split.next().context("Command cannot be empty")?; let mut cmd = CommandBuilder::new(cmd); for arg in command_split { cmd.arg(arg); } cmd.env("TERM", "xterm-256color"); cmd.env("COLORTERM", "truecolor"); let mut child = terminal .slave .spawn_command(cmd) .context("Failed to spawn child command")?; // Check the child didn't stop immediately (after a little wait) with error tokio::time::sleep(Duration::from_millis(100)).await; if let Some(status) = child .try_wait() .context("Failed to check child process exit status")? { return Err(anyhow!( "Child process exited immediately with code {}", status.exit_code() )); } let mut terminal_write = terminal .master .take_writer() .context("Failed to take terminal writer")?; let mut terminal_read = terminal .master .try_clone_reader() .context("Failed to clone terminal reader")?; let cancel = CancellationToken::new(); // CHILD WAIT TASK let _cancel = cancel.clone(); tokio::task::spawn_blocking(move || { loop { if _cancel.is_cancelled() { trace!("child wait handle cancelled from outside"); if let Err(e) = child.kill() { debug!("Failed to kill child | {e:?}"); } break; } match child.try_wait() { Ok(Some(code)) => { debug!("child exited with code {code}"); _cancel.cancel(); break; } Ok(None) => { std::thread::sleep(Duration::from_millis(500)); } Err(e) => { debug!("failed to wait for child | {e:?}"); _cancel.cancel(); break; } } } }); // WS (channel) -> STDIN TASK // Theres only one consumer here, so use mpsc let (stdin, mut channel_read) = tokio::sync::mpsc::channel::(8192); let _cancel = cancel.clone(); tokio::task::spawn_blocking(move || { loop { if _cancel.is_cancelled() { trace!("terminal write: cancelled from outside"); break; } match channel_read.blocking_recv() { Some(StdinMsg::Bytes(bytes)) => { if let Err(e) = terminal_write.write_all(&bytes) { debug!("Failed to write to PTY: {e:?}"); _cancel.cancel(); break; } } Some(StdinMsg::Resize(dimensions)) => { if let Err(e) = terminal.master.resize(PtySize { cols: dimensions.cols, rows: dimensions.rows, pixel_width: 0, pixel_height: 0, }) { debug!("Failed to resize | {e:?}"); _cancel.cancel(); break; }; } None => { debug!("WS -> PTY channel read error: Disconnected"); _cancel.cancel(); break; } } } }); let history = Arc::new(History::default()); // PTY -> WS (channel) TASK // Uses broadcast to output to multiple client simultaneously let (write, stdout) = tokio::sync::broadcast::channel::(8192); let _cancel = cancel.clone(); let _history = history.clone(); tokio::task::spawn_blocking(move || { let mut buf = [0u8; 8192]; loop { if _cancel.is_cancelled() { trace!("terminal read: cancelled from outside"); break; } match terminal_read.read(&mut buf) { Ok(0) => { // EOF trace!("Got PTY read EOF"); _cancel.cancel(); break; } Ok(n) => { _history.push(&buf[..n]); if let Err(e) = write.send(Bytes::copy_from_slice(&buf[..n])) { debug!("PTY -> WS channel send error: {e:?}"); _cancel.cancel(); break; } } Err(e) => { debug!("Failed to read for PTY: {e:?}"); _cancel.cancel(); break; } } } }); trace!("terminal tasks spawned"); Ok(Terminal { command, cancel, stdin, stdout, history, }) } pub fn cancel(&self) { trace!("Cancel called"); self.cancel.cancel(); } } /// 1 MiB rolling max history size per terminal const MAX_BYTES: usize = 1024 * 1024; pub struct History { buf: std::sync::RwLock>, } impl Default for History { fn default() -> Self { History { buf: VecDeque::with_capacity(MAX_BYTES).into(), } } } impl History { /// Push some bytes, evicting the oldest when full. fn push(&self, bytes: &[u8]) { let mut buf = self.buf.write().unwrap(); for byte in bytes { if buf.len() == MAX_BYTES { buf.pop_front(); } buf.push_back(*byte); } } pub fn bytes_parts(&self) -> (Bytes, Bytes) { let buf = self.buf.read().unwrap(); let (a, b) = buf.as_slices(); (Bytes::copy_from_slice(a), Bytes::copy_from_slice(b)) } pub fn size_kb(&self) -> f64 { self.buf.read().unwrap().len() as f64 / 1024.0 } } /// Execute Sentinels pub const START_OF_OUTPUT: &str = "__KOMODO_START_OF_OUTPUT__"; pub const END_OF_OUTPUT: &str = "__KOMODO_END_OF_OUTPUT__"; pin_project! { pub struct TerminalStream { #[pin] pub stdout: S } } impl Stream for TerminalStream where S: Stream>, { // Axum expects a stream of results type Item = Result; fn poll_next( self: Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> Poll> { let this = self.project(); match this.stdout.poll_next(cx) { Poll::Ready(None) => { // This is if a None comes in before END_OF_OUTPUT. // This probably means the terminal has exited early, // and needs to be cleaned up tokio::spawn(async move { clean_up_terminals().await }); Poll::Ready(None) } Poll::Ready(Some(line)) => { match line { Ok(line) if line.as_str() == END_OF_OUTPUT => { // Stop the stream on end sentinel Poll::Ready(None) } Ok(line) => Poll::Ready(Some(Ok(line + "\n"))), Err(e) => Poll::Ready(Some(Err(format!("{e:?}")))), } } Poll::Pending => Poll::Pending, } } } /// Tokens valid for 3 seconds const TOKEN_VALID_FOR_MS: i64 = 3_000; pub fn auth_tokens() -> &'static AuthTokens { static AUTH_TOKENS: OnceLock = OnceLock::new(); AUTH_TOKENS.get_or_init(Default::default) } #[derive(Default)] pub struct AuthTokens { map: std::sync::Mutex>, } impl AuthTokens { pub fn create_auth_token(&self) -> String { let mut lock = self.map.lock().unwrap(); // clear out any old tokens here (prevent unbounded growth) let ts = komodo_timestamp(); lock.retain(|_, valid_until| *valid_until > ts); let token: String = rand::rng() .sample_iter(&rand::distr::Alphanumeric) .take(30) .map(char::from) .collect(); lock.insert(token.clone(), ts + TOKEN_VALID_FOR_MS); token } pub fn check_token(&self, token: String) -> serror::Result<()> { let Some(valid_until) = self.map.lock().unwrap().remove(&token) else { return Err( anyhow!("Terminal auth token not found") .status_code(StatusCode::UNAUTHORIZED), ); }; if komodo_timestamp() <= valid_until { Ok(()) } else { Err( anyhow!("Terminal token is expired") .status_code(StatusCode::UNAUTHORIZED), ) } } } ================================================ FILE: bin/periphery/starship.toml ================================================ ## This is used to customize the shell prompt in Periphery container for Terminals "$schema" = 'https://starship.rs/config-schema.json' add_newline = true format = "$time$hostname$container$memory_usage$all" [character] success_symbol = "[❯](bright-blue bold)" error_symbol = "[❯](bright-red bold)" [package] disabled = true [time] format = "[❯$time](white dimmed) " time_format = "%l:%M %p" utc_time_offset = '-5' disabled = true [username] format = "[❯ $user]($style) " style_user = "bright-green" show_always = true [hostname] format = "[❯ $hostname]($style) " style = "bright-blue" ssh_only = false [directory] format = "[❯ $path]($style)[$read_only]($read_only_style) " style = "bright-cyan" [git_branch] format = "[❯ $symbol$branch(:$remote_branch)]($style) " style = "bright-purple" [git_status] style = "bright-purple" [rust] format = "[❯ $symbol($version )]($style)" symbol = "rustc " style = "bright-red" [nodejs] format = "[❯ $symbol($version )]($style)" symbol = "nodejs " style = "bright-red" [memory_usage] format = "[❯ mem ${ram} ${ram_pct}]($style) " threshold = -1 style = "white" [cmd_duration] format = "[❯ $duration]($style)" style = "bright-yellow" [container] format = "[❯ 🦎 periphery container ]($style)" style = "bright-green" [aws] disabled = true ================================================ FILE: client/core/rs/Cargo.toml ================================================ [package] name = "komodo_client" description = "Client for the Komodo build and deployment system" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true homepage.workspace = true repository.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] # default = ["blocking"] # use to dev client blocking mode mongo = ["dep:mongo_indexed"] blocking = ["reqwest/blocking"] [dependencies] # mogh mongo_indexed = { workspace = true, optional = true } serror = { workspace = true, features = ["axum"]} derive_default_builder.workspace = true derive_empty_traits.workspace = true async_timing_util.workspace = true partial_derive2.workspace = true derive_variants.workspace = true resolver_api.workspace = true # external tokio-tungstenite.workspace = true derive_builder.workspace = true urlencoding.workspace = true serde_json.workspace = true tokio-util.workspace = true thiserror.workspace = true typeshare.workspace = true indexmap.workspace = true serde_qs.workspace = true futures.workspace = true reqwest.workspace = true tracing.workspace = true anyhow.workspace = true serde.workspace = true tokio.workspace = true strum.workspace = true envy.workspace = true uuid.workspace = true clap.workspace = true bson.workspace = true ipnetwork.workspace = true ================================================ FILE: client/core/rs/README.md ================================================ # Komodo *A system to build and deploy software across many servers*. [https://komo.do](https://komo.do) Docs: [https://docs.rs/komodo_client/latest/komodo_client](https://docs.rs/komodo_client/latest/komodo_client). This is a client library for the Komodo Core API. It contains: - Definitions for the application [api](https://docs.rs/komodo_client/latest/komodo_client/api/index.html) and [entities](https://docs.rs/komodo_client/latest/komodo_client/entities/index.html). - A [client](https://docs.rs/komodo_client/latest/komodo_client/struct.KomodoClient.html) to interact with the Komodo Core API. - Information on configuring Komodo [Core](https://docs.rs/komodo_client/latest/komodo_client/entities/config/core/index.html) and [Periphery](https://docs.rs/komodo_client/latest/komodo_client/entities/config/periphery/index.html). ## Client Configuration The client includes a convenenience method to parse the Komodo API url and credentials from the environment: - `KOMODO_ADDRESS` - `KOMODO_API_KEY` - `KOMODO_API_SECRET` ## Client Example ```rust dotenvy::dotenv().ok(); let client = KomodoClient::new_from_env()?; // Get all the deployments let deployments = client.read(ListDeployments::default()).await?; println!("{deployments:#?}"); let update = client.execute(RunBuild { build: "test-build".to_string() }).await?: ``` ================================================ FILE: client/core/rs/src/api/auth.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::{HasResponse, Resolve}; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::user::User; pub trait KomodoAuthRequest: HasResponse {} /// JSON containing an authentication token. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct JwtResponse { /// User ID for signed in user. pub user_id: String, /// A token the user can use to authenticate their requests. pub jwt: String, } // /// Non authenticated route to see the available options /// users have to login to Komodo, eg. local auth, github, google. /// Response: [GetLoginOptionsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoAuthRequest)] #[response(GetLoginOptionsResponse)] #[error(serror::Error)] pub struct GetLoginOptions {} /// The response for [GetLoginOptions]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Copy)] pub struct GetLoginOptionsResponse { /// Whether local auth is enabled. pub local: bool, /// Whether github login is enabled. pub github: bool, /// Whether google login is enabled. pub google: bool, /// Whether OIDC login is enabled. pub oidc: bool, /// Whether user registration (Sign Up) has been disabled pub registration_disabled: bool, } // /// Sign up a new local user account. Will fail if a user with the /// given username already exists. /// Response: [SignUpLocalUserResponse]. /// /// Note. This method is only available if the core api has `local_auth` enabled, /// and if user registration is not disabled (after the first user). #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoAuthRequest)] #[response(SignUpLocalUserResponse)] #[error(serror::Error)] pub struct SignUpLocalUser { /// The username for the new user. pub username: String, /// The password for the new user. /// This cannot be retreived later. pub password: String, } /// Response for [SignUpLocalUser]. #[typeshare] pub type SignUpLocalUserResponse = JwtResponse; // /// Login as a local user. Will fail if the users credentials don't match /// any local user. /// /// Note. This method is only available if the core api has `local_auth` enabled. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoAuthRequest)] #[response(LoginLocalUserResponse)] #[error(serror::Error)] pub struct LoginLocalUser { /// The user's username pub username: String, /// The user's password pub password: String, } /// The response for [LoginLocalUser] #[typeshare] pub type LoginLocalUserResponse = JwtResponse; // /// Exchange a single use exchange token (safe for transport in url query) /// for a jwt. /// Response: [ExchangeForJwtResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoAuthRequest)] #[response(ExchangeForJwtResponse)] #[error(serror::Error)] pub struct ExchangeForJwt { /// The 'exchange token' pub token: String, } /// Response for [ExchangeForJwt]. #[typeshare] pub type ExchangeForJwtResponse = JwtResponse; // /// Get the user extracted from the request headers. /// Response: [User]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoAuthRequest)] #[response(GetUserResponse)] #[error(serror::Error)] pub struct GetUser {} #[typeshare] pub type GetUserResponse = User; // ================================================ FILE: client/core/rs/src/api/execute/action.rs ================================================ use anyhow::Context; use clap::Parser; use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{JsonObject, update::Update}; use super::{BatchExecutionResponse, KomodoExecuteRequest}; /// Runs the target Action. Response: [Update] #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RunAction { /// Id or name pub action: String, /// Custom arguments which are merged on top of the default arguments. /// CLI Format: `"VAR1=val1&VAR2=val2"` /// /// Webhook-triggered actions use this to pass WEBHOOK_BRANCH and WEBHOOK_BODY. #[clap(value_parser = args_parser)] pub args: Option, } fn args_parser(args: &str) -> anyhow::Result { serde_qs::from_str(args).context("Failed to parse args") } /// Runs multiple Actions in parallel that match pattern. Response: [BatchExecutionResponse] #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(BatchExecutionResponse)] #[error(serror::Error)] pub struct BatchRunAction { /// Id or name or wildcard pattern or regex. /// Supports multiline and comma delineated combinations of the above. /// /// Example: /// ```text /// # match all foo-* actions /// foo-* /// # add some more /// extra-action-1, extra-action-2 /// ``` pub pattern: String, } ================================================ FILE: client/core/rs/src/api/execute/alerter.rs ================================================ use clap::Parser; use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{alert::SeverityLevel, update::Update}; use super::KomodoExecuteRequest; /// Tests an Alerters ability to reach the configured endpoint. Response: [Update] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct TestAlerter { /// Name or id pub alerter: String, } // /// Send a custom alert message to configured Alerters. Response: [Update] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct SendAlert { /// The alert level. #[serde(default)] #[clap(long, short = 'l', default_value_t = SeverityLevel::Ok)] pub level: SeverityLevel, /// The alert message. Required. pub message: String, /// The alert details. Optional. #[serde(default)] #[arg(long, short = 'd', default_value_t = String::new())] pub details: String, /// Specific alerter names or ids. /// If empty / not passed, sends to all configured alerters /// with the `Custom` alert type whitelisted / not blacklisted. #[serde(default)] #[arg(long, short = 'a')] pub alerters: Vec, } ================================================ FILE: client/core/rs/src/api/execute/build.rs ================================================ use clap::Parser; use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::update::Update; use super::{BatchExecutionResponse, KomodoExecuteRequest}; // /// Runs the target build. Response: [Update]. /// /// 1. Get a handle to the builder. If using AWS builder, this means starting a builder ec2 instance. /// /// 2. Clone the repo on the builder. If an `on_clone` commmand is given, it will be executed. /// /// 3. Execute `docker build {...params}`, where params are determined using the builds configuration. /// /// 4. If a docker registry is configured, the build will be pushed to the registry. /// /// 5. If using AWS builder, destroy the builder ec2 instance. /// /// 6. Deploy any Deployments with *Redeploy on Build* enabled. #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RunBuild { /// Can be build id or name pub build: String, } // /// Runs multiple builds in parallel that match pattern. Response: [BatchExecutionResponse]. #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(BatchExecutionResponse)] #[error(serror::Error)] pub struct BatchRunBuild { /// Id or name or wildcard pattern or regex. /// Supports multiline and comma delineated combinations of the above. /// /// Example: /// ```text /// # match all foo-* builds /// foo-* /// # add some more /// extra-build-1, extra-build-2 /// ``` pub pattern: String, } // /// Cancels the target build. /// Only does anything if the build is `building` when called. /// Response: [Update] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct CancelBuild { /// Can be id or name pub build: String, } ================================================ FILE: client/core/rs/src/api/execute/deployment.rs ================================================ use clap::Parser; use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{TerminationSignal, update::Update}; use super::{BatchExecutionResponse, KomodoExecuteRequest}; /// Deploys the container for the target deployment. Response: [Update]. /// /// 1. Pulls the image onto the target server. /// 2. If the container is already running, /// it will be stopped and removed using `docker container rm ${container_name}`. /// 3. The container will be run using `docker run {...params}`, /// where params are determined by the deployment's configuration. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct Deploy { /// Name or id pub deployment: String, /// Override the default termination signal specified in the deployment. /// Only used when deployment needs to be taken down before redeploy. pub stop_signal: Option, /// Override the default termination max time. /// Only used when deployment needs to be taken down before redeploy. pub stop_time: Option, } // /// Deploys multiple Deployments in parallel that match pattern. Response: [BatchExecutionResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(BatchExecutionResponse)] #[error(serror::Error)] pub struct BatchDeploy { /// Id or name or wildcard pattern or regex. /// Supports multiline and comma delineated combinations of the above. /// /// Example: /// ```text /// # match all foo-* deployments /// foo-* /// # add some more /// extra-deployment-1, extra-deployment-2 /// ``` pub pattern: String, } // /// Pulls the image for the target deployment. Response: [Update] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct PullDeployment { /// Name or id pub deployment: String, } // /// Starts the container for the target deployment. Response: [Update] /// /// 1. Runs `docker start ${container_name}`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct StartDeployment { /// Name or id pub deployment: String, } // /// Restarts the container for the target deployment. Response: [Update] /// /// 1. Runs `docker restart ${container_name}`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RestartDeployment { /// Name or id pub deployment: String, } // /// Pauses the container for the target deployment. Response: [Update] /// /// 1. Runs `docker pause ${container_name}`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct PauseDeployment { /// Name or id pub deployment: String, } // /// Unpauses the container for the target deployment. Response: [Update] /// /// 1. Runs `docker unpause ${container_name}`. /// /// Note. This is the only way to restart a paused container. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct UnpauseDeployment { /// Name or id pub deployment: String, } // /// Stops the container for the target deployment. Response: [Update] /// /// 1. Runs `docker stop ${container_name}`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct StopDeployment { /// Name or id pub deployment: String, /// Override the default termination signal specified in the deployment. pub signal: Option, /// Override the default termination max time. pub time: Option, } // /// Stops and destroys the container for the target deployment. /// Reponse: [Update]. /// /// 1. The container is stopped and removed using `docker container rm ${container_name}`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct DestroyDeployment { /// Name or id. pub deployment: String, /// Override the default termination signal specified in the deployment. pub signal: Option, /// Override the default termination max time. pub time: Option, } // /// Destroys multiple Deployments in parallel that match pattern. Response: [BatchExecutionResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(BatchExecutionResponse)] #[error(serror::Error)] pub struct BatchDestroyDeployment { /// Id or name or wildcard pattern or regex. /// Supports multiline and comma delineated combinations of the above. /// /// Example: /// ```text /// # match all foo-* deployments /// foo-* /// # add some more /// extra-deployment-1, extra-deployment-2 /// ``` pub pattern: String, } ================================================ FILE: client/core/rs/src/api/execute/maintenance.rs ================================================ use clap::Parser; use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::update::Update; use super::KomodoExecuteRequest; /// Clears all repos from the Core repo cache. Admin only. /// Response: [Update] #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct ClearRepoCache {} /// Backs up the Komodo Core database to compressed jsonl files. /// Admin only. Response: [Update] /// /// Mount a folder to `/backups`, and Core will use it to create /// timestamped database dumps, which can be restored using /// the Komodo CLI. /// /// https://komo.do/docs/setup/backup #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct BackupCoreDatabase {} /// Trigger a global poll for image updates on Stacks and Deployments /// with `poll_for_updates` or `auto_update` enabled. /// Admin only. Response: [Update] /// /// 1. `docker compose pull` any Stacks / Deployments with `poll_for_updates` or `auto_update` enabled. This will pick up any available updates. /// 2. Redeploy Stacks / Deployments that have updates found and 'auto_update' enabled. #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct GlobalAutoUpdate {} ================================================ FILE: client/core/rs/src/api/execute/mod.rs ================================================ use clap::{Parser, Subcommand}; use derive_variants::EnumVariants; use resolver_api::HasResponse; use serde::{Deserialize, Serialize}; use strum::{Display, EnumString}; use typeshare::typeshare; mod action; mod alerter; mod build; mod deployment; mod maintenance; mod procedure; mod repo; mod server; mod stack; mod sync; pub use action::*; pub use alerter::*; pub use build::*; pub use deployment::*; pub use maintenance::*; pub use procedure::*; pub use repo::*; pub use server::*; pub use stack::*; pub use sync::*; use crate::{ api::write::CommitSync, entities::{_Serror, I64, NoData, update::Update}, }; pub trait KomodoExecuteRequest: HasResponse {} /// A wrapper for all Komodo exections. #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, EnumVariants, Subcommand, )] #[variant_derive( Debug, Clone, Copy, Serialize, Deserialize, Display, EnumString )] #[serde(tag = "type", content = "params")] pub enum Execution { /// The "null" execution. Does nothing. None(NoData), // ACTION /// Run the target action. (alias: `action`, `ac`) #[clap(alias = "action", alias = "ac")] RunAction(RunAction), BatchRunAction(BatchRunAction), // PROCEDURE /// Run the target procedure. (alias: `procedure`, `pr`) #[clap(alias = "procedure", alias = "pr")] RunProcedure(RunProcedure), BatchRunProcedure(BatchRunProcedure), // BUILD /// Run the target build. (alias: `build`, `bd`) #[clap(alias = "build", alias = "bd")] RunBuild(RunBuild), BatchRunBuild(BatchRunBuild), CancelBuild(CancelBuild), // DEPLOYMENT /// Deploy the target deployment. (alias: `dp`) #[clap(alias = "dp")] Deploy(Deploy), BatchDeploy(BatchDeploy), PullDeployment(PullDeployment), StartDeployment(StartDeployment), RestartDeployment(RestartDeployment), PauseDeployment(PauseDeployment), UnpauseDeployment(UnpauseDeployment), StopDeployment(StopDeployment), DestroyDeployment(DestroyDeployment), BatchDestroyDeployment(BatchDestroyDeployment), // REPO /// Clone the target repo #[clap(alias = "clone")] CloneRepo(CloneRepo), BatchCloneRepo(BatchCloneRepo), PullRepo(PullRepo), BatchPullRepo(BatchPullRepo), BuildRepo(BuildRepo), BatchBuildRepo(BatchBuildRepo), CancelRepoBuild(CancelRepoBuild), // SERVER (Container) StartContainer(StartContainer), RestartContainer(RestartContainer), PauseContainer(PauseContainer), UnpauseContainer(UnpauseContainer), StopContainer(StopContainer), DestroyContainer(DestroyContainer), StartAllContainers(StartAllContainers), RestartAllContainers(RestartAllContainers), PauseAllContainers(PauseAllContainers), UnpauseAllContainers(UnpauseAllContainers), StopAllContainers(StopAllContainers), PruneContainers(PruneContainers), // SERVER (Prune) DeleteNetwork(DeleteNetwork), PruneNetworks(PruneNetworks), DeleteImage(DeleteImage), PruneImages(PruneImages), DeleteVolume(DeleteVolume), PruneVolumes(PruneVolumes), PruneDockerBuilders(PruneDockerBuilders), PruneBuildx(PruneBuildx), PruneSystem(PruneSystem), // SYNC /// Execute a Resource Sync. (alias: `sync`) #[clap(alias = "sync")] RunSync(RunSync), /// Commit a Resource Sync. (alias: `commit`) #[clap(alias = "commit")] CommitSync(CommitSync), // This is a special case, its actually a write operation. // STACK /// Deploy the target stack. (alias: `stack`, `st`) #[clap(alias = "stack", alias = "st")] DeployStack(DeployStack), BatchDeployStack(BatchDeployStack), DeployStackIfChanged(DeployStackIfChanged), BatchDeployStackIfChanged(BatchDeployStackIfChanged), PullStack(PullStack), BatchPullStack(BatchPullStack), StartStack(StartStack), RestartStack(RestartStack), PauseStack(PauseStack), UnpauseStack(UnpauseStack), StopStack(StopStack), DestroyStack(DestroyStack), BatchDestroyStack(BatchDestroyStack), RunStackService(RunStackService), // ALERTER TestAlerter(TestAlerter), #[clap(alias = "alert")] SendAlert(SendAlert), // MAINTENANCE ClearRepoCache(ClearRepoCache), BackupCoreDatabase(BackupCoreDatabase), GlobalAutoUpdate(GlobalAutoUpdate), // SLEEP Sleep(Sleep), } /// Sleeps for the specified time. #[typeshare] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Parser)] pub struct Sleep { #[serde(default)] pub duration_ms: I64, } #[typeshare] pub type BatchExecutionResponse = Vec; #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "status", content = "data")] pub enum BatchExecutionResponseItem { Ok(Update), Err(BatchExecutionResponseItemErr), } impl From, BatchExecutionResponseItemErr>> for BatchExecutionResponseItem { fn from( value: Result, BatchExecutionResponseItemErr>, ) -> Self { match value { Ok(update) => Self::Ok(*update), Err(e) => Self::Err(e), } } } #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BatchExecutionResponseItemErr { pub name: String, pub error: _Serror, } ================================================ FILE: client/core/rs/src/api/execute/procedure.rs ================================================ use clap::Parser; use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::update::Update; use super::{BatchExecutionResponse, KomodoExecuteRequest}; /// Runs the target Procedure. Response: [Update] #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RunProcedure { /// Id or name pub procedure: String, } /// Runs multiple Procedures in parallel that match pattern. Response: [BatchExecutionResponse]. #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(BatchExecutionResponse)] #[error(serror::Error)] pub struct BatchRunProcedure { /// Id or name or wildcard pattern or regex. /// Supports multiline and comma delineated combinations of the above. /// /// Example: /// ```text /// # match all foo-* procedures /// foo-* /// # add some more /// extra-procedure-1, extra-procedure-2 /// ``` pub pattern: String, } ================================================ FILE: client/core/rs/src/api/execute/repo.rs ================================================ use clap::Parser; use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::update::Update; use super::{BatchExecutionResponse, KomodoExecuteRequest}; // /// Clones the target repo. Response: [Update]. /// /// Note. Repo must have server attached at `server_id`. /// /// 1. Clones the repo on the target server using `git clone https://{$token?}@github.com/${repo} -b ${branch}`. /// The token will only be used if a github account is specified, /// and must be declared in the periphery configuration on the target server. /// 2. If `on_clone` and `on_pull` are specified, they will be executed. /// `on_clone` will be executed before `on_pull`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct CloneRepo { /// Id or name pub repo: String, } // /// Clones multiple Repos in parallel that match pattern. Response: [BatchExecutionResponse]. #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(BatchExecutionResponse)] #[error(serror::Error)] pub struct BatchCloneRepo { /// Id or name or wildcard pattern or regex. /// Supports multiline and comma delineated combinations of the above. /// /// Example: /// ```text /// # match all foo-* repos /// foo-* /// # add some more /// extra-repo-1, extra-repo-2 /// ``` pub pattern: String, } // /// Pulls the target repo. Response: [Update]. /// /// Note. Repo must have server attached at `server_id`. /// /// 1. Pulls the repo on the target server using `git pull`. /// 2. If `on_pull` is specified, it will be executed after the pull is complete. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct PullRepo { /// Id or name pub repo: String, } // /// Pulls multiple Repos in parallel that match pattern. Response: [BatchExecutionResponse]. #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(BatchExecutionResponse)] #[error(serror::Error)] pub struct BatchPullRepo { /// Id or name or wildcard pattern or regex. /// Supports multiline and comma delineated combinations of the above. /// /// Example: /// ```text /// # match all foo-* repos /// foo-* /// # add some more /// extra-repo-1, extra-repo-2 /// ``` pub pattern: String, } // /// Builds the target repo, using the attached builder. Response: [Update]. /// /// Note. Repo must have builder attached at `builder_id`. /// /// 1. Spawns the target builder instance (For AWS type. For Server type, just use CloneRepo). /// 2. Clones the repo on the builder using `git clone https://{$token?}@github.com/${repo} -b ${branch}`. /// The token will only be used if a github account is specified, /// and must be declared in the periphery configuration on the builder instance. /// 3. If `on_clone` and `on_pull` are specified, they will be executed. /// `on_clone` will be executed before `on_pull`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct BuildRepo { /// Id or name pub repo: String, } // /// Builds multiple Repos in parallel that match pattern. Response: [BatchExecutionResponse]. #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(BatchExecutionResponse)] #[error(serror::Error)] pub struct BatchBuildRepo { /// Id or name or wildcard pattern or regex. /// Supports multiline and comma delineated combinations of the above. /// /// Example: /// ```text /// # match all foo-* repos /// foo-* /// # add some more /// extra-repo-1, extra-repo-2 /// ``` pub pattern: String, } // /// Cancels the target repo build. /// Only does anything if the repo build is `building` when called. /// Response: [Update] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct CancelRepoBuild { /// Can be id or name pub repo: String, } ================================================ FILE: client/core/rs/src/api/execute/server.rs ================================================ use clap::Parser; use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{TerminationSignal, update::Update}; use super::KomodoExecuteRequest; // ============= // = CONTAINER = // ============= /// Starts the container on the target server. Response: [Update] /// /// 1. Runs `docker start ${container_name}`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct StartContainer { /// Name or id pub server: String, /// The container name pub container: String, } // /// Restarts the container on the target server. Response: [Update] /// /// 1. Runs `docker restart ${container_name}`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RestartContainer { /// Name or id pub server: String, /// The container name pub container: String, } // /// Pauses the container on the target server. Response: [Update] /// /// 1. Runs `docker pause ${container_name}`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct PauseContainer { /// Name or id pub server: String, /// The container name pub container: String, } // /// Unpauses the container on the target server. Response: [Update] /// /// 1. Runs `docker unpause ${container_name}`. /// /// Note. This is the only way to restart a paused container. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct UnpauseContainer { /// Name or id pub server: String, /// The container name pub container: String, } // /// Stops the container on the target server. Response: [Update] /// /// 1. Runs `docker stop ${container_name}`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct StopContainer { /// Name or id pub server: String, /// The container name pub container: String, /// Override the default termination signal. pub signal: Option, /// Override the default termination max time. pub time: Option, } // /// Stops and destroys the container on the target server. /// Reponse: [Update]. /// /// 1. The container is stopped and removed using `docker container rm ${container_name}`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct DestroyContainer { /// Name or id pub server: String, /// The container name pub container: String, /// Override the default termination signal. pub signal: Option, /// Override the default termination max time. pub time: Option, } // /// Starts all containers on the target server. Response: [Update] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct StartAllContainers { /// Name or id pub server: String, } // /// Restarts all containers on the target server. Response: [Update] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RestartAllContainers { /// Name or id pub server: String, } // /// Pauses all containers on the target server. Response: [Update] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct PauseAllContainers { /// Name or id pub server: String, } // /// Unpauses all containers on the target server. Response: [Update] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct UnpauseAllContainers { /// Name or id pub server: String, } // /// Stops all containers on the target server. Response: [Update] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct StopAllContainers { /// Name or id pub server: String, } // /// Prunes the docker containers on the target server. Response: [Update]. /// /// 1. Runs `docker container prune -f`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct PruneContainers { /// Id or name pub server: String, } // ============================ // = NETWORK / IMAGE / VOLUME = // ============================ /// Delete a docker network. /// Response: [Update] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct DeleteNetwork { /// Id or name. pub server: String, /// The name of the network to delete. pub name: String, } // /// Prunes the docker networks on the target server. Response: [Update]. /// /// 1. Runs `docker network prune -f`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct PruneNetworks { /// Id or name pub server: String, } // /// Delete a docker image. /// Response: [Update] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct DeleteImage { /// Id or name. pub server: String, /// The name of the image to delete. pub name: String, } // /// Prunes the docker images on the target server. Response: [Update]. /// /// 1. Runs `docker image prune -a -f`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct PruneImages { /// Id or name pub server: String, } // /// Delete a docker volume. /// Response: [Update] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct DeleteVolume { /// Id or name. pub server: String, /// The name of the volume to delete. pub name: String, } // /// Prunes the docker volumes on the target server. Response: [Update]. /// /// 1. Runs `docker volume prune -a -f`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct PruneVolumes { /// Id or name pub server: String, } // /// Prunes the docker builders (build cache) on the target server. Response: [Update]. /// /// 1. Runs `docker builder prune -a -f`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct PruneDockerBuilders { /// Id or name pub server: String, } // /// Prunes the docker buildx cache on the target server. Response: [Update]. /// /// 1. Runs `docker buildx prune -a -f`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct PruneBuildx { /// Id or name pub server: String, } // /// Prunes the docker system on the target server, including volumes. Response: [Update]. /// /// 1. Runs `docker system prune -a -f --volumes`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct PruneSystem { /// Id or name pub server: String, } ================================================ FILE: client/core/rs/src/api/execute/stack.rs ================================================ use crate::entities::update::Update; use anyhow::Context; use clap::ArgAction::SetTrue; use clap::Parser; use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use typeshare::typeshare; use super::{BatchExecutionResponse, KomodoExecuteRequest}; /// Deploys the target stack. `docker compose up`. Response: [Update] #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct DeployStack { /// Id or name pub stack: String, /// Filter to only deploy specific services. /// If empty, will deploy all services. #[serde(default)] pub services: Vec, /// Override the default termination max time. /// Only used if the stack needs to be taken down first. pub stop_time: Option, } // /// Deploys multiple Stacks in parallel that match pattern. Response: [BatchExecutionResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(BatchExecutionResponse)] #[error(serror::Error)] pub struct BatchDeployStack { /// Id or name or wildcard pattern or regex. /// Supports multiline and comma delineated combinations of the above. /// /// Example: /// ```text /// # match all foo-* stacks /// foo-* /// # add some more /// extra-stack-1, extra-stack-2 /// ``` pub pattern: String, } // /// Checks deployed contents vs latest contents, /// and only if any changes found /// will `docker compose up`. Response: [Update] #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct DeployStackIfChanged { /// Id or name pub stack: String, /// Override the default termination max time. /// Only used if the stack needs to be taken down first. pub stop_time: Option, } // /// Deploys multiple Stacks if changed in parallel that match pattern. Response: [BatchExecutionResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(BatchExecutionResponse)] #[error(serror::Error)] pub struct BatchDeployStackIfChanged { /// Id or name or wildcard pattern or regex. /// Supports multiline and comma delineated combinations of the above. /// /// Example: /// ```text /// # match all foo-* stacks /// foo-* /// # add some more /// extra-stack-1, extra-stack-2 /// ``` pub pattern: String, } // /// Pulls images for the target stack. `docker compose pull`. Response: [Update] #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct PullStack { /// Id or name pub stack: String, /// Filter to only pull specific services. /// If empty, will pull all services. #[serde(default)] pub services: Vec, } // /// Pulls multiple Stacks in parallel that match pattern. Response: [BatchExecutionResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(BatchExecutionResponse)] #[error(serror::Error)] pub struct BatchPullStack { /// Id or name or wildcard pattern or regex. /// Supports multiline and comma delineated combinations of the above. /// /// Example: /// ```text /// # match all foo-* stacks /// foo-* /// # add some more /// extra-stack-1, extra-stack-2 /// ``` pub pattern: String, } // /// Starts the target stack. `docker compose start`. Response: [Update] #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct StartStack { /// Id or name pub stack: String, /// Filter to only start specific services. /// If empty, will start all services. #[serde(default)] pub services: Vec, } // /// Restarts the target stack. `docker compose restart`. Response: [Update] #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RestartStack { /// Id or name pub stack: String, /// Filter to only restart specific services. /// If empty, will restart all services. #[serde(default)] pub services: Vec, } // /// Pauses the target stack. `docker compose pause`. Response: [Update] #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct PauseStack { /// Id or name pub stack: String, /// Filter to only pause specific services. /// If empty, will pause all services. #[serde(default)] pub services: Vec, } // /// Unpauses the target stack. `docker compose unpause`. Response: [Update]. /// /// Note. This is the only way to restart a paused container. #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct UnpauseStack { /// Id or name pub stack: String, /// Filter to only unpause specific services. /// If empty, will unpause all services. #[serde(default)] pub services: Vec, } // /// Stops the target stack. `docker compose stop`. Response: [Update] #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct StopStack { /// Id or name pub stack: String, /// Override the default termination max time. pub stop_time: Option, /// Filter to only stop specific services. /// If empty, will stop all services. #[serde(default)] pub services: Vec, } // /// Destoys the target stack. `docker compose down`. Response: [Update] #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct DestroyStack { /// Id or name pub stack: String, /// Filter to only destroy specific services. /// If empty, will destroy all services. #[serde(default)] pub services: Vec, /// Pass `--remove-orphans` #[serde(default)] pub remove_orphans: bool, /// Override the default termination max time. pub stop_time: Option, } // /// Runs a one-time command against a service using `docker compose run`. Response: [Update] #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RunStackService { /// Id or name pub stack: String, /// Service to run pub service: String, /// Command and args to pass to the service container #[arg(trailing_var_arg = true, num_args = 1.., allow_hyphen_values = true)] pub command: Option>, /// Do not allocate TTY #[arg(long = "no-tty", action = SetTrue)] pub no_tty: Option, /// Do not start linked services #[arg(long = "no-deps", action = SetTrue)] pub no_deps: Option, /// Detach container on run #[arg(long = "detach", action = SetTrue)] pub detach: Option, /// Map service ports to the host #[arg(long = "service-ports", action = SetTrue)] pub service_ports: Option, /// Extra environment variables for the run #[arg(long = "env", short = 'e', value_parser = env_parser)] pub env: Option>, /// Working directory inside the container #[arg(long = "workdir")] pub workdir: Option, /// User to run as inside the container #[arg(long = "user")] pub user: Option, /// Override the default entrypoint #[arg(long = "entrypoint")] pub entrypoint: Option, /// Pull the image before running #[arg(long = "pull", action = SetTrue)] pub pull: Option, } fn env_parser(args: &str) -> anyhow::Result> { serde_qs::from_str(args).context("Failed to parse env") } // /// Destroys multiple Stacks in parallel that match pattern. Response: [BatchExecutionResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(BatchExecutionResponse)] #[error(serror::Error)] pub struct BatchDestroyStack { /// Id or name or wildcard pattern or regex. /// Supports multiline and comma delineated combinations of the above. ///d /// Example: /// ```text /// # match all foo-* stacks /// foo-* /// # add some more /// extra-stack-1, extra-stack-2 /// ``` pub pattern: String, } ================================================ FILE: client/core/rs/src/api/execute/sync.rs ================================================ use clap::Parser; use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ResourceTargetVariant, update::Update}; use super::KomodoExecuteRequest; /// Runs the target resource sync. Response: [Update] #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoExecuteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RunSync { /// Id or name pub sync: String, /// Only execute sync on a specific resource type. /// Combine with `resource_id` to specify resource. pub resource_type: Option, /// Only execute sync on a specific resources. /// Combine with `resource_type` to specify resources. /// Supports name or id. pub resources: Option>, } ================================================ FILE: client/core/rs/src/api/mod.rs ================================================ //! # Komodo Core API //! //! Komodo Core exposes an HTTP api using standard JSON serialization. //! //! All calls share some common HTTP params: //! - Method: `POST` //! - Path: `/auth`, `/user`, `/read`, `/write`, `/execute` //! - Headers: //! - Content-Type: `application/json` //! - Authorization: `your_jwt` //! - X-Api-Key: `your_api_key` //! - X-Api-Secret: `your_api_secret` //! - Use either Authorization *or* X-Api-Key and X-Api-Secret to authenticate requests. //! - Body: JSON specifying the request type (`type`) and the parameters (`params`). //! //! You can create API keys for your user, or for a Service User with limited permissions, //! from the Komodo UI Settings page. //! //! To call the api, construct JSON bodies following //! the schemas given in [read], [mod@write], [execute], and so on. //! //! For example, this is an example body for [read::GetDeployment]: //! ```json //! { //! "type": "GetDeployment", //! "params": { //! "deployment": "66113df3abe32960b87018dd" //! } //! } //! ``` //! //! The request's parent module (eg. [read], [mod@write]) determines the http path which //! must be used for the requests. For example, requests under [read] are made using http path `/read`. //! //! ## Curl Example //! //! Putting it all together, here is an example `curl` for [write::UpdateBuild], to update the version: //! //! ```text //! curl --header "Content-Type: application/json" \ //! --header "X-Api-Key: your_api_key" \ //! --header "X-Api-Secret: your_api_secret" \ //! --data '{ "type": "UpdateBuild", "params": { "id": "67076689ed600cfdd52ac637", "config": { "version": "1.15.9" } } }' \ //! https://komodo.example.com/write //! ``` //! //! ## Modules //! //! - [auth]: Requests relating to logging in / obtaining authentication tokens. //! - [user]: User self-management actions (manage api keys, etc.) //! - [read]: Read only requests which retrieve data from Komodo. //! - [execute]: Run actions on Komodo resources, eg [execute::RunBuild]. //! - [mod@write]: Requests which alter data, like create / update / delete resources. //! //! ## Errors //! //! Request errors will be returned with a JSON body containing information about the error. //! They will have the following common format: //! ```json //! { //! "error": "top level error message", //! "trace": [ //! "first traceback message", //! "second traceback message" //! ] //! } //! ``` pub mod auth; pub mod execute; pub mod read; pub mod terminal; pub mod user; pub mod write; ================================================ FILE: client/core/rs/src/api/read/action.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::action::{ Action, ActionActionState, ActionListItem, ActionQuery, }; use super::KomodoReadRequest; // /// Get a specific action. Response: [Action]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetActionResponse)] #[error(serror::Error)] pub struct GetAction { /// Id or name #[serde(alias = "id", alias = "name")] pub action: String, } #[typeshare] pub type GetActionResponse = Action; // /// List actions matching optional query. Response: [ListActionsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListActionsResponse)] #[error(serror::Error)] pub struct ListActions { /// optional structured query to filter actions. #[serde(default)] pub query: ActionQuery, } #[typeshare] pub type ListActionsResponse = Vec; // /// List actions matching optional query. Response: [ListFullActionsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListFullActionsResponse)] #[error(serror::Error)] pub struct ListFullActions { /// optional structured query to filter actions. #[serde(default)] pub query: ActionQuery, } #[typeshare] pub type ListFullActionsResponse = Vec; // /// Get current action state for the action. Response: [ActionActionState]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetActionActionStateResponse)] #[error(serror::Error)] pub struct GetActionActionState { /// Id or name #[serde(alias = "id", alias = "name")] pub action: String, } #[typeshare] pub type GetActionActionStateResponse = ActionActionState; // /// Gets a summary of data relating to all actions. /// Response: [GetActionsSummaryResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetActionsSummaryResponse)] #[error(serror::Error)] pub struct GetActionsSummary {} /// Response for [GetActionsSummary]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct GetActionsSummaryResponse { /// The total number of actions. pub total: u32, /// The number of actions with Ok state. pub ok: u32, /// The number of actions currently running. pub running: u32, /// The number of actions with failed state. pub failed: u32, /// The number of actions with unknown state. pub unknown: u32, } ================================================ FILE: client/core/rs/src/api/read/alert.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{I64, MongoDocument, U64, alert::Alert}; use super::KomodoReadRequest; /// Get a paginated list of alerts sorted by timestamp descending. /// Response: [ListAlertsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListAlertsResponse)] #[error(serror::Error)] pub struct ListAlerts { /// Pass a custom mongo query to filter the alerts. /// /// ## Example JSON /// ```json /// { /// "resolved": "false", /// "level": "CRITICAL", /// "$or": [ /// { /// "target": { /// "type": "Server", /// "id": "6608bf89cb2a12b257ab6c09" /// } /// }, /// { /// "target": { /// "type": "Server", /// "id": "660a5f60b74f90d5dae45fa3" /// } /// } /// ] /// } /// ``` /// This will filter to only include open alerts that have CRITICAL level on those two servers. pub query: Option, /// Retrieve older results by incrementing the page. /// `page: 0` is default, and returns the most recent results. #[serde(default)] pub page: U64, } /// Response for [ListAlerts]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ListAlertsResponse { pub alerts: Vec, /// If more alerts exist, the next page will be given here. /// Otherwise it will be `null` pub next_page: Option, } // /// Get an alert: Response: [Alert]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetAlertResponse)] #[error(serror::Error)] pub struct GetAlert { pub id: String, } #[typeshare] pub type GetAlertResponse = Alert; ================================================ FILE: client/core/rs/src/api/read/alerter.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::alerter::{ Alerter, AlerterListItem, AlerterQuery, }; use super::KomodoReadRequest; // /// Get a specific alerter. Response: [Alerter]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetAlerterResponse)] #[error(serror::Error)] pub struct GetAlerter { /// Id or name #[serde(alias = "id", alias = "name")] pub alerter: String, } #[typeshare] pub type GetAlerterResponse = Alerter; // /// List alerters matching optional query. Response: [ListAlertersResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListAlertersResponse)] #[error(serror::Error)] pub struct ListAlerters { /// Structured query to filter alerters. #[serde(default)] pub query: AlerterQuery, } #[typeshare] pub type ListAlertersResponse = Vec; /// List full alerters matching optional query. Response: [ListFullAlertersResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListFullAlertersResponse)] #[error(serror::Error)] pub struct ListFullAlerters { /// Structured query to filter alerters. #[serde(default)] pub query: AlerterQuery, } #[typeshare] pub type ListFullAlertersResponse = Vec; // /// Gets a summary of data relating to all alerters. /// Response: [GetAlertersSummaryResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetAlertersSummaryResponse)] #[error(serror::Error)] pub struct GetAlertersSummary {} /// Response for [GetAlertersSummary]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetAlertersSummaryResponse { pub total: u32, } ================================================ FILE: client/core/rs/src/api/read/build.rs ================================================ use std::cmp::Ordering; use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ I64, Version, build::{Build, BuildActionState, BuildListItem, BuildQuery}, }; use super::KomodoReadRequest; // /// Get a specific build. Response: [Build]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetBuildResponse)] #[error(serror::Error)] pub struct GetBuild { /// Id or name #[serde(alias = "id", alias = "name")] pub build: String, } #[typeshare] pub type GetBuildResponse = Build; // /// List builds matching optional query. Response: [ListBuildsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListBuildsResponse)] #[error(serror::Error)] pub struct ListBuilds { /// optional structured query to filter builds. #[serde(default)] pub query: BuildQuery, } #[typeshare] pub type ListBuildsResponse = Vec; // /// List builds matching optional query. Response: [ListFullBuildsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListFullBuildsResponse)] #[error(serror::Error)] pub struct ListFullBuilds { /// optional structured query to filter builds. #[serde(default)] pub query: BuildQuery, } #[typeshare] pub type ListFullBuildsResponse = Vec; // /// Get current action state for the build. Response: [BuildActionState]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetBuildActionStateResponse)] #[error(serror::Error)] pub struct GetBuildActionState { /// Id or name #[serde(alias = "id", alias = "name")] pub build: String, } #[typeshare] pub type GetBuildActionStateResponse = BuildActionState; // /// Gets a summary of data relating to all builds. /// Response: [GetBuildsSummaryResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetBuildsSummaryResponse)] #[error(serror::Error)] pub struct GetBuildsSummary {} /// Response for [GetBuildsSummary]. #[typeshare] #[derive(Serialize, Deserialize, Default, Debug, Clone)] pub struct GetBuildsSummaryResponse { /// The total number of builds in Komodo. pub total: u32, /// The number of builds with Ok state. pub ok: u32, /// The number of builds with Failed state. pub failed: u32, /// The number of builds currently building. pub building: u32, /// The number of builds with unknown state. pub unknown: u32, } // /// Gets summary and timeseries breakdown of the last months build count / time for charting. /// Response: [GetBuildMonthlyStatsResponse]. /// /// Note. This method is paginated. One page is 30 days of data. /// Query for older pages by incrementing the page, starting at 0. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetBuildMonthlyStatsResponse)] #[error(serror::Error)] pub struct GetBuildMonthlyStats { /// Query for older data by incrementing the page. /// `page: 0` is the default, and will return the most recent data. #[serde(default)] pub page: u32, } /// Response for [GetBuildMonthlyStats]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct GetBuildMonthlyStatsResponse { pub total_time: f64, // in hours pub total_count: f64, // number of builds pub days: Vec, } /// Item in [GetBuildMonthlyStatsResponse] #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct BuildStatsDay { pub time: f64, pub count: f64, pub ts: f64, } impl GetBuildMonthlyStatsResponse { pub fn new( mut days: Vec, ) -> GetBuildMonthlyStatsResponse { days.sort_by(|a, b| { if a.ts < b.ts { Ordering::Less } else { Ordering::Greater } }); let mut total_time = 0.0; let mut total_count = 0.0; for day in &days { total_time += day.time; total_count += day.count; } GetBuildMonthlyStatsResponse { total_time, total_count, days, } } } // /// Retrieve versions of the build that were built in the past and available for deployment, /// sorted by most recent first. /// Response: [ListBuildVersionsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListBuildVersionsResponse)] #[error(serror::Error)] pub struct ListBuildVersions { /// Id or name #[serde(alias = "id", alias = "name")] pub build: String, /// Filter to only include versions matching this major version. pub major: Option, /// Filter to only include versions matching this minor version. pub minor: Option, /// Filter to only include versions matching this patch version. pub patch: Option, /// Limit the number of included results. Default is no limit. pub limit: Option, } #[typeshare] pub type ListBuildVersionsResponse = Vec; #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct BuildVersionResponseItem { pub version: Version, pub ts: I64, } // /// Gets a list of existing values used as extra args across other builds. /// Useful to offer suggestions. Response: [ListCommonBuildExtraArgsResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListCommonBuildExtraArgsResponse)] #[error(serror::Error)] pub struct ListCommonBuildExtraArgs { /// optional structured query to filter builds. #[serde(default)] pub query: BuildQuery, } #[typeshare] pub type ListCommonBuildExtraArgsResponse = Vec; // /// Get whether a Build's target repo has a webhook for the build configured. Response: [GetBuildWebhookEnabledResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetBuildWebhookEnabledResponse)] #[error(serror::Error)] pub struct GetBuildWebhookEnabled { /// Id or name #[serde(alias = "id", alias = "name")] pub build: String, } /// Response for [GetBuildWebhookEnabled] #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetBuildWebhookEnabledResponse { /// Whether the repo webhooks can even be managed. /// The repo owner must be in `github_webhook_app.owners` list to be managed. pub managed: bool, /// Whether pushes to branch trigger build. Will always be false if managed is false. pub enabled: bool, } ================================================ FILE: client/core/rs/src/api/read/builder.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::builder::{ Builder, BuilderListItem, BuilderQuery, }; use super::KomodoReadRequest; // /// Get a specific builder by id or name. Response: [Builder]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetBuilderResponse)] #[error(serror::Error)] pub struct GetBuilder { /// Id or name #[serde(alias = "id", alias = "name")] pub builder: String, } #[typeshare] pub type GetBuilderResponse = Builder; // /// List builders matching structured query. Response: [ListBuildersResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListBuildersResponse)] #[error(serror::Error)] pub struct ListBuilders { #[serde(default)] pub query: BuilderQuery, } #[typeshare] pub type ListBuildersResponse = Vec; // /// List builders matching structured query. Response: [ListFullBuildersResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListFullBuildersResponse)] #[error(serror::Error)] pub struct ListFullBuilders { #[serde(default)] pub query: BuilderQuery, } #[typeshare] pub type ListFullBuildersResponse = Vec; // /// Gets a summary of data relating to all builders. /// Response: [GetBuildersSummaryResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetBuildersSummaryResponse)] #[error(serror::Error)] pub struct GetBuildersSummary {} /// Response for [GetBuildersSummary]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetBuildersSummaryResponse { /// The total number of builders. pub total: u32, } ================================================ FILE: client/core/rs/src/api/read/deployment.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ I64, SearchCombinator, U64, deployment::{ Deployment, DeploymentActionState, DeploymentListItem, DeploymentQuery, DeploymentState, }, docker::container::{Container, ContainerListItem, ContainerStats}, update::Log, }; use super::KomodoReadRequest; // /// Get a specific deployment by name or id. Response: [Deployment]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetDeploymentResponse)] #[error(serror::Error)] pub struct GetDeployment { /// Id or name #[serde(alias = "id", alias = "name")] pub deployment: String, } #[typeshare] pub type GetDeploymentResponse = Deployment; // /// List deployments matching optional query. /// Response: [ListDeploymentsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListDeploymentsResponse)] #[error(serror::Error)] pub struct ListDeployments { /// optional structured query to filter deployments. #[serde(default)] pub query: DeploymentQuery, } #[typeshare] pub type ListDeploymentsResponse = Vec; // /// List deployments matching optional query. /// Response: [ListFullDeploymentsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListFullDeploymentsResponse)] #[error(serror::Error)] pub struct ListFullDeployments { /// optional structured query to filter deployments. #[serde(default)] pub query: DeploymentQuery, } #[typeshare] pub type ListFullDeploymentsResponse = Vec; // /// Get the container, including image / status, of the target deployment. /// Response: [GetDeploymentContainerResponse]. /// /// Note. This does not hit the server directly. The status comes from an /// in memory cache on the core, which hits the server periodically /// to keep it up to date. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetDeploymentContainerResponse)] #[error(serror::Error)] pub struct GetDeploymentContainer { /// Id or name #[serde(alias = "id", alias = "name")] pub deployment: String, } /// Response for [GetDeploymentContainer]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetDeploymentContainerResponse { pub state: DeploymentState, pub container: Option, } // /// Inspect the docker container associated with the Deployment. /// Response: [Container]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(InspectDeploymentContainerResponse)] #[error(serror::Error)] pub struct InspectDeploymentContainer { /// Id or name #[serde(alias = "id", alias = "name")] pub deployment: String, } #[typeshare] pub type InspectDeploymentContainerResponse = Container; // /// Get the deployment log's tail, split by stdout/stderr. /// Response: [Log]. /// /// Note. This call will hit the underlying server directly for most up to date log. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetDeploymentLogResponse)] #[error(serror::Error)] pub struct GetDeploymentLog { /// Id or name #[serde(alias = "id", alias = "name")] pub deployment: String, /// The number of lines of the log tail to include. /// Default: 100. /// Max: 5000. #[serde(default = "default_tail")] pub tail: U64, /// Enable `--timestamps` #[serde(default)] pub timestamps: bool, } fn default_tail() -> u64 { 50 } #[typeshare] pub type GetDeploymentLogResponse = Log; // /// Search the deployment log's tail using `grep`. All lines go to stdout. /// Response: [Log]. /// /// Note. This call will hit the underlying server directly for most up to date log. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(SearchDeploymentLogResponse)] #[error(serror::Error)] pub struct SearchDeploymentLog { /// Id or name #[serde(alias = "id", alias = "name")] pub deployment: String, /// The terms to search for. pub terms: Vec, /// When searching for multiple terms, can use `AND` or `OR` combinator. /// /// - `AND`: Only include lines with **all** terms present in that line. /// - `OR`: Include lines that have one or more matches in the terms. #[serde(default)] pub combinator: SearchCombinator, /// Invert the results, ie return all lines that DON'T match the terms / combinator. #[serde(default)] pub invert: bool, /// Enable `--timestamps` #[serde(default)] pub timestamps: bool, } #[typeshare] pub type SearchDeploymentLogResponse = Log; // /// Get the deployment container's stats using `docker stats`. /// Response: [GetDeploymentStatsResponse]. /// /// Note. This call will hit the underlying server directly for most up to date stats. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetDeploymentStatsResponse)] #[error(serror::Error)] pub struct GetDeploymentStats { /// Id or name #[serde(alias = "id", alias = "name")] pub deployment: String, } #[typeshare] pub type GetDeploymentStatsResponse = ContainerStats; // /// Get current action state for the deployment. /// Response: [DeploymentActionState]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(DeploymentActionState)] #[error(serror::Error)] pub struct GetDeploymentActionState { /// Id or name #[serde(alias = "id", alias = "name")] pub deployment: String, } #[typeshare] pub type GetDeploymentActionStateResponse = DeploymentActionState; // /// Gets a summary of data relating to all deployments. /// Response: [GetDeploymentsSummaryResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetDeploymentsSummaryResponse)] #[error(serror::Error)] pub struct GetDeploymentsSummary {} /// Response for [GetDeploymentsSummary]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct GetDeploymentsSummaryResponse { /// The total number of Deployments pub total: I64, /// The number of Deployments with Running state pub running: I64, /// The number of Deployments with Stopped or Paused state pub stopped: I64, /// The number of Deployments with NotDeployed state pub not_deployed: I64, /// The number of Deployments with Restarting or Dead or Created (other) state pub unhealthy: I64, /// The number of Deployments with Unknown state pub unknown: I64, } // /// Gets a list of existing values used as extra args across other deployments. /// Useful to offer suggestions. Response: [ListCommonDeploymentExtraArgsResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListCommonDeploymentExtraArgsResponse)] #[error(serror::Error)] pub struct ListCommonDeploymentExtraArgs { /// optional structured query to filter deployments. #[serde(default)] pub query: DeploymentQuery, } #[typeshare] pub type ListCommonDeploymentExtraArgsResponse = Vec; ================================================ FILE: client/core/rs/src/api/read/mod.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::{HasResponse, Resolve}; use serde::{Deserialize, Serialize}; use typeshare::typeshare; mod action; mod alert; mod alerter; mod build; mod builder; mod deployment; mod permission; mod procedure; mod provider; mod repo; mod schedule; mod server; mod stack; mod sync; mod tag; mod toml; mod update; mod user; mod user_group; mod variable; pub use action::*; pub use alert::*; pub use alerter::*; pub use build::*; pub use builder::*; pub use deployment::*; pub use permission::*; pub use procedure::*; pub use provider::*; pub use repo::*; pub use schedule::*; pub use server::*; pub use stack::*; pub use sync::*; pub use tag::*; pub use toml::*; pub use update::*; pub use user::*; pub use user_group::*; pub use variable::*; use crate::entities::{ ResourceTarget, Timelength, config::{DockerRegistry, GitProvider}, }; pub trait KomodoReadRequest: HasResponse {} // /// Get the version of the Komodo Core api. /// Response: [GetVersionResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetVersionResponse)] #[error(serror::Error)] pub struct GetVersion {} /// Response for [GetVersion]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetVersionResponse { /// The version of the core api. pub version: String, } // /// Get info about the core api configuration. /// Response: [GetCoreInfoResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetCoreInfoResponse)] #[error(serror::Error)] pub struct GetCoreInfo {} /// Response for [GetCoreInfo]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetCoreInfoResponse { /// The title assigned to this core api. pub title: String, /// The monitoring interval of this core api. pub monitoring_interval: Timelength, /// The webhook base url. pub webhook_base_url: String, /// Whether transparent mode is enabled, which gives all users read access to all resources. pub transparent_mode: bool, /// Whether UI write access should be disabled pub ui_write_disabled: bool, /// Whether non admins can create resources pub disable_non_admin_create: bool, /// Whether confirm dialog should be disabled pub disable_confirm_dialog: bool, /// The repo owners for which github webhook management api is available pub github_webhook_owners: Vec, /// Whether to disable websocket automatic reconnect. pub disable_websocket_reconnect: bool, /// Whether to enable fancy toml highlighting. pub enable_fancy_toml: bool, /// TZ identifier Core is using, if manually set. pub timezone: String, } // /// List the git providers available in Core / Periphery config files. /// Response: [ListGitProvidersFromConfigResponse]. /// /// Includes: /// - providers in core config /// - providers configured on builds, repos, syncs /// - providers on the optional Server or Builder #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListGitProvidersFromConfigResponse)] #[error(serror::Error)] pub struct ListGitProvidersFromConfig { /// Accepts an optional Server or Builder target to expand the core list with /// providers available on that specific resource. pub target: Option, } #[typeshare] pub type ListGitProvidersFromConfigResponse = Vec; // /// List the docker registry providers available in Core / Periphery config files. /// Response: [ListDockerRegistriesFromConfigResponse]. /// /// Includes: /// - registries in core config /// - registries configured on builds, deployments /// - registries on the optional Server or Builder #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListDockerRegistriesFromConfigResponse)] #[error(serror::Error)] pub struct ListDockerRegistriesFromConfig { /// Accepts an optional Server or Builder target to expand the core list with /// providers available on that specific resource. pub target: Option, } #[typeshare] pub type ListDockerRegistriesFromConfigResponse = Vec; // /// List the available secrets from the core config. /// Response: [ListSecretsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListSecretsResponse)] #[error(serror::Error)] pub struct ListSecrets { /// Accepts an optional Server or Builder target to expand the core list with /// providers available on that specific resource. pub target: Option, } #[typeshare] pub type ListSecretsResponse = Vec; ================================================ FILE: client/core/rs/src/api/read/permission.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ ResourceTarget, permission::{Permission, PermissionLevelAndSpecifics, UserTarget}, }; use super::KomodoReadRequest; /// List permissions for the calling user. /// Does not include any permissions on UserGroups they may be a part of. /// Response: [ListPermissionsResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListPermissionsResponse)] #[error(serror::Error)] pub struct ListPermissions {} #[typeshare] pub type ListPermissionsResponse = Vec; // /// Gets the calling user's permission level on a specific resource. /// Factors in any UserGroup's permissions they may be a part of. /// Response: [PermissionLevel] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetPermissionResponse)] #[error(serror::Error)] pub struct GetPermission { /// The target to get user permission on. pub target: ResourceTarget, } #[typeshare] pub type GetPermissionResponse = PermissionLevelAndSpecifics; // /// List permissions for a specific user. **Admin only**. /// Response: [ListUserTargetPermissionsResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListUserTargetPermissionsResponse)] #[error(serror::Error)] pub struct ListUserTargetPermissions { /// Specify either a user or a user group. pub user_target: UserTarget, } #[typeshare] pub type ListUserTargetPermissionsResponse = Vec; ================================================ FILE: client/core/rs/src/api/read/procedure.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::procedure::{ Procedure, ProcedureActionState, ProcedureListItem, ProcedureQuery, }; use super::KomodoReadRequest; // /// Get a specific procedure. Response: [Procedure]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetProcedureResponse)] #[error(serror::Error)] pub struct GetProcedure { /// Id or name #[serde(alias = "id", alias = "name")] pub procedure: String, } #[typeshare] pub type GetProcedureResponse = Procedure; // /// List procedures matching optional query. Response: [ListProceduresResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListProceduresResponse)] #[error(serror::Error)] pub struct ListProcedures { /// optional structured query to filter procedures. #[serde(default)] pub query: ProcedureQuery, } #[typeshare] pub type ListProceduresResponse = Vec; // /// List procedures matching optional query. Response: [ListFullProceduresResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListFullProceduresResponse)] #[error(serror::Error)] pub struct ListFullProcedures { /// optional structured query to filter procedures. #[serde(default)] pub query: ProcedureQuery, } #[typeshare] pub type ListFullProceduresResponse = Vec; // /// Get current action state for the procedure. Response: [ProcedureActionState]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetProcedureActionStateResponse)] #[error(serror::Error)] pub struct GetProcedureActionState { /// Id or name #[serde(alias = "id", alias = "name")] pub procedure: String, } #[typeshare] pub type GetProcedureActionStateResponse = ProcedureActionState; // /// Gets a summary of data relating to all procedures. /// Response: [GetProceduresSummaryResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetProceduresSummaryResponse)] #[error(serror::Error)] pub struct GetProceduresSummary {} /// Response for [GetProceduresSummary]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct GetProceduresSummaryResponse { /// The total number of procedures. pub total: u32, /// The number of procedures with Ok state. pub ok: u32, /// The number of procedures currently running. pub running: u32, /// The number of procedures with failed state. pub failed: u32, /// The number of procedures with unknown state. pub unknown: u32, } ================================================ FILE: client/core/rs/src/api/read/provider.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::provider::{ DockerRegistryAccount, GitProviderAccount, }; use super::KomodoReadRequest; /// Get a specific git provider account. /// Response: [GetGitProviderAccountResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetGitProviderAccountResponse)] #[error(serror::Error)] pub struct GetGitProviderAccount { pub id: String, } #[typeshare] pub type GetGitProviderAccountResponse = GitProviderAccount; // /// List git provider accounts matching optional query. /// Response: [ListGitProviderAccountsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListGitProviderAccountsResponse)] #[error(serror::Error)] pub struct ListGitProviderAccounts { /// Optionally filter by accounts with a specific domain. pub domain: Option, /// Optionally filter by accounts with a specific username. pub username: Option, } #[typeshare] pub type ListGitProviderAccountsResponse = Vec; // /// Get a specific docker registry account. /// Response: [GetDockerRegistryAccountResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetDockerRegistryAccountResponse)] #[error(serror::Error)] pub struct GetDockerRegistryAccount { pub id: String, } #[typeshare] pub type GetDockerRegistryAccountResponse = DockerRegistryAccount; // /// List docker registry accounts matching optional query. /// Response: [ListDockerRegistryAccountsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListDockerRegistryAccountsResponse)] #[error(serror::Error)] pub struct ListDockerRegistryAccounts { /// Optionally filter by accounts with a specific domain. pub domain: Option, /// Optionally filter by accounts with a specific username. pub username: Option, } #[typeshare] pub type ListDockerRegistryAccountsResponse = Vec; ================================================ FILE: client/core/rs/src/api/read/repo.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::repo::{ Repo, RepoActionState, RepoListItem, RepoQuery, }; use super::KomodoReadRequest; // /// Get a specific repo. Response: [Repo]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(Repo)] #[error(serror::Error)] pub struct GetRepo { /// Id or name #[serde(alias = "id", alias = "name")] pub repo: String, } #[typeshare] pub type GetRepoResponse = Repo; // /// List repos matching optional query. Response: [ListReposResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListReposResponse)] #[error(serror::Error)] pub struct ListRepos { /// optional structured query to filter repos. #[serde(default)] pub query: RepoQuery, } #[typeshare] pub type ListReposResponse = Vec; // /// List repos matching optional query. Response: [ListFullReposResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListFullReposResponse)] #[error(serror::Error)] pub struct ListFullRepos { /// optional structured query to filter repos. #[serde(default)] pub query: RepoQuery, } #[typeshare] pub type ListFullReposResponse = Vec; // /// Get current action state for the repo. Response: [RepoActionState]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetRepoActionStateResponse)] #[error(serror::Error)] pub struct GetRepoActionState { /// Id or name #[serde(alias = "id", alias = "name")] pub repo: String, } #[typeshare] pub type GetRepoActionStateResponse = RepoActionState; // /// Gets a summary of data relating to all repos. /// Response: [GetReposSummaryResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetReposSummaryResponse)] #[error(serror::Error)] pub struct GetReposSummary {} /// Response for [GetReposSummary] #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct GetReposSummaryResponse { /// The total number of repos pub total: u32, /// The number of repos with Ok state. pub ok: u32, /// The number of repos currently cloning. pub cloning: u32, /// The number of repos currently pulling. pub pulling: u32, /// The number of repos currently building. pub building: u32, /// The number of repos with failed state. pub failed: u32, /// The number of repos with unknown state. pub unknown: u32, } // /// Get a target Repo's configured webhooks. Response: [GetRepoWebhooksEnabledResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetRepoWebhooksEnabledResponse)] #[error(serror::Error)] pub struct GetRepoWebhooksEnabled { /// Id or name #[serde(alias = "id", alias = "name")] pub repo: String, } /// Response for [GetRepoWebhooksEnabled] #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetRepoWebhooksEnabledResponse { /// Whether the repo webhooks can even be managed. /// The repo owner must be in `github_webhook_app.owners` list to be managed. pub managed: bool, /// Whether pushes to branch trigger clone. Will always be false if managed is false. pub clone_enabled: bool, /// Whether pushes to branch trigger pull. Will always be false if managed is false. pub pull_enabled: bool, /// Whether pushes to branch trigger build. Will always be false if managed is false. pub build_enabled: bool, } ================================================ FILE: client/core/rs/src/api/read/schedule.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::{ deserializers::string_list_deserializer, entities::{resource::TagQueryBehavior, schedule::Schedule}, }; use super::KomodoReadRequest; /// List configured schedules. /// Response: [ListSchedulesResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListSchedulesResponse)] #[error(serror::Error)] pub struct ListSchedules { /// Pass Vec of tag ids or tag names #[serde(default, deserialize_with = "string_list_deserializer")] pub tags: Vec, /// 'All' or 'Any' #[serde(default)] pub tag_behavior: TagQueryBehavior, } #[typeshare] pub type ListSchedulesResponse = Vec; ================================================ FILE: client/core/rs/src/api/read/server.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ I64, ResourceTarget, SearchCombinator, Timelength, U64, docker::{ container::{Container, ContainerListItem}, image::{Image, ImageHistoryResponseItem, ImageListItem}, network::{Network, NetworkListItem}, volume::{Volume, VolumeListItem}, }, server::{ Server, ServerActionState, ServerListItem, ServerQuery, ServerState, TerminalInfo, }, stack::ComposeProject, stats::{ SystemInformation, SystemProcess, SystemStats, SystemStatsRecord, }, update::Log, }; use super::KomodoReadRequest; // /// Get a specific server. Response: [Server]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(Server)] #[error(serror::Error)] pub struct GetServer { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, } #[typeshare] pub type GetServerResponse = Server; // /// List servers matching optional query. Response: [ListServersResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListServersResponse)] #[error(serror::Error)] pub struct ListServers { /// optional structured query to filter servers. #[serde(default)] pub query: ServerQuery, } #[typeshare] pub type ListServersResponse = Vec; // /// List servers matching optional query. Response: [ListFullServersResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListFullServersResponse)] #[error(serror::Error)] pub struct ListFullServers { /// optional structured query to filter servers. #[serde(default)] pub query: ServerQuery, } #[typeshare] pub type ListFullServersResponse = Vec; // /// Get the state of the target server. Response: [GetServerStateResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetServerStateResponse)] #[error(serror::Error)] pub struct GetServerState { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, } /// The response for [GetServerState]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetServerStateResponse { /// The server status. pub status: ServerState, } // /// Get current action state for the servers. Response: [ServerActionState]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ServerActionState)] #[error(serror::Error)] pub struct GetServerActionState { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, } #[typeshare] pub type GetServerActionStateResponse = ServerActionState; // /// Get the version of the Komodo Periphery agent on the target server. /// Response: [GetPeripheryVersionResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetPeripheryVersionResponse)] #[error(serror::Error)] pub struct GetPeripheryVersion { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, } /// Response for [GetPeripheryVersion]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetPeripheryVersionResponse { /// The version of periphery. pub version: String, } // /// List the docker networks on the server. Response: [ListDockerNetworksResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListDockerNetworksResponse)] #[error(serror::Error)] pub struct ListDockerNetworks { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, } #[typeshare] pub type ListDockerNetworksResponse = Vec; // /// Inspect a docker network on the server. Response: [InspectDockerNetworkResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(InspectDockerNetworkResponse)] #[error(serror::Error)] pub struct InspectDockerNetwork { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, /// The network name pub network: String, } #[typeshare] pub type InspectDockerNetworkResponse = Network; // /// List the docker images locally cached on the target server. /// Response: [ListDockerImagesResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListDockerImagesResponse)] #[error(serror::Error)] pub struct ListDockerImages { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, } #[typeshare] pub type ListDockerImagesResponse = Vec; // /// Inspect a docker image on the server. Response: [Image]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(InspectDockerImageResponse)] #[error(serror::Error)] pub struct InspectDockerImage { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, /// The image name pub image: String, } #[typeshare] pub type InspectDockerImageResponse = Image; // /// Get image history from the server. Response: [ListDockerImageHistoryResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListDockerImageHistoryResponse)] #[error(serror::Error)] pub struct ListDockerImageHistory { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, /// The image name pub image: String, } #[typeshare] pub type ListDockerImageHistoryResponse = Vec; // /// List all docker containers on the target server. /// Response: [ListDockerContainersResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListDockerContainersResponse)] #[error(serror::Error)] pub struct ListDockerContainers { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, } #[typeshare] pub type ListDockerContainersResponse = Vec; // /// List all docker containers on the target server. /// Response: [ListDockerContainersResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListAllDockerContainersResponse)] #[error(serror::Error)] pub struct ListAllDockerContainers { /// Filter by server id or name. #[serde(default)] pub servers: Vec, } #[typeshare] pub type ListAllDockerContainersResponse = Vec; // /// Gets a summary of data relating to all containers. /// Response: [GetDockerContainersSummaryResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetDockerContainersSummaryResponse)] #[error(serror::Error)] pub struct GetDockerContainersSummary {} /// Response for [GetDockerContainersSummary] #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct GetDockerContainersSummaryResponse { /// The total number of Containers pub total: u32, /// The number of Containers with Running state pub running: u32, /// The number of Containers with Stopped or Paused or Created state pub stopped: u32, /// The number of Containers with Restarting or Dead state pub unhealthy: u32, /// The number of Containers with Unknown state pub unknown: u32, } // /// Inspect a docker container on the server. Response: [Container]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(InspectDockerContainerResponse)] #[error(serror::Error)] pub struct InspectDockerContainer { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, /// The container name pub container: String, } #[typeshare] pub type InspectDockerContainerResponse = Container; // /// Get the container log's tail, split by stdout/stderr. /// Response: [Log]. /// /// Note. This call will hit the underlying server directly for most up to date log. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetContainerLogResponse)] #[error(serror::Error)] pub struct GetContainerLog { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, /// The container name pub container: String, /// The number of lines of the log tail to include. /// Default: 100. /// Max: 5000. #[serde(default = "default_tail")] pub tail: U64, /// Enable `--timestamps` #[serde(default)] pub timestamps: bool, } fn default_tail() -> u64 { 50 } #[typeshare] pub type GetContainerLogResponse = Log; // /// Search the container log's tail using `grep`. All lines go to stdout. /// Response: [Log]. /// /// Note. This call will hit the underlying server directly for most up to date log. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(SearchContainerLogResponse)] #[error(serror::Error)] pub struct SearchContainerLog { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, /// The container name pub container: String, /// The terms to search for. pub terms: Vec, /// When searching for multiple terms, can use `AND` or `OR` combinator. /// /// - `AND`: Only include lines with **all** terms present in that line. /// - `OR`: Include lines that have one or more matches in the terms. #[serde(default)] pub combinator: SearchCombinator, /// Invert the results, ie return all lines that DON'T match the terms / combinator. #[serde(default)] pub invert: bool, /// Enable `--timestamps` #[serde(default)] pub timestamps: bool, } #[typeshare] pub type SearchContainerLogResponse = Log; // /// Find the attached resource for a container. Either Deployment or Stack. Response: [GetResourceMatchingContainerResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetResourceMatchingContainerResponse)] #[error(serror::Error)] pub struct GetResourceMatchingContainer { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, /// The container name pub container: String, } /// Response for [GetResourceMatchingContainer]. Resource is either Deployment, Stack, or None. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetResourceMatchingContainerResponse { pub resource: Option, } // /// List all docker volumes on the target server. /// Response: [ListDockerVolumesResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListDockerVolumesResponse)] #[error(serror::Error)] pub struct ListDockerVolumes { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, } #[typeshare] pub type ListDockerVolumesResponse = Vec; // /// Inspect a docker volume on the server. Response: [Volume]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(InspectDockerVolumeResponse)] #[error(serror::Error)] pub struct InspectDockerVolume { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, /// The volume name pub volume: String, } #[typeshare] pub type InspectDockerVolumeResponse = Volume; // /// List all docker compose projects on the target server. /// Response: [ListComposeProjectsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListComposeProjectsResponse)] #[error(serror::Error)] pub struct ListComposeProjects { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, } #[typeshare] pub type ListComposeProjectsResponse = Vec; // /// Get the system information of the target server. /// Response: [SystemInformation]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetSystemInformationResponse)] #[error(serror::Error)] pub struct GetSystemInformation { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, } #[typeshare] pub type GetSystemInformationResponse = SystemInformation; // /// Get the system stats on the target server. Response: [SystemStats]. /// /// Note. This does not hit the server directly. The stats come from an /// in memory cache on the core, which hits the server periodically /// to keep it up to date. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetSystemStatsResponse)] #[error(serror::Error)] pub struct GetSystemStats { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, } #[typeshare] pub type GetSystemStatsResponse = SystemStats; // /// List the processes running on the target server. /// Response: [ListSystemProcessesResponse]. /// /// Note. This does not hit the server directly. The procedures come from an /// in memory cache on the core, which hits the server periodically /// to keep it up to date. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListSystemProcessesResponse)] #[error(serror::Error)] pub struct ListSystemProcesses { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, } #[typeshare] pub type ListSystemProcessesResponse = Vec; // /// Paginated endpoint serving historical (timeseries) server stats for graphing. /// Response: [GetHistoricalServerStatsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetHistoricalServerStatsResponse)] #[error(serror::Error)] pub struct GetHistoricalServerStats { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, /// The granularity of the data. pub granularity: Timelength, /// Page of historical data. Default is 0, which is the most recent data. /// Use with the `next_page` field of the response. #[serde(default)] pub page: u32, } /// Response to [GetHistoricalServerStats]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetHistoricalServerStatsResponse { /// The timeseries page of data. pub stats: Vec, /// If there is a next page of data, pass this to `page` to get it. pub next_page: Option, } // /// Gets a summary of data relating to all servers. /// Response: [GetServersSummaryResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetServersSummaryResponse)] #[error(serror::Error)] pub struct GetServersSummary {} /// Response for [GetServersSummary]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct GetServersSummaryResponse { /// The total number of servers. pub total: I64, /// The number of healthy (`status: OK`) servers. pub healthy: I64, /// The number of servers with warnings (e.g., version mismatch). pub warning: I64, /// The number of unhealthy servers. pub unhealthy: I64, /// The number of disabled servers. pub disabled: I64, } // /// List the current terminals on specified server. /// Response: [ListTerminalsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListTerminalsResponse)] #[error(serror::Error)] pub struct ListTerminals { /// Id or name #[serde(alias = "id", alias = "name")] pub server: String, /// Force a fresh call to Periphery for the list. /// Otherwise the response will be cached for 30s #[serde(default)] pub fresh: bool, } #[typeshare] pub type ListTerminalsResponse = Vec; ================================================ FILE: client/core/rs/src/api/read/stack.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ SearchCombinator, U64, docker::container::Container, stack::{ Stack, StackActionState, StackListItem, StackQuery, StackService, }, update::Log, }; use super::KomodoReadRequest; // /// Get a specific stack. Response: [Stack]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetStackResponse)] #[error(serror::Error)] pub struct GetStack { /// Id or name #[serde(alias = "id", alias = "name")] pub stack: String, } #[typeshare] pub type GetStackResponse = Stack; // /// Lists a specific stacks services (the containers). Response: [ListStackServicesResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListStackServicesResponse)] #[error(serror::Error)] pub struct ListStackServices { /// Id or name #[serde(alias = "id", alias = "name")] pub stack: String, } #[typeshare] pub type ListStackServicesResponse = Vec; // /// Inspect the docker container associated with the Stack. /// Response: [Container]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(InspectStackContainerResponse)] #[error(serror::Error)] pub struct InspectStackContainer { /// Id or name #[serde(alias = "id", alias = "name")] pub stack: String, /// The service name to inspect pub service: String, } #[typeshare] pub type InspectStackContainerResponse = Container; // /// Get a stack's logs. Filter down included services. Response: [GetStackLogResponse]. /// /// Note. This call will hit the underlying server directly for most up to date log. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetStackLogResponse)] #[error(serror::Error)] pub struct GetStackLog { /// Id or name #[serde(alias = "id", alias = "name")] pub stack: String, /// Filter the logs to only ones from specific services. /// If empty, will include logs from all services. pub services: Vec, /// The number of lines of the log tail to include. /// Default: 100. /// Max: 5000. #[serde(default = "default_tail")] pub tail: U64, /// Enable `--timestamps` #[serde(default)] pub timestamps: bool, } fn default_tail() -> u64 { 50 } #[typeshare] pub type GetStackLogResponse = Log; // /// Search the stack log's tail using `grep`. All lines go to stdout. /// Response: [SearchStackLogResponse]. /// /// Note. This call will hit the underlying server directly for most up to date log. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(SearchStackLogResponse)] #[error(serror::Error)] pub struct SearchStackLog { /// Id or name #[serde(alias = "id", alias = "name")] pub stack: String, /// Filter the logs to only ones from specific services. /// If empty, will include logs from all services. pub services: Vec, /// The terms to search for. pub terms: Vec, /// When searching for multiple terms, can use `AND` or `OR` combinator. /// /// - `AND`: Only include lines with **all** terms present in that line. /// - `OR`: Include lines that have one or more matches in the terms. #[serde(default)] pub combinator: SearchCombinator, /// Invert the results, ie return all lines that DON'T match the terms / combinator. #[serde(default)] pub invert: bool, /// Enable `--timestamps` #[serde(default)] pub timestamps: bool, } #[typeshare] pub type SearchStackLogResponse = Log; // /// Gets a list of existing values used as extra args across other stacks. /// Useful to offer suggestions. Response: [ListCommonStackExtraArgsResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListCommonStackExtraArgsResponse)] #[error(serror::Error)] pub struct ListCommonStackExtraArgs { /// optional structured query to filter stacks. #[serde(default)] pub query: StackQuery, } #[typeshare] pub type ListCommonStackExtraArgsResponse = Vec; // /// Gets a list of existing values used as build extra args across other stacks. /// Useful to offer suggestions. Response: [ListCommonStackBuildExtraArgsResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListCommonStackBuildExtraArgsResponse)] #[error(serror::Error)] pub struct ListCommonStackBuildExtraArgs { /// optional structured query to filter stacks. #[serde(default)] pub query: StackQuery, } #[typeshare] pub type ListCommonStackBuildExtraArgsResponse = Vec; // /// List stacks matching optional query. Response: [ListStacksResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListStacksResponse)] #[error(serror::Error)] pub struct ListStacks { /// optional structured query to filter stacks. #[serde(default)] pub query: StackQuery, } #[typeshare] pub type ListStacksResponse = Vec; // /// List stacks matching optional query. Response: [ListFullStacksResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListFullStacksResponse)] #[error(serror::Error)] pub struct ListFullStacks { /// optional structured query to filter stacks. #[serde(default)] pub query: StackQuery, } #[typeshare] pub type ListFullStacksResponse = Vec; // /// Get current action state for the stack. Response: [StackActionState]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetStackActionStateResponse)] #[error(serror::Error)] pub struct GetStackActionState { /// Id or name #[serde(alias = "id", alias = "name")] pub stack: String, } #[typeshare] pub type GetStackActionStateResponse = StackActionState; // /// Gets a summary of data relating to all syncs. /// Response: [GetStacksSummaryResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetStacksSummaryResponse)] #[error(serror::Error)] pub struct GetStacksSummary {} /// Response for [GetStacksSummary] #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct GetStacksSummaryResponse { /// The total number of stacks pub total: u32, /// The number of stacks with Running state. pub running: u32, /// The number of stacks with Stopped or Paused state. pub stopped: u32, /// The number of stacks with Down state. pub down: u32, /// The number of stacks with Unhealthy or Restarting or Dead or Created or Removing state. pub unhealthy: u32, /// The number of stacks with Unknown state. pub unknown: u32, } // /// Get a target stack's configured webhooks. Response: [GetStackWebhooksEnabledResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetStackWebhooksEnabledResponse)] #[error(serror::Error)] pub struct GetStackWebhooksEnabled { /// Id or name #[serde(alias = "id", alias = "name")] pub stack: String, } /// Response for [GetStackWebhooksEnabled] #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetStackWebhooksEnabledResponse { /// Whether the repo webhooks can even be managed. /// The repo owner must be in `github_webhook_app.owners` list to be managed. pub managed: bool, /// Whether pushes to branch trigger refresh. Will always be false if managed is false. pub refresh_enabled: bool, /// Whether pushes to branch trigger stack execution. Will always be false if managed is false. pub deploy_enabled: bool, } ================================================ FILE: client/core/rs/src/api/read/sync.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::sync::{ ResourceSync, ResourceSyncActionState, ResourceSyncListItem, ResourceSyncQuery, }; use super::KomodoReadRequest; // /// Get a specific sync. Response: [ResourceSync]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ResourceSync)] #[error(serror::Error)] pub struct GetResourceSync { /// Id or name #[serde(alias = "id", alias = "name")] pub sync: String, } #[typeshare] pub type GetResourceSyncResponse = ResourceSync; // /// List syncs matching optional query. Response: [ListResourceSyncsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListResourceSyncsResponse)] #[error(serror::Error)] pub struct ListResourceSyncs { /// optional structured query to filter syncs. #[serde(default)] pub query: ResourceSyncQuery, } #[typeshare] pub type ListResourceSyncsResponse = Vec; // /// List syncs matching optional query. Response: [ListFullResourceSyncsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListFullResourceSyncsResponse)] #[error(serror::Error)] pub struct ListFullResourceSyncs { /// optional structured query to filter syncs. #[serde(default)] pub query: ResourceSyncQuery, } #[typeshare] pub type ListFullResourceSyncsResponse = Vec; // /// Get current action state for the sync. Response: [ResourceSyncActionState]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetResourceSyncActionStateResponse)] #[error(serror::Error)] pub struct GetResourceSyncActionState { /// Id or name #[serde(alias = "id", alias = "name")] pub sync: String, } #[typeshare] pub type GetResourceSyncActionStateResponse = ResourceSyncActionState; // /// Gets a summary of data relating to all syncs. /// Response: [GetResourceSyncsSummaryResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetResourceSyncsSummaryResponse)] #[error(serror::Error)] pub struct GetResourceSyncsSummary {} /// Response for [GetResourceSyncsSummary] #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct GetResourceSyncsSummaryResponse { /// The total number of syncs pub total: u32, /// The number of syncs with Ok state. pub ok: u32, /// The number of syncs currently syncing. pub syncing: u32, /// The number of syncs with pending updates pub pending: u32, /// The number of syncs with failed state. pub failed: u32, /// The number of syncs with unknown state. pub unknown: u32, } // /// Get a target Sync's configured webhooks. Response: [GetSyncWebhooksEnabledResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetSyncWebhooksEnabledResponse)] #[error(serror::Error)] pub struct GetSyncWebhooksEnabled { /// Id or name #[serde(alias = "id", alias = "name")] pub sync: String, } /// Response for [GetSyncWebhooksEnabled] #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetSyncWebhooksEnabledResponse { /// Whether the repo webhooks can even be managed. /// The repo owner must be in `github_webhook_app.owners` list to be managed. pub managed: bool, /// Whether pushes to branch trigger refresh. Will always be false if managed is false. pub refresh_enabled: bool, /// Whether pushes to branch trigger sync execution. Will always be false if managed is false. pub sync_enabled: bool, } ================================================ FILE: client/core/rs/src/api/read/tag.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{MongoDocument, tag::Tag}; use super::KomodoReadRequest; // /// Get data for a specific tag. Response [Tag]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetTagResponse)] #[error(serror::Error)] pub struct GetTag { /// Id or name #[serde(alias = "id", alias = "name")] pub tag: String, } #[typeshare] pub type GetTagResponse = Tag; // /// List data for tags matching optional mongo query. /// Response: [ListTagsResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListTagsResponse)] #[error(serror::Error)] pub struct ListTags { pub query: Option, } #[typeshare] pub type ListTagsResponse = Vec; ================================================ FILE: client/core/rs/src/api/read/toml.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::ResourceTarget; use super::KomodoReadRequest; /// Response containing pretty formatted toml contents. #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TomlResponse { pub toml: String, } // /// Get pretty formatted monrun sync toml for all resources /// which the user has permissions to view. /// Response: [TomlResponse]. #[typeshare] #[derive( Debug, Clone, Default, Serialize, Deserialize, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ExportAllResourcesToTomlResponse)] #[error(serror::Error)] pub struct ExportAllResourcesToToml { /// Whether to include any resources (servers, stacks, etc.) /// in the exported contents. /// Default: `true` #[serde(default = "default_include_resources")] pub include_resources: bool, /// Filter resources by tag. /// Accepts tag name or id. Empty array will not filter by tag. #[serde(default)] pub tags: Vec, /// Whether to include variables in the exported contents. /// Default: false #[serde(default)] pub include_variables: bool, /// Whether to include user groups in the exported contents. /// Default: false #[serde(default)] pub include_user_groups: bool, } fn default_include_resources() -> bool { true } #[typeshare] pub type ExportAllResourcesToTomlResponse = TomlResponse; // /// Get pretty formatted monrun sync toml for specific resources and user groups. /// Response: [TomlResponse]. #[typeshare] #[derive( Debug, Clone, Default, Serialize, Deserialize, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ExportResourcesToTomlResponse)] #[error(serror::Error)] pub struct ExportResourcesToToml { /// The targets to include in the export. #[serde(default)] pub targets: Vec, /// The user group names or ids to include in the export. #[serde(default)] pub user_groups: Vec, /// Whether to include variables #[serde(default)] pub include_variables: bool, } #[typeshare] pub type ExportResourcesToTomlResponse = TomlResponse; ================================================ FILE: client/core/rs/src/api/read/update.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ MongoDocument, update::{Update, UpdateListItem}, }; use super::KomodoReadRequest; /// Get all data for the target update. /// Response: [Update]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetUpdateResponse)] #[error(serror::Error)] pub struct GetUpdate { /// The update id. pub id: String, } #[typeshare] pub type GetUpdateResponse = Update; // /// Paginated endpoint for updates matching optional query. /// More recent updates will be returned first. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListUpdatesResponse)] #[error(serror::Error)] pub struct ListUpdates { /// An optional mongo query to filter the updates. pub query: Option, /// Page of updates. Default is 0, which is the most recent data. /// Use with the `next_page` field of the response. #[serde(default)] pub page: u32, } /// Response for [ListUpdates]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ListUpdatesResponse { /// The page of updates, sorted by timestamp descending. pub updates: Vec, /// If there is a next page of data, pass this to `page` to get it. pub next_page: Option, } ================================================ FILE: client/core/rs/src/api/read/user.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{api_key::ApiKey, user::User}; use super::KomodoReadRequest; /// Gets list of api keys for the calling user. /// Response: [ListApiKeysResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListApiKeysResponse)] #[error(serror::Error)] pub struct ListApiKeys {} #[typeshare] pub type ListApiKeysResponse = Vec; // /// **Admin only.** /// Gets list of api keys for the user. /// Will still fail if you call for a user_id that isn't a service user. /// Response: [ListApiKeysForServiceUserResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListApiKeysForServiceUserResponse)] #[error(serror::Error)] pub struct ListApiKeysForServiceUser { /// Id or username #[serde(alias = "id", alias = "username")] pub user: String, } #[typeshare] pub type ListApiKeysForServiceUserResponse = Vec; // /// **Admin only.** /// Find a user. /// Response: [FindUserResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(FindUserResponse)] #[error(serror::Error)] pub struct FindUser { /// Id or username #[serde(alias = "id", alias = "username")] pub user: String, } #[typeshare] pub type FindUserResponse = User; // /// **Admin only.** /// Gets list of Komodo users. /// Response: [ListUsersResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListUsersResponse)] #[error(serror::Error)] pub struct ListUsers {} #[typeshare] pub type ListUsersResponse = Vec; // /// Gets the username of a specific user. /// Response: [GetUsernameResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetUsernameResponse)] #[error(serror::Error)] pub struct GetUsername { /// The id of the user. pub user_id: String, } /// Response for [GetUsername]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetUsernameResponse { /// The username of the user. pub username: String, /// An optional icon for the user. pub avatar: Option, } ================================================ FILE: client/core/rs/src/api/read/user_group.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::user_group::UserGroup; use super::KomodoReadRequest; /// Get a specific user group by name or id. /// Response: [UserGroup]. #[typeshare] #[derive( Debug, Clone, Serialize, Deserialize, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetUserGroupResponse)] #[error(serror::Error)] pub struct GetUserGroup { /// Name or Id pub user_group: String, } #[typeshare] pub type GetUserGroupResponse = UserGroup; // /// List all user groups which user can see. Response: [ListUserGroupsResponse]. /// /// Admins can see all user groups, /// and users can see user groups to which they belong. #[typeshare] #[derive( Debug, Clone, Default, Serialize, Deserialize, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListUserGroupsResponse)] #[error(serror::Error)] pub struct ListUserGroups {} #[typeshare] pub type ListUserGroupsResponse = Vec; ================================================ FILE: client/core/rs/src/api/read/variable.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::variable::Variable; use super::KomodoReadRequest; /// List all available global variables. /// Response: [Variable] /// /// Note. For non admin users making this call, /// secret variables will have their values obscured. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(GetVariableResponse)] #[error(serror::Error)] pub struct GetVariable { /// The name of the variable to get. pub name: String, } #[typeshare] pub type GetVariableResponse = Variable; // /// List all available global variables. /// Response: [ListVariablesResponse] /// /// Note. For non admin users making this call, /// secret variables will have their values obscured. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Resolve, EmptyTraits, )] #[empty_traits(KomodoReadRequest)] #[response(ListVariablesResponse)] #[error(serror::Error)] pub struct ListVariables {} #[typeshare] pub type ListVariablesResponse = Vec; ================================================ FILE: client/core/rs/src/api/terminal.rs ================================================ use serde::{Deserialize, Serialize}; use typeshare::typeshare; /// Query to connect to a terminal (interactive shell over websocket) on the given server. /// TODO: Document calling. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ConnectTerminalQuery { /// Server Id or name pub server: String, /// Each periphery can keep multiple terminals open. /// If a terminals with the specified name does not exist, /// the call will fail. /// Create a terminal using [CreateTerminal][super::write::server::CreateTerminal] pub terminal: String, } /// Execute a terminal command on the given server. /// TODO: Document calling. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ExecuteTerminalBody { /// Server Id or name pub server: String, /// The name of the terminal on the server to use to execute. /// If the terminal at name exists, it will be used to execute the command. /// Otherwise, a new terminal will be created for this command, which will /// persist until it exits or is deleted. pub terminal: String, /// The command to execute. pub command: String, } /// Query to connect to a container exec session (interactive shell over websocket) on the given server. /// TODO: Document calling. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ConnectContainerExecQuery { /// Server Id or name pub server: String, /// The container name pub container: String, /// The shell to use (eg. `sh` or `bash`) pub shell: String, } /// Execute a command in the given containers shell. /// TODO: Document calling. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ExecuteContainerExecBody { /// Server Id or name pub server: String, /// The container name pub container: String, /// The shell to use (eg. `sh` or `bash`) pub shell: String, /// The command to execute. pub command: String, } /// Query to connect to a container exec session (interactive shell over websocket) on the given Deployment. /// This call will use access to the Deployment Terminal to permission the call. /// TODO: Document calling. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ConnectDeploymentExecQuery { /// Deployment Id or name pub deployment: String, /// The shell to use (eg. `sh` or `bash`) pub shell: String, } /// Execute a command in the given containers shell. /// TODO: Document calling. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ExecuteDeploymentExecBody { /// Deployment Id or name pub deployment: String, /// The shell to use (eg. `sh` or `bash`) pub shell: String, /// The command to execute. pub command: String, } /// Query to connect to a container exec session (interactive shell over websocket) on the given Stack / service. /// This call will use access to the Stack Terminal to permission the call. /// TODO: Document calling. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ConnectStackExecQuery { /// Stack Id or name pub stack: String, /// The service name to connect to pub service: String, /// The shell to use (eg. `sh` or `bash`) pub shell: String, } /// Execute a command in the given containers shell. /// TODO: Document calling. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ExecuteStackExecBody { /// Stack Id or name pub stack: String, /// The service name to connect to pub service: String, /// The shell to use (eg. `sh` or `bash`) pub shell: String, /// The command to execute. pub command: String, } ================================================ FILE: client/core/rs/src/api/user.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::{HasResponse, Resolve}; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{I64, NoData, ResourceTarget}; pub trait KomodoUserRequest: HasResponse {} // /// Push a resource to the front of the users 10 most recently viewed resources. /// Response: [NoData]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoUserRequest)] #[response(PushRecentlyViewedResponse)] #[error(serror::Error)] pub struct PushRecentlyViewed { /// The target to push. pub resource: ResourceTarget, } #[typeshare] pub type PushRecentlyViewedResponse = NoData; // /// Set the time the user last opened the UI updates. /// Used for unseen notification dot. /// Response: [NoData] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoUserRequest)] #[response(SetLastSeenUpdateResponse)] #[error(serror::Error)] pub struct SetLastSeenUpdate {} #[typeshare] pub type SetLastSeenUpdateResponse = NoData; // /// Create an api key for the calling user. /// Response: [CreateApiKeyResponse]. /// /// Note. After the response is served, there will be no way /// to get the secret later. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoUserRequest)] #[response(CreateApiKeyResponse)] #[error(serror::Error)] pub struct CreateApiKey { /// The name for the api key. pub name: String, /// A unix timestamp in millseconds specifying api key expire time. /// Default is 0, which means no expiry. #[serde(default)] pub expires: I64, } /// Response for [CreateApiKey]. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct CreateApiKeyResponse { /// X-API-KEY pub key: String, /// X-API-SECRET /// /// Note. /// There is no way to get the secret again after it is distributed in this message pub secret: String, } // /// Delete an api key for the calling user. /// Response: [NoData] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoUserRequest)] #[response(DeleteApiKeyResponse)] #[error(serror::Error)] pub struct DeleteApiKey { /// The key which the user intends to delete. pub key: String, } #[typeshare] pub type DeleteApiKeyResponse = NoData; ================================================ FILE: client/core/rs/src/api/write/action.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ NoData, action::{_PartialActionConfig, Action}, update::Update, }; use super::KomodoWriteRequest; // /// Create a action. Response: [Action]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Action)] #[error(serror::Error)] pub struct CreateAction { /// The name given to newly created action. pub name: String, /// Optional partial config to initialize the action with. #[serde(default)] pub config: _PartialActionConfig, } // /// Creates a new action with given `name` and the configuration /// of the action at the given `id`. Response: [Action]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Action)] #[error(serror::Error)] pub struct CopyAction { /// The name of the new action. pub name: String, /// The id of the action to copy. pub id: String, } // /// Deletes the action at the given id, and returns the deleted action. /// Response: [Action] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Action)] #[error(serror::Error)] pub struct DeleteAction { /// The id or name of the action to delete. pub id: String, } // /// Update the action at the given id, and return the updated action. /// Response: [Action]. /// /// Note. This method updates only the fields which are set in the [_PartialActionConfig], /// effectively merging diffs into the final document. /// This is helpful when multiple users are using /// the same resources concurrently by ensuring no unintentional /// field changes occur from out of date local state. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Action)] #[error(serror::Error)] pub struct UpdateAction { /// The id of the action to update. pub id: String, /// The partial config update to apply. pub config: _PartialActionConfig, } // /// Rename the Action at id to the given name. /// Response: [Update]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RenameAction { /// The id or name of the Action to rename. pub id: String, /// The new name. pub name: String, } /// Create a webhook on the github action attached to the Action resource. /// passed in request. Response: [CreateActionWebhookResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(CreateActionWebhookResponse)] #[error(serror::Error)] pub struct CreateActionWebhook { /// Id or name #[serde(alias = "id", alias = "name")] pub action: String, } #[typeshare] pub type CreateActionWebhookResponse = NoData; // /// Delete the webhook on the github action attached to the Action resource. /// passed in request. Response: [DeleteActionWebhookResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(DeleteActionWebhookResponse)] #[error(serror::Error)] pub struct DeleteActionWebhook { /// Id or name #[serde(alias = "id", alias = "name")] pub action: String, } #[typeshare] pub type DeleteActionWebhookResponse = NoData; ================================================ FILE: client/core/rs/src/api/write/alerter.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ alerter::{_PartialAlerterConfig, Alerter}, update::Update, }; use super::KomodoWriteRequest; // /// Create an alerter. Response: [Alerter]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Alerter)] #[error(serror::Error)] pub struct CreateAlerter { /// The name given to newly created alerter. pub name: String, /// Optional partial config to initialize the alerter with. #[serde(default)] pub config: _PartialAlerterConfig, } // /// Creates a new alerter with given `name` and the configuration /// of the alerter at the given `id`. Response: [Alerter]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Alerter)] #[error(serror::Error)] pub struct CopyAlerter { /// The name of the new alerter. pub name: String, /// The id of the alerter to copy. pub id: String, } // /// Deletes the alerter at the given id, and returns the deleted alerter. /// Response: [Alerter] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Alerter)] #[error(serror::Error)] pub struct DeleteAlerter { /// The id or name of the alerter to delete. pub id: String, } // /// Update the alerter at the given id, and return the updated alerter. Response: [Alerter]. /// /// Note. This method updates only the fields which are set in the [PartialAlerterConfig][crate::entities::alerter::PartialAlerterConfig], /// effectively merging diffs into the final document. This is helpful when multiple users are using /// the same resources concurrently by ensuring no unintentional /// field changes occur from out of date local state. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Alerter)] #[error(serror::Error)] pub struct UpdateAlerter { /// The id of the alerter to update. pub id: String, /// The partial config update to apply. pub config: _PartialAlerterConfig, } // /// Rename the Alerter at id to the given name. /// Response: [Update]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RenameAlerter { /// The id or name of the Alerter to rename. pub id: String, /// The new name. pub name: String, } ================================================ FILE: client/core/rs/src/api/write/api_key.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::{ api::user::CreateApiKeyResponse, entities::{I64, NoData}, }; use super::KomodoWriteRequest; // /// Admin only method to create an api key for a service user. /// Response: [CreateApiKeyResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(CreateApiKeyForServiceUserResponse)] #[error(serror::Error)] pub struct CreateApiKeyForServiceUser { /// Must be service user pub user_id: String, /// The name for the api key pub name: String, /// A unix timestamp in millseconds specifying api key expire time. /// Default is 0, which means no expiry. #[serde(default)] pub expires: I64, } #[typeshare] pub type CreateApiKeyForServiceUserResponse = CreateApiKeyResponse; // /// Admin only method to delete an api key for a service user. /// Response: [NoData]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(DeleteApiKeyForServiceUserResponse)] #[error(serror::Error)] pub struct DeleteApiKeyForServiceUser { pub key: String, } #[typeshare] pub type DeleteApiKeyForServiceUserResponse = NoData; ================================================ FILE: client/core/rs/src/api/write/build.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ NoData, build::{_PartialBuildConfig, Build}, update::Update, }; use super::KomodoWriteRequest; // /// Create a build. Response: [Build]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Build)] #[error(serror::Error)] pub struct CreateBuild { /// The name given to newly created build. pub name: String, /// Optional partial config to initialize the build with. #[serde(default)] pub config: _PartialBuildConfig, } // /// Creates a new build with given `name` and the configuration /// of the build at the given `id`. Response: [Build]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Build)] #[error(serror::Error)] pub struct CopyBuild { /// The name of the new build. pub name: String, /// The id of the build to copy. pub id: String, } // /// Deletes the build at the given id, and returns the deleted build. /// Response: [Build] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Build)] #[error(serror::Error)] pub struct DeleteBuild { /// The id or name of the build to delete. pub id: String, } // /// Update the build at the given id, and return the updated build. /// Response: [Build]. /// /// Note. This method updates only the fields which are set in the [_PartialBuildConfig], /// effectively merging diffs into the final document. /// This is helpful when multiple users are using /// the same resources concurrently by ensuring no unintentional /// field changes occur from out of date local state. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Build)] #[error(serror::Error)] pub struct UpdateBuild { /// The id or name of the build to update. pub id: String, /// The partial config update to apply. pub config: _PartialBuildConfig, } // /// Rename the Build at id to the given name. /// Response: [Update]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RenameBuild { /// The id or name of the Build to rename. pub id: String, /// The new name. pub name: String, } // /// Update dockerfile contents in Files on Server or Git Repo mode. Response: [Update]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Update)] #[error(serror::Error)] pub struct WriteBuildFileContents { /// The name or id of the target Build. #[serde(alias = "id", alias = "name")] pub build: String, /// The dockerfile contents to write. pub contents: String, } // /// Trigger a refresh of the cached latest hash and message. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(NoData)] #[error(serror::Error)] pub struct RefreshBuildCache { /// Id or name #[serde(alias = "id", alias = "name")] pub build: String, } // /// Create a webhook on the github repo attached to the build /// passed in request. Response: [CreateBuildWebhookResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(CreateBuildWebhookResponse)] #[error(serror::Error)] pub struct CreateBuildWebhook { /// Id or name #[serde(alias = "id", alias = "name")] pub build: String, } #[typeshare] pub type CreateBuildWebhookResponse = NoData; // /// Delete a webhook on the github repo attached to the build /// passed in request. Response: [CreateBuildWebhookResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(DeleteBuildWebhookResponse)] #[error(serror::Error)] pub struct DeleteBuildWebhook { /// Id or name #[serde(alias = "id", alias = "name")] pub build: String, } #[typeshare] pub type DeleteBuildWebhookResponse = NoData; ================================================ FILE: client/core/rs/src/api/write/builder.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ builder::{Builder, PartialBuilderConfig}, update::Update, }; use super::KomodoWriteRequest; // /// Create a builder. Response: [Builder]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Builder)] #[error(serror::Error)] pub struct CreateBuilder { /// The name given to newly created builder. pub name: String, /// Optional partial config to initialize the builder with. #[serde(default)] pub config: PartialBuilderConfig, } // /// Creates a new builder with given `name` and the configuration /// of the builder at the given `id`. Response: [Builder] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Builder)] #[error(serror::Error)] pub struct CopyBuilder { /// The name of the new builder. pub name: String, /// The id of the builder to copy. pub id: String, } // /// Deletes the builder at the given id, and returns the deleted builder. /// Response: [Builder] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Builder)] #[error(serror::Error)] pub struct DeleteBuilder { /// The id or name of the builder to delete. pub id: String, } // /// Update the builder at the given id, and return the updated builder. /// Response: [Builder]. /// /// Note. This method updates only the fields which are set in the [PartialBuilderConfig], /// effectively merging diffs into the final document. /// This is helpful when multiple users are using /// the same resources concurrently by ensuring no unintentional /// field changes occur from out of date local state. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Builder)] #[error(serror::Error)] pub struct UpdateBuilder { /// The id of the builder to update. pub id: String, /// The partial config update to apply. pub config: PartialBuilderConfig, } // /// Rename the Builder at id to the given name. /// Response: [Update]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RenameBuilder { /// The id or name of the Builder to rename. pub id: String, /// The new name. pub name: String, } ================================================ FILE: client/core/rs/src/api/write/deployment.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ deployment::{_PartialDeploymentConfig, Deployment}, update::Update, }; use super::KomodoWriteRequest; // /// Create a deployment. Response: [Deployment]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Deployment)] #[error(serror::Error)] pub struct CreateDeployment { /// The name given to newly created deployment. pub name: String, /// Optional partial config to initialize the deployment with. #[serde(default)] pub config: _PartialDeploymentConfig, } // /// Creates a new deployment with given `name` and the configuration /// of the deployment at the given `id`. Response: [Deployment] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Deployment)] #[error(serror::Error)] pub struct CopyDeployment { /// The name of the new deployment. pub name: String, /// The id of the deployment to copy. pub id: String, } // /// Create a Deployment from an existing container. Response: [Deployment]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Deployment)] #[error(serror::Error)] pub struct CreateDeploymentFromContainer { /// The name or id of the existing container. pub name: String, /// The server id or name on which container exists. pub server: String, } // /// Deletes the deployment at the given id, and returns the deleted deployment. /// Response: [Deployment]. /// /// Note. If the associated container is running, it will be deleted as part of /// the deployment clean up. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Deployment)] #[error(serror::Error)] pub struct DeleteDeployment { /// The id or name of the deployment to delete. pub id: String, } // /// Update the deployment at the given id, and return the updated deployment. /// Response: [Deployment]. /// /// Note. If the attached server for the deployment changes, /// the deployment will be deleted / cleaned up on the old server. /// /// Note. This method updates only the fields which are set in the [_PartialDeploymentConfig], /// effectively merging diffs into the final document. /// This is helpful when multiple users are using /// the same resources concurrently by ensuring no unintentional /// field changes occur from out of date local state. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Deployment)] #[error(serror::Error)] pub struct UpdateDeployment { /// The deployment id to update. pub id: String, /// The partial config update. pub config: _PartialDeploymentConfig, } // /// Rename the deployment at id to the given name. Response: [Update]. /// /// Note. If a container is created for the deployment, it will be renamed using /// `docker rename ...`. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RenameDeployment { /// The id of the deployment to rename. pub id: String, /// The new name. pub name: String, } ================================================ FILE: client/core/rs/src/api/write/mod.rs ================================================ mod action; mod alerter; mod api_key; mod build; mod builder; mod deployment; mod permissions; mod procedure; mod provider; mod repo; mod resource; mod server; mod stack; mod sync; mod tags; mod user; mod user_group; mod variable; pub use action::*; pub use alerter::*; pub use api_key::*; pub use build::*; pub use builder::*; pub use deployment::*; pub use permissions::*; pub use procedure::*; pub use provider::*; pub use repo::*; pub use resource::*; pub use server::*; pub use stack::*; pub use sync::*; pub use tags::*; pub use user::*; pub use user_group::*; pub use variable::*; pub trait KomodoWriteRequest: resolver_api::HasResponse {} ================================================ FILE: client/core/rs/src/api/write/permissions.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ NoData, ResourceTarget, ResourceTargetVariant, permission::{PermissionLevelAndSpecifics, UserTarget}, }; use super::KomodoWriteRequest; /// **Admin only.** Update a user or user groups permission on a resource. /// Response: [NoData]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UpdatePermissionOnTargetResponse)] #[error(serror::Error)] pub struct UpdatePermissionOnTarget { /// Specify the user or user group. pub user_target: UserTarget, /// Specify the target resource. pub resource_target: ResourceTarget, /// Specify the permission level. pub permission: PermissionLevelAndSpecifics, } #[typeshare] pub type UpdatePermissionOnTargetResponse = NoData; // /// **Admin only.** Update a user or user groups base permission level on a resource type. /// Response: [NoData]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UpdatePermissionOnResourceTypeResponse)] #[error(serror::Error)] pub struct UpdatePermissionOnResourceType { /// Specify the user or user group. pub user_target: UserTarget, /// The resource type: eg. Server, Build, Deployment, etc. pub resource_type: ResourceTargetVariant, /// The base permission level. pub permission: PermissionLevelAndSpecifics, } #[typeshare] pub type UpdatePermissionOnResourceTypeResponse = NoData; // /// **Admin only.** Update a user's "base" permissions, eg. "enabled". /// Response: [NoData]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UpdateUserBasePermissionsResponse)] #[error(serror::Error)] pub struct UpdateUserBasePermissions { /// The target user. pub user_id: String, /// If specified, will update users enabled state. pub enabled: Option, /// If specified, will update user's ability to create servers. pub create_servers: Option, /// If specified, will update user's ability to create builds. pub create_builds: Option, } #[typeshare] pub type UpdateUserBasePermissionsResponse = NoData; /// **Super Admin only.** Update's whether a user is admin. /// Response: [NoData]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UpdateUserAdminResponse)] #[error(serror::Error)] pub struct UpdateUserAdmin { /// The target user. pub user_id: String, /// Whether user should be admin. pub admin: bool, } #[typeshare] pub type UpdateUserAdminResponse = NoData; ================================================ FILE: client/core/rs/src/api/write/procedure.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ procedure::{_PartialProcedureConfig, Procedure}, update::Update, }; use super::KomodoWriteRequest; // /// Create a procedure. Response: [Procedure]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(CreateProcedureResponse)] #[error(serror::Error)] pub struct CreateProcedure { /// The name given to newly created build. pub name: String, /// Optional partial config to initialize the procedure with. #[serde(default)] pub config: _PartialProcedureConfig, } #[typeshare] pub type CreateProcedureResponse = Procedure; // /// Creates a new procedure with given `name` and the configuration /// of the procedure at the given `id`. Response: [Procedure]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(CopyProcedureResponse)] #[error(serror::Error)] pub struct CopyProcedure { /// The name of the new procedure. pub name: String, /// The id of the procedure to copy. pub id: String, } #[typeshare] pub type CopyProcedureResponse = Procedure; // /// Deletes the procedure at the given id, and returns the deleted procedure. /// Response: [Procedure] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(DeleteProcedureResponse)] #[error(serror::Error)] pub struct DeleteProcedure { /// The id or name of the procedure to delete. pub id: String, } #[typeshare] pub type DeleteProcedureResponse = Procedure; // /// Update the procedure at the given id, and return the updated procedure. /// Response: [Procedure]. /// /// Note. This method updates only the fields which are set in the [_PartialProcedureConfig], /// effectively merging diffs into the final document. /// This is helpful when multiple users are using /// the same resources concurrently by ensuring no unintentional /// field changes occur from out of date local state. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UpdateProcedureResponse)] #[error(serror::Error)] pub struct UpdateProcedure { /// The id of the procedure to update. pub id: String, /// The partial config update. pub config: _PartialProcedureConfig, } #[typeshare] pub type UpdateProcedureResponse = Procedure; // /// Rename the Procedure at id to the given name. /// Response: [Update]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RenameProcedure { /// The id or name of the Procedure to rename. pub id: String, /// The new name. pub name: String, } ================================================ FILE: client/core/rs/src/api/write/provider.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::provider::*; use super::KomodoWriteRequest; /// **Admin only.** Create a git provider account. /// Response: [GitProviderAccount]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(CreateGitProviderAccountResponse)] #[error(serror::Error)] pub struct CreateGitProviderAccount { /// The initial account config. Anything in the _id field will be ignored, /// as this is generated on creation. pub account: _PartialGitProviderAccount, } #[typeshare] pub type CreateGitProviderAccountResponse = GitProviderAccount; // /// **Admin only.** Update a git provider account. /// Response: [GitProviderAccount]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UpdateGitProviderAccountResponse)] #[error(serror::Error)] pub struct UpdateGitProviderAccount { /// The id of the git provider account to update. pub id: String, /// The partial git provider account. pub account: _PartialGitProviderAccount, } #[typeshare] pub type UpdateGitProviderAccountResponse = GitProviderAccount; // /// **Admin only.** Delete a git provider account. /// Response: [DeleteGitProviderAccountResponse]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(DeleteGitProviderAccountResponse)] #[error(serror::Error)] pub struct DeleteGitProviderAccount { /// The id of the git provider to delete pub id: String, } #[typeshare] pub type DeleteGitProviderAccountResponse = GitProviderAccount; // /// **Admin only.** Create a docker registry account. /// Response: [DockerRegistryAccount]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(CreateDockerRegistryAccountResponse)] #[error(serror::Error)] pub struct CreateDockerRegistryAccount { pub account: _PartialDockerRegistryAccount, } #[typeshare] pub type CreateDockerRegistryAccountResponse = DockerRegistryAccount; // /// **Admin only.** Update a docker registry account. /// Response: [DockerRegistryAccount]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UpdateDockerRegistryAccountResponse)] #[error(serror::Error)] pub struct UpdateDockerRegistryAccount { /// The id of the docker registry to update pub id: String, /// The partial docker registry account. pub account: _PartialDockerRegistryAccount, } #[typeshare] pub type UpdateDockerRegistryAccountResponse = DockerRegistryAccount; // /// **Admin only.** Delete a docker registry account. /// Response: [DockerRegistryAccount]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(DeleteDockerRegistryAccountResponse)] #[error(serror::Error)] pub struct DeleteDockerRegistryAccount { /// The id of the docker registry account to delete pub id: String, } #[typeshare] pub type DeleteDockerRegistryAccountResponse = DockerRegistryAccount; ================================================ FILE: client/core/rs/src/api/write/repo.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ NoData, repo::{_PartialRepoConfig, Repo}, update::Update, }; use super::KomodoWriteRequest; // /// Create a repo. Response: [Repo]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Repo)] #[error(serror::Error)] pub struct CreateRepo { /// The name given to newly created repo. pub name: String, /// Optional partial config to initialize the repo with. #[serde(default)] pub config: _PartialRepoConfig, } // /// Creates a new repo with given `name` and the configuration /// of the repo at the given `id`. Response: [Repo]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Repo)] #[error(serror::Error)] pub struct CopyRepo { /// The name of the new repo. pub name: String, /// The id of the repo to copy. pub id: String, } // /// Deletes the repo at the given id, and returns the deleted repo. /// Response: [Repo] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Repo)] #[error(serror::Error)] pub struct DeleteRepo { /// The id or name of the repo to delete. pub id: String, } // /// Update the repo at the given id, and return the updated repo. /// Response: [Repo]. /// /// Note. If the attached server for the repo changes, /// the repo will be deleted / cleaned up on the old server. /// /// Note. This method updates only the fields which are set in the [_PartialRepoConfig], /// effectively merging diffs into the final document. /// This is helpful when multiple users are using /// the same resources concurrently by ensuring no unintentional /// field changes occur from out of date local state. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Repo)] #[error(serror::Error)] pub struct UpdateRepo { /// The id of the repo to update. pub id: String, /// The partial config update to apply. pub config: _PartialRepoConfig, } // /// Rename the Repo at id to the given name. /// Response: [Update]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RenameRepo { /// The id or name of the Repo to rename. pub id: String, /// The new name. pub name: String, } // /// Trigger a refresh of the cached latest hash and message. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(NoData)] #[error(serror::Error)] pub struct RefreshRepoCache { /// Id or name #[serde(alias = "id", alias = "name")] pub repo: String, } // #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] pub enum RepoWebhookAction { Clone, Pull, Build, } /// Create a webhook on the github repo attached to the (Komodo) Repo resource. /// passed in request. Response: [CreateRepoWebhookResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(CreateRepoWebhookResponse)] #[error(serror::Error)] pub struct CreateRepoWebhook { /// Id or name #[serde(alias = "id", alias = "name")] pub repo: String, /// "Clone" or "Pull" or "Build" pub action: RepoWebhookAction, } #[typeshare] pub type CreateRepoWebhookResponse = NoData; // /// Delete the webhook on the github repo attached to the (Komodo) Repo resource. /// passed in request. Response: [DeleteRepoWebhookResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(DeleteRepoWebhookResponse)] #[error(serror::Error)] pub struct DeleteRepoWebhook { /// Id or name #[serde(alias = "id", alias = "name")] pub repo: String, /// "Clone" or "Pull" or "Build" pub action: RepoWebhookAction, } #[typeshare] pub type DeleteRepoWebhookResponse = NoData; ================================================ FILE: client/core/rs/src/api/write/resource.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{NoData, ResourceTarget}; use super::KomodoWriteRequest; /// Update a resources common meta fields. /// - description /// - template /// - tags /// Response: [NoData]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UpdateResourceMetaResponse)] #[error(serror::Error)] pub struct UpdateResourceMeta { /// The target resource to set update meta. pub target: ResourceTarget, /// New description to set, /// or null for no update pub description: Option, /// New template value (true or false), /// or null for no update pub template: Option, /// The exact tags to set, /// or null for no update pub tags: Option>, } #[typeshare] pub type UpdateResourceMetaResponse = NoData; ================================================ FILE: client/core/rs/src/api/write/server.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ NoData, server::{_PartialServerConfig, Server}, update::Update, }; use super::KomodoWriteRequest; // /// Create a server. Response: [Server]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Server)] #[error(serror::Error)] pub struct CreateServer { /// The name given to newly created server. pub name: String, /// Optional partial config to initialize the server with. #[serde(default)] pub config: _PartialServerConfig, } // /// Creates a new server with given `name` and the configuration /// of the server at the given `id`. Response: [Server]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Server)] #[error(serror::Error)] pub struct CopyServer { /// The name of the new server. pub name: String, /// The id of the server to copy. pub id: String, } // /// Deletes the server at the given id, and returns the deleted server. /// Response: [Server] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Server)] #[error(serror::Error)] pub struct DeleteServer { /// The id or name of the server to delete. pub id: String, } // /// Update the server at the given id, and return the updated server. /// Response: [Server]. /// /// Note. This method updates only the fields which are set in the [_PartialServerConfig], /// effectively merging diffs into the final document. /// This is helpful when multiple users are using /// the same resources concurrently by ensuring no unintentional /// field changes occur from out of date local state. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Server)] #[error(serror::Error)] pub struct UpdateServer { /// The id or name of the server to update. pub id: String, /// The partial config update to apply. pub config: _PartialServerConfig, } // /// Rename an Server to the given name. /// Response: [Update]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RenameServer { /// The id or name of the Server to rename. pub id: String, /// The new name. pub name: String, } // /// Create a docker network on the server. /// Response: [Update] /// /// `docker network create {name}` #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Update)] #[error(serror::Error)] pub struct CreateNetwork { /// Server Id or name pub server: String, /// The name of the network to create. pub name: String, } // /// Configures the behavior of [CreateTerminal] if the /// specified terminal name already exists. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)] pub enum TerminalRecreateMode { /// Never kill the old terminal if it already exists. /// If the command is different, returns error. #[default] Never, /// Always kill the old terminal and create new one Always, /// Only kill and recreate if the command is different. DifferentCommand, } /// Create a terminal on the server. /// Response: [NoData] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(NoData)] #[error(serror::Error)] pub struct CreateTerminal { /// Server Id or name pub server: String, /// The name of the terminal on the server to create. pub name: String, /// The shell command (eg `bash`) to init the shell. /// /// This can also include args: /// `docker exec -it container sh` /// /// Default: `bash` #[serde(default = "default_command")] pub command: String, /// Default: `Never` #[serde(default)] pub recreate: TerminalRecreateMode, } fn default_command() -> String { String::from("bash") } // /// Delete a terminal on the server. /// Response: [NoData] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(NoData)] #[error(serror::Error)] pub struct DeleteTerminal { /// Server Id or name pub server: String, /// The name of the terminal on the server to delete. pub terminal: String, } /// Delete all terminals on the server. /// Response: [NoData] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(NoData)] #[error(serror::Error)] pub struct DeleteAllTerminals { /// Server Id or name pub server: String, } ================================================ FILE: client/core/rs/src/api/write/stack.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ NoData, stack::{_PartialStackConfig, Stack}, update::Update, }; use super::KomodoWriteRequest; // /// Create a stack. Response: [Stack]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Stack)] #[error(serror::Error)] pub struct CreateStack { /// The name given to newly created stack. pub name: String, /// Optional partial config to initialize the stack with. #[serde(default)] pub config: _PartialStackConfig, } // /// Creates a new stack with given `name` and the configuration /// of the stack at the given `id`. Response: [Stack]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Stack)] #[error(serror::Error)] pub struct CopyStack { /// The name of the new stack. pub name: String, /// The id of the stack to copy. pub id: String, } // /// Deletes the stack at the given id, and returns the deleted stack. /// Response: [Stack] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Stack)] #[error(serror::Error)] pub struct DeleteStack { /// The id or name of the stack to delete. pub id: String, } // /// Update the stack at the given id, and return the updated stack. /// Response: [Stack]. /// /// Note. If the attached server for the stack changes, /// the stack will be deleted / cleaned up on the old server. /// /// Note. This method updates only the fields which are set in the [_PartialStackConfig], /// merging diffs into the final document. /// This is helpful when multiple users are using /// the same resources concurrently by ensuring no unintentional /// field changes occur from out of date local state. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Stack)] #[error(serror::Error)] pub struct UpdateStack { /// The id of the Stack to update. pub id: String, /// The partial config update to apply. pub config: _PartialStackConfig, } // /// Rename the stack at id to the given name. Response: [Update]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RenameStack { /// The id of the stack to rename. pub id: String, /// The new name. pub name: String, } // /// Update file contents in Files on Server or Git Repo mode. Response: [Update]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Update)] #[error(serror::Error)] pub struct WriteStackFileContents { /// The name or id of the target Stack. #[serde(alias = "id", alias = "name")] pub stack: String, /// The file path relative to the stack run directory, /// or absolute path. pub file_path: String, /// The contents to write. pub contents: String, } // /// Trigger a refresh of the cached compose file contents. /// Refreshes: /// - Whether the remote file is missing /// - The latest json, and for repos, the remote contents, hash, and message. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(NoData)] #[error(serror::Error)] pub struct RefreshStackCache { /// Id or name #[serde(alias = "id", alias = "name")] pub stack: String, } // #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] pub enum StackWebhookAction { Refresh, Deploy, } /// Create a webhook on the github repo attached to the stack /// passed in request. Response: [CreateStackWebhookResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(CreateStackWebhookResponse)] #[error(serror::Error)] pub struct CreateStackWebhook { /// Id or name #[serde(alias = "id", alias = "name")] pub stack: String, /// "Refresh" or "Deploy" pub action: StackWebhookAction, } #[typeshare] pub type CreateStackWebhookResponse = NoData; // /// Delete the webhook on the github repo attached to the stack /// passed in request. Response: [DeleteStackWebhookResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(DeleteStackWebhookResponse)] #[error(serror::Error)] pub struct DeleteStackWebhook { /// Id or name #[serde(alias = "id", alias = "name")] pub stack: String, /// "Refresh" or "Deploy" pub action: StackWebhookAction, } #[typeshare] pub type DeleteStackWebhookResponse = NoData; ================================================ FILE: client/core/rs/src/api/write/sync.rs ================================================ use clap::Parser; use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{ NoData, sync::{_PartialResourceSyncConfig, ResourceSync}, update::Update, }; use super::KomodoWriteRequest; // /// Create a sync. Response: [ResourceSync]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(ResourceSync)] #[error(serror::Error)] pub struct CreateResourceSync { /// The name given to newly created sync. pub name: String, /// Optional partial config to initialize the sync with. #[serde(default)] pub config: _PartialResourceSyncConfig, } // /// Creates a new sync with given `name` and the configuration /// of the sync at the given `id`. Response: [ResourceSync]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(ResourceSync)] #[error(serror::Error)] pub struct CopyResourceSync { /// The name of the new sync. pub name: String, /// The id of the sync to copy. pub id: String, } // /// Deletes the sync at the given id, and returns the deleted sync. /// Response: [ResourceSync] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(ResourceSync)] #[error(serror::Error)] pub struct DeleteResourceSync { /// The id or name of the sync to delete. pub id: String, } // /// Update the sync at the given id, and return the updated sync. /// Response: [ResourceSync]. /// /// Note. This method updates only the fields which are set in the [_PartialResourceSyncConfig], /// effectively merging diffs into the final document. /// This is helpful when multiple users are using /// the same resources concurrently by ensuring no unintentional /// field changes occur from out of date local state. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(ResourceSync)] #[error(serror::Error)] pub struct UpdateResourceSync { /// The id of the sync to update. pub id: String, /// The partial config update to apply. pub config: _PartialResourceSyncConfig, } // /// Rename the ResourceSync at id to the given name. /// Response: [Update]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Update)] #[error(serror::Error)] pub struct RenameResourceSync { /// The id or name of the ResourceSync to rename. pub id: String, /// The new name. pub name: String, } // /// Trigger a refresh of the computed diff logs for view. Response: [ResourceSync] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(ResourceSync)] #[error(serror::Error)] pub struct RefreshResourceSyncPending { /// Id or name #[serde(alias = "id", alias = "name")] pub sync: String, } // /// Rename the stack at id to the given name. Response: [Update]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Update)] #[error(serror::Error)] pub struct WriteSyncFileContents { /// The name or id of the target Sync. #[serde(alias = "id", alias = "name")] pub sync: String, /// If this file was under a resource folder, this will be the folder. /// Otherwise, it should be empty string. pub resource_path: String, /// The file path relative to the resource path. pub file_path: String, /// The contents to write. pub contents: String, } // /// Exports matching resources, and writes to the target sync's resource file. Response: [Update] /// /// Note. Will fail if the Sync is not `managed`. #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Resolve, EmptyTraits, Parser, )] #[empty_traits(KomodoWriteRequest)] #[response(Update)] #[error(serror::Error)] pub struct CommitSync { /// Id or name #[serde(alias = "id", alias = "name")] pub sync: String, } // #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] pub enum SyncWebhookAction { Refresh, Sync, } /// Create a webhook on the github repo attached to the sync /// passed in request. Response: [CreateSyncWebhookResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(CreateSyncWebhookResponse)] #[error(serror::Error)] pub struct CreateSyncWebhook { /// Id or name #[serde(alias = "id", alias = "name")] pub sync: String, /// "Refresh" or "Sync" pub action: SyncWebhookAction, } #[typeshare] pub type CreateSyncWebhookResponse = NoData; // /// Delete the webhook on the github repo attached to the sync /// passed in request. Response: [DeleteSyncWebhookResponse] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(DeleteSyncWebhookResponse)] #[error(serror::Error)] pub struct DeleteSyncWebhook { /// Id or name #[serde(alias = "id", alias = "name")] pub sync: String, /// "Refresh" or "Sync" pub action: SyncWebhookAction, } #[typeshare] pub type DeleteSyncWebhookResponse = NoData; ================================================ FILE: client/core/rs/src/api/write/tags.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::tag::{Tag, TagColor}; use super::KomodoWriteRequest; // /// Create a tag. Response: [Tag]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Tag)] #[error(serror::Error)] pub struct CreateTag { /// The name of the tag. pub name: String, /// Tag color. Default: Slate. pub color: Option, } // /// Delete a tag, and return the deleted tag. Response: [Tag]. /// /// Note. Will also remove this tag from all attached resources. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Tag)] #[error(serror::Error)] pub struct DeleteTag { /// The id of the tag to delete. pub id: String, } // /// Rename a tag at id. Response: [Tag]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Tag)] #[error(serror::Error)] pub struct RenameTag { /// The id of the tag to rename. pub id: String, /// The new name of the tag. pub name: String, } /// Update color for tag. Response: [Tag]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(Tag)] #[error(serror::Error)] pub struct UpdateTagColor { /// The name or id of the tag to update. pub tag: String, /// The new color for the tag. pub color: TagColor, } ================================================ FILE: client/core/rs/src/api/write/user.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{NoData, user::User}; use super::KomodoWriteRequest; // /// **Only for local users**. Update the calling users username. /// Response: [NoData]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UpdateUserUsernameResponse)] #[error(serror::Error)] pub struct UpdateUserUsername { pub username: String, } #[typeshare] pub type UpdateUserUsernameResponse = NoData; // /// **Only for local users**. Update the calling users password. /// Response: [NoData]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UpdateUserPasswordResponse)] #[error(serror::Error)] pub struct UpdateUserPassword { pub password: String, } #[typeshare] pub type UpdateUserPasswordResponse = NoData; // /// **Admin only**. Delete a user. /// Admins can delete any non-admin user. /// Only Super Admin can delete an admin. /// No users can delete a Super Admin user. /// User cannot delete themselves. /// Response: [NoData]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(DeleteUserResponse)] #[error(serror::Error)] pub struct DeleteUser { /// User id or username #[serde(alias = "username", alias = "id")] pub user: String, } #[typeshare] pub type DeleteUserResponse = User; // /// **Admin only.** Create a local user. /// Response: [User]. /// /// Note. Not to be confused with /auth/SignUpLocalUser. /// This method requires admin user credentials, and can /// bypass disabled user registration. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(CreateLocalUserResponse)] #[error(serror::Error)] pub struct CreateLocalUser { /// The username for the local user. pub username: String, /// A password for the local user. pub password: String, } #[typeshare] pub type CreateLocalUserResponse = User; // /// **Admin only.** Create a service user. /// Response: [User]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(CreateServiceUserResponse)] #[error(serror::Error)] pub struct CreateServiceUser { /// The username for the service user. pub username: String, /// A description for the service user. pub description: String, } #[typeshare] pub type CreateServiceUserResponse = User; // /// **Admin only.** Update a service user's description. /// Response: [User]. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UpdateServiceUserDescriptionResponse)] #[error(serror::Error)] pub struct UpdateServiceUserDescription { /// The service user's username pub username: String, /// A new description for the service user. pub description: String, } #[typeshare] pub type UpdateServiceUserDescriptionResponse = User; ================================================ FILE: client/core/rs/src/api/write/user_group.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::user_group::UserGroup; use super::KomodoWriteRequest; /// **Admin only.** Create a user group. Response: [UserGroup] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UserGroup)] #[error(serror::Error)] pub struct CreateUserGroup { /// The name to assign to the new UserGroup pub name: String, } // /// **Admin only.** Rename a user group. Response: [UserGroup] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UserGroup)] #[error(serror::Error)] pub struct RenameUserGroup { /// The id of the UserGroup pub id: String, /// The new name for the UserGroup pub name: String, } // /// **Admin only.** Delete a user group. Response: [UserGroup] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UserGroup)] #[error(serror::Error)] pub struct DeleteUserGroup { /// The id of the UserGroup pub id: String, } // /// **Admin only.** Add a user to a user group. Response: [UserGroup] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UserGroup)] #[error(serror::Error)] pub struct AddUserToUserGroup { /// The name or id of UserGroup that user should be added to. pub user_group: String, /// The id or username of the user to add pub user: String, } // /// **Admin only.** Remove a user from a user group. Response: [UserGroup] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UserGroup)] #[error(serror::Error)] pub struct RemoveUserFromUserGroup { /// The name or id of UserGroup that user should be removed from. pub user_group: String, /// The id or username of the user to remove pub user: String, } // /// **Admin only.** Completely override the users in the group. /// Response: [UserGroup] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UserGroup)] #[error(serror::Error)] pub struct SetUsersInUserGroup { /// Id or name. pub user_group: String, /// The user ids or usernames to hard set as the group's users. pub users: Vec, } // /// **Admin only.** Set `everyone` property of User Group. /// Response: [UserGroup] #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UserGroup)] #[error(serror::Error)] pub struct SetEveryoneUserGroup { /// Id or name. pub user_group: String, /// Whether this user group applies to everyone. pub everyone: bool, } ================================================ FILE: client/core/rs/src/api/write/variable.rs ================================================ use derive_empty_traits::EmptyTraits; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::variable::Variable; use super::KomodoWriteRequest; /// **Admin only.** Create variable. Response: [Variable]. #[typeshare] #[derive( Debug, Clone, Serialize, Deserialize, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(CreateVariableResponse)] #[error(serror::Error)] pub struct CreateVariable { /// The name of the variable to create. pub name: String, /// The initial value of the variable. defualt: "". #[serde(default)] pub value: String, /// The initial value of the description. default: "". #[serde(default)] pub description: String, /// Whether to make this a secret variable. #[serde(default)] pub is_secret: bool, } #[typeshare] pub type CreateVariableResponse = Variable; // /// **Admin only.** Update variable value. Response: [Variable]. #[typeshare] #[derive( Debug, Clone, Serialize, Deserialize, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UpdateVariableValueResponse)] #[error(serror::Error)] pub struct UpdateVariableValue { /// The name of the variable to update. pub name: String, /// The value to set. pub value: String, } #[typeshare] pub type UpdateVariableValueResponse = Variable; // /// **Admin only.** Update variable description. Response: [Variable]. #[typeshare] #[derive( Debug, Clone, Serialize, Deserialize, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UpdateVariableDescriptionResponse)] #[error(serror::Error)] pub struct UpdateVariableDescription { /// The name of the variable to update. pub name: String, /// The description to set. pub description: String, } #[typeshare] pub type UpdateVariableDescriptionResponse = Variable; // /// **Admin only.** Update whether variable is secret. Response: [Variable]. #[typeshare] #[derive( Debug, Clone, Serialize, Deserialize, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(UpdateVariableIsSecretResponse)] #[error(serror::Error)] pub struct UpdateVariableIsSecret { /// The name of the variable to update. pub name: String, /// Whether variable is secret. pub is_secret: bool, } #[typeshare] pub type UpdateVariableIsSecretResponse = Variable; // /// **Admin only.** Delete a variable. Response: [Variable]. #[typeshare] #[derive( Debug, Clone, Serialize, Deserialize, Resolve, EmptyTraits, )] #[empty_traits(KomodoWriteRequest)] #[response(DeleteVariableResponse)] #[error(serror::Error)] pub struct DeleteVariable { pub name: String, } #[typeshare] pub type DeleteVariableResponse = Variable; ================================================ FILE: client/core/rs/src/busy.rs ================================================ use crate::entities::{ action::ActionActionState, build::BuildActionState, deployment::DeploymentActionState, procedure::ProcedureActionState, repo::RepoActionState, server::ServerActionState, stack::StackActionState, sync::ResourceSyncActionState, }; pub trait Busy { fn busy(&self) -> bool; } impl Busy for ServerActionState { fn busy(&self) -> bool { self.pruning_containers || self.pruning_images || self.pruning_networks || self.pruning_volumes || self.starting_containers || self.restarting_containers || self.pausing_containers || self.unpausing_containers || self.stopping_containers } } impl Busy for DeploymentActionState { fn busy(&self) -> bool { self.deploying || self.starting || self.restarting || self.pausing || self.unpausing || self.stopping || self.destroying || self.renaming } } impl Busy for StackActionState { fn busy(&self) -> bool { self.deploying || self.starting || self.restarting || self.pausing || self.unpausing || self.stopping || self.destroying } } impl Busy for BuildActionState { fn busy(&self) -> bool { self.building } } impl Busy for RepoActionState { fn busy(&self) -> bool { self.cloning || self.pulling || self.building } } impl Busy for ProcedureActionState { fn busy(&self) -> bool { self.running } } impl Busy for ActionActionState { fn busy(&self) -> bool { self.running > 0 } } impl Busy for ResourceSyncActionState { fn busy(&self) -> bool { self.syncing } } ================================================ FILE: client/core/rs/src/deserializers/conversion.rs ================================================ use serde::{ Deserialize, Deserializer, de::{Visitor, value::SeqAccessDeserializer}, }; use crate::entities::deployment::Conversion; pub fn conversions_deserializer<'de, D>( deserializer: D, ) -> Result where D: Deserializer<'de>, { deserializer.deserialize_any(ConversionVisitor) } pub fn option_conversions_deserializer<'de, D>( deserializer: D, ) -> Result, D::Error> where D: Deserializer<'de>, { deserializer.deserialize_any(OptionConversionVisitor) } struct ConversionVisitor; impl<'de> Visitor<'de> for ConversionVisitor { type Value = String; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "string or Vec") } fn visit_string(self, out: String) -> Result where E: serde::de::Error, { if out.is_empty() || out.ends_with('\n') { Ok(out) } else { Ok(out + "\n") } } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { Self::visit_string(self, v.to_string()) } fn visit_seq(self, seq: A) -> Result where A: serde::de::SeqAccess<'de>, { let res = Vec::::deserialize( SeqAccessDeserializer::new(seq), )?; let res = res .iter() .map(|Conversion { local, container }| { format!(" {local}: {container}") }) .collect::>() .join("\n"); let extra = if res.is_empty() { "" } else { "\n" }; Ok(res + extra) } } struct OptionConversionVisitor; impl<'de> Visitor<'de> for OptionConversionVisitor { type Value = Option; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "null or string or Vec") } fn visit_string(self, v: String) -> Result where E: serde::de::Error, { ConversionVisitor.visit_string(v).map(Some) } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { ConversionVisitor.visit_str(v).map(Some) } fn visit_seq(self, seq: A) -> Result where A: serde::de::SeqAccess<'de>, { ConversionVisitor.visit_seq(seq).map(Some) } fn visit_none(self) -> Result where E: serde::de::Error, { Ok(None) } fn visit_unit(self) -> Result where E: serde::de::Error, { Ok(None) } } ================================================ FILE: client/core/rs/src/deserializers/environment.rs ================================================ use serde::{ Deserialize, Deserializer, de::{Visitor, value::SeqAccessDeserializer}, }; use crate::entities::EnvironmentVar; pub fn env_vars_deserializer<'de, D>( deserializer: D, ) -> Result where D: Deserializer<'de>, { deserializer.deserialize_any(EnvironmentVarVisitor) } pub fn option_env_vars_deserializer<'de, D>( deserializer: D, ) -> Result, D::Error> where D: Deserializer<'de>, { deserializer.deserialize_any(OptionEnvVarVisitor) } struct EnvironmentVarVisitor; impl<'de> Visitor<'de> for EnvironmentVarVisitor { type Value = String; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "string or Vec") } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { let out = v.to_string(); if out.is_empty() || out.ends_with('\n') { Ok(out) } else { Ok(out + "\n") } } fn visit_seq(self, seq: A) -> Result where A: serde::de::SeqAccess<'de>, { let vars = Vec::::deserialize( SeqAccessDeserializer::new(seq), )?; let vars = vars .iter() .map(|EnvironmentVar { variable, value }| { format!(" {variable} = {value}") }) .collect::>() .join("\n"); let extra = if vars.is_empty() { "" } else { "\n" }; Ok(vars + extra) } } struct OptionEnvVarVisitor; impl<'de> Visitor<'de> for OptionEnvVarVisitor { type Value = Option; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "null or string or Vec") } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { EnvironmentVarVisitor.visit_str(v).map(Some) } fn visit_seq(self, seq: A) -> Result where A: serde::de::SeqAccess<'de>, { EnvironmentVarVisitor.visit_seq(seq).map(Some) } fn visit_none(self) -> Result where E: serde::de::Error, { Ok(None) } fn visit_unit(self) -> Result where E: serde::de::Error, { Ok(None) } } ================================================ FILE: client/core/rs/src/deserializers/file_contents.rs ================================================ use serde::{Deserializer, de::Visitor}; /// Using this ensures the file contents end with trailing '\n' pub fn file_contents_deserializer<'de, D>( deserializer: D, ) -> Result where D: Deserializer<'de>, { deserializer.deserialize_any(FileContentsVisitor) } /// Using this ensures the file contents end with trailing '\n' pub fn option_file_contents_deserializer<'de, D>( deserializer: D, ) -> Result, D::Error> where D: Deserializer<'de>, { deserializer.deserialize_any(OptionFileContentsVisitor) } struct FileContentsVisitor; impl Visitor<'_> for FileContentsVisitor { type Value = String; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "string") } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { let out = v.trim_end().to_string(); if out.is_empty() { Ok(out) } else { Ok(out + "\n") } } } struct OptionFileContentsVisitor; impl Visitor<'_> for OptionFileContentsVisitor { type Value = Option; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "null or string") } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { FileContentsVisitor.visit_str(v).map(Some) } fn visit_none(self) -> Result where E: serde::de::Error, { Ok(None) } fn visit_unit(self) -> Result where E: serde::de::Error, { Ok(None) } } ================================================ FILE: client/core/rs/src/deserializers/forgiving_vec.rs ================================================ use serde::{ Deserialize, Deserializer, de::{IntoDeserializer, Visitor}, }; #[derive(Debug, Clone)] pub struct ForgivingVec(pub Vec); impl ForgivingVec { pub fn iter(&self) -> std::slice::Iter<'_, T> { self.0.iter() } pub fn is_empty(&self) -> bool { self.0.is_empty() } } impl Default for ForgivingVec { fn default() -> Self { ForgivingVec(Vec::new()) } } impl IntoIterator for ForgivingVec { type Item = T; type IntoIter = as IntoIterator>::IntoIter; fn into_iter(self) -> Self::IntoIter { self.0.into_iter() } } impl FromIterator for ForgivingVec { fn from_iter>(iter: I) -> Self { Self(Vec::from_iter(iter)) } } impl<'de, T: Deserialize<'de>> Deserialize<'de> for ForgivingVec { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { deserializer.deserialize_seq(ForgivingVecVisitor::( std::marker::PhantomData, )) } } struct ForgivingVecVisitor(std::marker::PhantomData); impl<'de, T: Deserialize<'de>> Visitor<'de> for ForgivingVecVisitor { type Value = ForgivingVec; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "Vec") } fn visit_seq(self, mut seq: S) -> Result where S: serde::de::SeqAccess<'de>, { let mut res = Vec::with_capacity(seq.size_hint().unwrap_or_default()); loop { match seq.next_element::() { Ok(Some(value)) => { match T::deserialize(value.clone().into_deserializer()) { Ok(item) => res.push(item), Err(e) => { // Since this is used to parse startup config (including logging config), // the tracing logging is not initialized. Need to use eprintln. eprintln!( "WARN: failed to parse item in list | {value:?} | {e:?}", ) } } } Ok(None) => break, Err(e) => { eprintln!("WARN: failed to get item in list | {e:?}"); } } } Ok(ForgivingVec(res)) } } ================================================ FILE: client/core/rs/src/deserializers/item_or_vec.rs ================================================ //! # Item or Vec deserializer. //! //! Used to convert `item: T` (struct / map) -> `item: Vec` (seq) in schemas with backward compatibility. //! Supports deserializing either a T as Vec with length 1, or a seq as Vec directly. use serde::{ Deserialize, Deserializer, de::{ DeserializeOwned, IntoDeserializer, Visitor, value::{MapAccessDeserializer, SeqAccessDeserializer}, }, }; pub fn item_or_vec_deserializer<'de, D, T>( deserializer: D, ) -> Result, D::Error> where D: Deserializer<'de>, T: DeserializeOwned, { deserializer .deserialize_any(ItemOrVecVisitor::(std::marker::PhantomData)) } pub fn option_item_or_vec_deserializer<'de, D, T>( deserializer: D, ) -> Result>, D::Error> where D: Deserializer<'de>, T: DeserializeOwned, { deserializer.deserialize_any(OptionItemOrVecVisitor::( std::marker::PhantomData, )) } struct ItemOrVecVisitor(std::marker::PhantomData); impl<'de, T> Visitor<'de> for ItemOrVecVisitor where T: Deserialize<'de>, { type Value = Vec; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "Item or Vec") } fn visit_map(self, map: A) -> Result where A: serde::de::MapAccess<'de>, { T::deserialize( MapAccessDeserializer::new(map).into_deserializer(), ) .map(|r| vec![r]) } fn visit_seq(self, seq: A) -> Result where A: serde::de::SeqAccess<'de>, { Vec::::deserialize( SeqAccessDeserializer::new(seq).into_deserializer(), ) } } struct OptionItemOrVecVisitor(std::marker::PhantomData); impl<'de, T> Visitor<'de> for OptionItemOrVecVisitor where T: Deserialize<'de>, { type Value = Option>; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "null or Item or Vec") } fn visit_map(self, map: A) -> Result where A: serde::de::MapAccess<'de>, { ItemOrVecVisitor::(std::marker::PhantomData) .visit_map(map) .map(Some) } fn visit_seq(self, seq: A) -> Result where A: serde::de::SeqAccess<'de>, { ItemOrVecVisitor::(std::marker::PhantomData) .visit_seq(seq) .map(Some) } } ================================================ FILE: client/core/rs/src/deserializers/labels.rs ================================================ use serde::{ Deserialize, Deserializer, de::{Visitor, value::SeqAccessDeserializer}, }; use crate::entities::EnvironmentVar; pub fn labels_deserializer<'de, D>( deserializer: D, ) -> Result where D: Deserializer<'de>, { deserializer.deserialize_any(LabelVisitor) } pub fn option_labels_deserializer<'de, D>( deserializer: D, ) -> Result, D::Error> where D: Deserializer<'de>, { deserializer.deserialize_any(OptionLabelVisitor) } struct LabelVisitor; impl<'de> Visitor<'de> for LabelVisitor { type Value = String; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "string or Vec") } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { let out = v.to_string(); if out.is_empty() || out.ends_with('\n') { Ok(out) } else { Ok(out + "\n") } } fn visit_seq(self, seq: A) -> Result where A: serde::de::SeqAccess<'de>, { let vars = Vec::::deserialize( SeqAccessDeserializer::new(seq), )?; let vars = vars .iter() .map(|EnvironmentVar { variable, value }| { format!(" {variable}: {value}") }) .collect::>() .join("\n"); let extra = if vars.is_empty() { "" } else { "\n" }; Ok(vars + extra) } } struct OptionLabelVisitor; impl<'de> Visitor<'de> for OptionLabelVisitor { type Value = Option; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "null or string or Vec") } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { LabelVisitor.visit_str(v).map(Some) } fn visit_seq(self, seq: A) -> Result where A: serde::de::SeqAccess<'de>, { LabelVisitor.visit_seq(seq).map(Some) } fn visit_none(self) -> Result where E: serde::de::Error, { Ok(None) } fn visit_unit(self) -> Result where E: serde::de::Error, { Ok(None) } } ================================================ FILE: client/core/rs/src/deserializers/maybe_string_i64.rs ================================================ use serde::{Deserializer, de::Visitor}; pub fn maybe_string_i64_deserializer<'de, D>( deserializer: D, ) -> Result where D: Deserializer<'de>, { deserializer.deserialize_any(MaybeStringI64Visitor) } pub fn option_maybe_string_i64_deserializer<'de, D>( deserializer: D, ) -> Result, D::Error> where D: Deserializer<'de>, { deserializer.deserialize_any(OptionMaybeStringI64Visitor) } struct MaybeStringI64Visitor; impl Visitor<'_> for MaybeStringI64Visitor { type Value = i64; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "number or string number") } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { v.parse::().map_err(E::custom) } fn visit_f32(self, v: f32) -> Result where E: serde::de::Error, { Ok(v as i64) } fn visit_f64(self, v: f64) -> Result where E: serde::de::Error, { Ok(v as i64) } fn visit_i8(self, v: i8) -> Result where E: serde::de::Error, { Ok(v as i64) } fn visit_i16(self, v: i16) -> Result where E: serde::de::Error, { Ok(v as i64) } fn visit_i32(self, v: i32) -> Result where E: serde::de::Error, { Ok(v as i64) } fn visit_i64(self, v: i64) -> Result where E: serde::de::Error, { Ok(v) } fn visit_u8(self, v: u8) -> Result where E: serde::de::Error, { Ok(v as i64) } fn visit_u16(self, v: u16) -> Result where E: serde::de::Error, { Ok(v as i64) } fn visit_u32(self, v: u32) -> Result where E: serde::de::Error, { Ok(v as i64) } fn visit_u64(self, v: u64) -> Result where E: serde::de::Error, { Ok(v as i64) } } struct OptionMaybeStringI64Visitor; impl Visitor<'_> for OptionMaybeStringI64Visitor { type Value = Option; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "null or number or string number") } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { MaybeStringI64Visitor.visit_str(v).map(Some) } fn visit_f32(self, v: f32) -> Result where E: serde::de::Error, { Ok(Some(v as i64)) } fn visit_f64(self, v: f64) -> Result where E: serde::de::Error, { Ok(Some(v as i64)) } fn visit_i8(self, v: i8) -> Result where E: serde::de::Error, { Ok(Some(v as i64)) } fn visit_i16(self, v: i16) -> Result where E: serde::de::Error, { Ok(Some(v as i64)) } fn visit_i32(self, v: i32) -> Result where E: serde::de::Error, { Ok(Some(v as i64)) } fn visit_i64(self, v: i64) -> Result where E: serde::de::Error, { Ok(Some(v)) } fn visit_u8(self, v: u8) -> Result where E: serde::de::Error, { Ok(Some(v as i64)) } fn visit_u16(self, v: u16) -> Result where E: serde::de::Error, { Ok(Some(v as i64)) } fn visit_u32(self, v: u32) -> Result where E: serde::de::Error, { Ok(Some(v as i64)) } fn visit_u64(self, v: u64) -> Result where E: serde::de::Error, { Ok(Some(v as i64)) } fn visit_none(self) -> Result where E: serde::de::Error, { Ok(None) } fn visit_unit(self) -> Result where E: serde::de::Error, { Ok(None) } } ================================================ FILE: client/core/rs/src/deserializers/mod.rs ================================================ //! Deserializers for custom behavior and backward compatibility. mod conversion; mod environment; mod file_contents; mod forgiving_vec; mod item_or_vec; mod labels; mod maybe_string_i64; mod permission; mod string_list; mod term_signal_labels; pub use conversion::*; pub use environment::*; pub use file_contents::*; pub use forgiving_vec::*; pub use item_or_vec::*; pub use labels::*; pub use maybe_string_i64::*; pub use string_list::*; pub use term_signal_labels::*; ================================================ FILE: client/core/rs/src/deserializers/permission.rs ================================================ //! This is a module to deserialize [PermissionLevelAndSpecifics]. //! //! ## As just [PermissionLevel] //! permission = "Write" //! //! ## As expanded with [SpecificPermission] //! permission = { level = "Write", specific = ["Terminal"] } use std::str::FromStr; use indexmap::IndexSet; use serde::{ Deserialize, Serialize, de::{Visitor, value::MapAccessDeserializer}, }; use crate::entities::permission::{ PermissionLevel, PermissionLevelAndSpecifics, SpecificPermission, }; #[derive(Serialize, Deserialize)] struct _PermissionLevelAndSpecifics { #[serde(default)] level: PermissionLevel, #[serde(default)] specific: IndexSet, } impl Serialize for PermissionLevelAndSpecifics { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { if self.specific.is_empty() { // Serialize to simple string self.level.serialize(serializer) } else { _PermissionLevelAndSpecifics { level: self.level, specific: self.specific.clone(), } .serialize(serializer) } } } impl<'de> Deserialize<'de> for PermissionLevelAndSpecifics { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { deserializer.deserialize_any(PermissionLevelAndSpecificsVisitor) } } struct PermissionLevelAndSpecificsVisitor; impl<'de> Visitor<'de> for PermissionLevelAndSpecificsVisitor { type Value = PermissionLevelAndSpecifics; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!( formatter, "PermissionLevel or PermissionLevelAndSpecifics" ) } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { Ok(PermissionLevelAndSpecifics { level: PermissionLevel::from_str(v) .map_err(|e| serde::de::Error::custom(e))?, specific: IndexSet::new(), }) } fn visit_map(self, map: A) -> Result where A: serde::de::MapAccess<'de>, { _PermissionLevelAndSpecifics::deserialize( MapAccessDeserializer::new(map), ) .map(|p| PermissionLevelAndSpecifics { level: p.level, specific: p.specific, }) } fn visit_unit(self) -> Result where E: serde::de::Error, { Ok(PermissionLevelAndSpecifics { level: PermissionLevel::None, specific: IndexSet::new(), }) } fn visit_none(self) -> Result where E: serde::de::Error, { self.visit_unit() } } ================================================ FILE: client/core/rs/src/deserializers/string_list.rs ================================================ use serde::{ Deserialize, Deserializer, de::{SeqAccess, Visitor, value::SeqAccessDeserializer}, }; use crate::parsers::parse_string_list; pub fn string_list_deserializer<'de, D>( deserializer: D, ) -> Result, D::Error> where D: Deserializer<'de>, { deserializer.deserialize_any(StringListVisitor) } pub fn option_string_list_deserializer<'de, D>( deserializer: D, ) -> Result>, D::Error> where D: Deserializer<'de>, { deserializer.deserialize_any(OptionStringListVisitor) } struct StringListVisitor; impl<'de> Visitor<'de> for StringListVisitor { type Value = Vec; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "string or Vec") } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { Ok(parse_string_list(v)) } fn visit_seq(self, seq: A) -> Result where A: serde::de::SeqAccess<'de>, { Vec::::deserialize(SeqAccessDeserializer::new(seq)) } } struct OptionStringListVisitor; impl<'de> Visitor<'de> for OptionStringListVisitor { type Value = Option>; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "null or string or Vec") } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { StringListVisitor.visit_str(v).map(Some) } fn visit_seq(self, seq: A) -> Result where A: SeqAccess<'de>, { StringListVisitor.visit_seq(seq).map(Some) } fn visit_none(self) -> Result where E: serde::de::Error, { Ok(None) } fn visit_unit(self) -> Result where E: serde::de::Error, { Ok(None) } } ================================================ FILE: client/core/rs/src/deserializers/term_signal_labels.rs ================================================ use serde::{ Deserialize, Deserializer, de::{Visitor, value::SeqAccessDeserializer}, }; use crate::entities::deployment::TerminationSignalLabel; pub fn term_labels_deserializer<'de, D>( deserializer: D, ) -> Result where D: Deserializer<'de>, { deserializer.deserialize_any(TermSignalLabelVisitor) } pub fn option_term_labels_deserializer<'de, D>( deserializer: D, ) -> Result, D::Error> where D: Deserializer<'de>, { deserializer.deserialize_any(OptionTermSignalLabelVisitor) } struct TermSignalLabelVisitor; impl<'de> Visitor<'de> for TermSignalLabelVisitor { type Value = String; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "string or Vec") } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { let out = v.to_string(); if out.is_empty() || out.ends_with('\n') { Ok(out) } else { Ok(out + "\n") } } fn visit_seq(self, seq: A) -> Result where A: serde::de::SeqAccess<'de>, { let res = Vec::::deserialize( SeqAccessDeserializer::new(seq), )? .into_iter() .map(|TerminationSignalLabel { signal, label }| { format!(" {signal}: {label}") }) .collect::>() .join("\n"); let extra = if res.is_empty() { "" } else { "\n" }; Ok(res + extra) } } struct OptionTermSignalLabelVisitor; impl<'de> Visitor<'de> for OptionTermSignalLabelVisitor { type Value = Option; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "null or string or Vec") } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { TermSignalLabelVisitor.visit_str(v).map(Some) } fn visit_seq(self, seq: A) -> Result where A: serde::de::SeqAccess<'de>, { TermSignalLabelVisitor.visit_seq(seq).map(Some) } fn visit_none(self) -> Result where E: serde::de::Error, { Ok(None) } fn visit_unit(self) -> Result where E: serde::de::Error, { Ok(None) } } ================================================ FILE: client/core/rs/src/entities/action.rs ================================================ use bson::{Document, doc}; use derive_builder::Builder; use derive_default_builder::DefaultBuilder; use partial_derive2::Partial; use serde::{Deserialize, Serialize}; use strum::Display; use typeshare::typeshare; use crate::{ deserializers::{ file_contents_deserializer, option_file_contents_deserializer, }, entities::{FileFormat, I64, NoData}, }; use super::{ ScheduleFormat, resource::{Resource, ResourceListItem, ResourceQuery}, }; #[typeshare] pub type ActionListItem = ResourceListItem; #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct ActionListItemInfo { /// Whether last action run successful pub state: ActionState, /// Action last successful run timestamp in ms. pub last_run_at: Option, /// If the action has schedule enabled, this is the /// next scheduled run time in unix ms. pub next_scheduled_run: Option, /// If there is an error parsing schedule expression, /// it will be given here. pub schedule_error: Option, } #[typeshare] #[derive( Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Display, Serialize, Deserialize, )] pub enum ActionState { /// Unknown case #[default] Unknown, /// Last clone / pull successful (or never cloned) Ok, /// Last clone / pull failed Failed, /// Currently running Running, } #[typeshare] pub type Action = Resource; #[typeshare(serialized_as = "Partial")] pub type _PartialActionConfig = PartialActionConfig; #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Builder, Partial)] #[partial_derive(Serialize, Deserialize, Debug, Clone, Default)] #[partial(skip_serializing_none, from, diff)] pub struct ActionConfig { /// Whether this action should run at startup. #[serde(default = "default_run_at_startup")] #[builder(default = "default_run_at_startup()")] #[partial_default(default_run_at_startup())] pub run_at_startup: bool, /// Choose whether to specify schedule as regular CRON, or using the english to CRON parser. #[serde(default)] #[builder(default)] pub schedule_format: ScheduleFormat, /// Optionally provide a schedule for the procedure to run on. /// /// There are 2 ways to specify a schedule: /// /// 1. Regular CRON expression: /// /// (second, minute, hour, day, month, day-of-week) /// ```text /// 0 0 0 1,15 * ? /// ``` /// /// 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron): /// /// ```text /// at midnight on the 1st and 15th of the month /// ``` #[serde(default)] #[builder(default)] pub schedule: String, /// Whether schedule is enabled if one is provided. /// Can be used to temporarily disable the schedule. #[serde(default = "default_schedule_enabled")] #[builder(default = "default_schedule_enabled()")] #[partial_default(default_schedule_enabled())] pub schedule_enabled: bool, /// Optional. A TZ Identifier. If not provided, will use Core local timezone. /// https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. #[serde(default)] #[builder(default)] pub schedule_timezone: String, /// Whether to send alerts when the schedule was run. #[serde(default = "default_schedule_alert")] #[builder(default = "default_schedule_alert()")] #[partial_default(default_schedule_alert())] pub schedule_alert: bool, /// Whether to send alerts when this action fails. #[serde(default = "default_failure_alert")] #[builder(default = "default_failure_alert()")] #[partial_default(default_failure_alert())] pub failure_alert: bool, /// Whether incoming webhooks actually trigger action. #[serde(default = "default_webhook_enabled")] #[builder(default = "default_webhook_enabled()")] #[partial_default(default_webhook_enabled())] pub webhook_enabled: bool, /// Optionally provide an alternate webhook secret for this procedure. /// If its an empty string, use the default secret from the config. #[serde(default)] #[builder(default)] pub webhook_secret: String, /// Whether deno will be instructed to reload all dependencies, /// this can usually be kept false outside of development. #[serde(default)] #[builder(default)] pub reload_deno_deps: bool, /// Typescript file contents using pre-initialized `komodo` client. /// Supports variable / secret interpolation. #[serde(default, deserialize_with = "file_contents_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_file_contents_deserializer" ))] #[builder(default)] pub file_contents: String, /// Specify the format in which the arguments are defined. /// Default: `key_value` (like environment) #[serde(default)] #[builder(default)] pub arguments_format: FileFormat, /// Default arguments to give to the Action for use in the script at `ARGS`. #[serde(default, deserialize_with = "file_contents_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_file_contents_deserializer" ))] #[builder(default)] pub arguments: String, } fn default_schedule_enabled() -> bool { true } fn default_schedule_alert() -> bool { true } fn default_failure_alert() -> bool { true } fn default_run_at_startup() -> bool { false } fn default_webhook_enabled() -> bool { true } impl ActionConfig { pub fn builder() -> ActionConfigBuilder { ActionConfigBuilder::default() } } impl Default for ActionConfig { fn default() -> Self { Self { schedule_format: Default::default(), schedule: Default::default(), schedule_enabled: default_schedule_enabled(), schedule_timezone: Default::default(), run_at_startup: default_run_at_startup(), schedule_alert: default_schedule_alert(), failure_alert: default_failure_alert(), webhook_enabled: default_webhook_enabled(), webhook_secret: Default::default(), reload_deno_deps: Default::default(), arguments_format: Default::default(), file_contents: Default::default(), arguments: Default::default(), } } } #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)] pub struct ActionActionState { /// Number of instances of the Action currently running pub running: u32, } #[typeshare] pub type ActionQuery = ResourceQuery; #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, DefaultBuilder, )] pub struct ActionQuerySpecifics {} impl super::resource::AddFilters for ActionQuerySpecifics { fn add_filters(&self, _filters: &mut Document) {} } ================================================ FILE: client/core/rs/src/entities/alert.rs ================================================ use std::path::PathBuf; use derive_variants::EnumVariants; use serde::{Deserialize, Serialize}; use strum::{Display, EnumString}; use typeshare::typeshare; use crate::entities::{I64, MongoId}; use super::{ _Serror, ResourceTarget, ResourceTargetVariant, Version, deployment::DeploymentState, stack::StackState, }; /// Representation of an alert in the system. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] #[cfg_attr( feature = "mongo", derive(mongo_indexed::derive::MongoIndexed) )] #[cfg_attr(feature = "mongo", doc_index({ "data.type": 1 }))] #[cfg_attr(feature = "mongo", doc_index({ "target.type": 1 }))] #[cfg_attr(feature = "mongo", doc_index({ "target.id": 1 }))] pub struct Alert { /// The Mongo ID of the alert. /// This field is de/serialized from/to JSON as /// `{ "_id": { "$oid": "..." }, ...(rest of serialized Alert) }` #[serde( default, rename = "_id", skip_serializing_if = "String::is_empty", with = "bson::serde_helpers::hex_string_as_object_id" )] pub id: MongoId, /// Unix timestamp in milliseconds the alert was opened #[cfg_attr(feature = "mongo", index)] pub ts: I64, /// Whether the alert is already resolved #[cfg_attr(feature = "mongo", index)] pub resolved: bool, /// The severity of the alert #[cfg_attr(feature = "mongo", index)] pub level: SeverityLevel, /// The target of the alert pub target: ResourceTarget, /// The data attached to the alert pub data: AlertData, /// The timestamp of alert resolution pub resolved_ts: Option, } /// The variants of data related to the alert. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, EnumVariants)] #[variant_derive( Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash )] #[serde(tag = "type", content = "data")] pub enum AlertData { /// A null alert None {}, /// The user triggered a test of the /// Alerter configuration. Test { /// The id of the alerter id: String, /// The name of the alerter name: String, }, /// A server could not be reached. ServerUnreachable { /// The id of the server id: String, /// The name of the server name: String, /// The region of the server region: Option, /// The error data err: Option<_Serror>, }, /// A server has high CPU usage. ServerCpu { /// The id of the server id: String, /// The name of the server name: String, /// The region of the server region: Option, /// The cpu usage percentage percentage: f64, }, /// A server has high memory usage. ServerMem { /// The id of the server id: String, /// The name of the server name: String, /// The region of the server region: Option, /// The used memory used_gb: f64, /// The total memory total_gb: f64, }, /// A server has high disk usage. ServerDisk { /// The id of the server id: String, /// The name of the server name: String, /// The region of the server region: Option, /// The mount path of the disk path: PathBuf, /// The used portion of the disk in GB used_gb: f64, /// The total size of the disk in GB total_gb: f64, }, /// A server has a version mismatch with the core. ServerVersionMismatch { /// The id of the server id: String, /// The name of the server name: String, /// The region of the server region: Option, /// The actual server version server_version: String, /// The core version core_version: String, }, /// A container's state has changed unexpectedly. ContainerStateChange { /// The id of the deployment id: String, /// The name of the deployment name: String, /// The server id of server that the deployment is on server_id: String, /// The server name server_name: String, /// The previous container state from: DeploymentState, /// The current container state to: DeploymentState, }, /// A Deployment has an image update available DeploymentImageUpdateAvailable { /// The id of the deployment id: String, /// The name of the deployment name: String, /// The server id of server that the deployment is on server_id: String, /// The server name server_name: String, /// The image with update image: String, }, /// A Deployment has an image update available DeploymentAutoUpdated { /// The id of the deployment id: String, /// The name of the deployment name: String, /// The server id of server that the deployment is on server_id: String, /// The server name server_name: String, /// The updated image image: String, }, /// A stack's state has changed unexpectedly. StackStateChange { /// The id of the stack id: String, /// The name of the stack name: String, /// The server id of server that the stack is on server_id: String, /// The server name server_name: String, /// The previous stack state from: StackState, /// The current stack state to: StackState, }, /// A Stack has an image update available StackImageUpdateAvailable { /// The id of the stack id: String, /// The name of the stack name: String, /// The server id of server that the stack is on server_id: String, /// The server name server_name: String, /// The service name to update service: String, /// The image with update image: String, }, /// A Stack was auto updated StackAutoUpdated { /// The id of the stack id: String, /// The name of the stack name: String, /// The server id of server that the stack is on server_id: String, /// The server name server_name: String, /// One or more images that were updated images: Vec, }, /// An AWS builder failed to terminate. AwsBuilderTerminationFailed { /// The id of the aws instance which failed to terminate instance_id: String, /// A reason for the failure message: String, }, /// A resource sync has pending updates ResourceSyncPendingUpdates { /// The id of the resource sync id: String, /// The name of the resource sync name: String, }, /// A build has failed BuildFailed { /// The id of the build id: String, /// The name of the build name: String, /// The version that failed to build version: Version, }, /// A repo has failed RepoBuildFailed { /// The id of the repo id: String, /// The name of the repo name: String, }, /// A procedure has failed ProcedureFailed { /// The id of the procedure id: String, /// The name of the procedure name: String, }, /// An action has failed ActionFailed { /// The id of the action id: String, /// The name of the action name: String, }, /// A schedule was run ScheduleRun { /// Procedure or Action resource_type: ResourceTargetVariant, /// The resource id id: String, /// The resource name name: String, }, /// Custom header / body. /// Produced using `/execute/SendAlert` Custom { /// The alert message. message: String, /// Message details. May be empty string. #[serde(default)] details: String, }, } impl Default for AlertData { fn default() -> Self { AlertData::None {} } } #[allow(clippy::derivable_impls)] impl Default for AlertDataVariant { fn default() -> Self { AlertDataVariant::None } } /// Severity level of problem. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Default, Display, EnumString, )] #[serde(rename_all = "UPPERCASE")] #[strum(serialize_all = "UPPERCASE")] pub enum SeverityLevel { /// No problem. /// /// Aliases: ok, low, l #[default] #[strum(serialize = "ok", serialize = "low", serialize = "l")] Ok, /// Problem is imminent. /// /// Aliases: warning, w, medium, m #[strum( serialize = "warning", serialize = "w", serialize = "medium", serialize = "m" )] Warning, /// Problem fully realized. /// /// Aliases: critical, c, high, h #[strum( serialize = "critical", serialize = "c", serialize = "high", serialize = "h" )] Critical, } ================================================ FILE: client/core/rs/src/entities/alerter.rs ================================================ use bson::{Document, doc}; use derive_builder::Builder; use derive_default_builder::DefaultBuilder; use derive_variants::EnumVariants; use partial_derive2::Partial; use serde::{Deserialize, Serialize}; use strum::{AsRefStr, Display, EnumString}; use typeshare::typeshare; use crate::entities::MaintenanceWindow; use super::{ ResourceTarget, alert::AlertDataVariant, resource::{Resource, ResourceListItem, ResourceQuery}, }; #[typeshare] pub type Alerter = Resource; #[typeshare] pub type AlerterListItem = ResourceListItem; #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct AlerterListItemInfo { /// Whether alerter is enabled for sending alerts pub enabled: bool, /// The type of the alerter, eg. `Slack`, `Custom` pub endpoint_type: AlerterEndpointVariant, } #[typeshare(serialized_as = "Partial")] pub type _PartialAlerterConfig = PartialAlerterConfig; #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Builder, Partial)] #[partial_derive(Serialize, Deserialize, Debug, Clone, Default)] #[partial(skip_serializing_none, from, diff)] pub struct AlerterConfig { /// Whether the alerter is enabled #[serde(default)] #[builder(default)] pub enabled: bool, /// Where to route the alert messages. /// /// Default: Custom endpoint `http://localhost:7000` #[serde(default)] #[builder(default)] pub endpoint: AlerterEndpoint, /// Only send specific alert types. /// If empty, will send all alert types. #[serde(default)] #[builder(default)] pub alert_types: Vec, /// Only send alerts on specific resources. /// If empty, will send alerts for all resources. #[serde(default)] #[builder(default)] pub resources: Vec, /// DON'T send alerts on these resources. #[serde(default)] #[builder(default)] pub except_resources: Vec, /// Scheduled maintenance windows during which alerts will be suppressed. #[serde(default)] #[builder(default)] pub maintenance_windows: Vec, } impl AlerterConfig { pub fn builder() -> AlerterConfigBuilder { AlerterConfigBuilder::default() } } #[allow(clippy::derivable_impls)] impl Default for AlerterConfig { fn default() -> Self { Self { enabled: Default::default(), endpoint: Default::default(), alert_types: Default::default(), resources: Default::default(), except_resources: Default::default(), maintenance_windows: Default::default(), } } } // ENDPOINTS #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, EnumVariants, )] #[variant_derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Display, EnumString, AsRefStr, Serialize, Deserialize )] #[serde(tag = "type", content = "params")] pub enum AlerterEndpoint { /// Send alert serialized to JSON to an http endpoint. Custom(CustomAlerterEndpoint), /// Send alert to a Slack app Slack(SlackAlerterEndpoint), /// Send alert to a Discord app Discord(DiscordAlerterEndpoint), /// Send alert to Ntfy Ntfy(NtfyAlerterEndpoint), /// Send alert to Pushover Pushover(PushoverAlerterEndpoint), } impl Default for AlerterEndpoint { fn default() -> Self { Self::Custom(Default::default()) } } /// Configuration for a Custom alerter endpoint. #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Builder, )] pub struct CustomAlerterEndpoint { /// The http/s endpoint to send the POST to #[serde(default = "default_custom_url")] #[builder(default = "default_custom_url()")] pub url: String, } impl Default for CustomAlerterEndpoint { fn default() -> Self { Self { url: default_custom_url(), } } } fn default_custom_url() -> String { String::from("http://localhost:7000") } /// Configuration for a Slack alerter. #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Builder, )] pub struct SlackAlerterEndpoint { /// The Slack app webhook url #[serde(default = "default_slack_url")] #[builder(default = "default_slack_url()")] pub url: String, } impl Default for SlackAlerterEndpoint { fn default() -> Self { Self { url: default_slack_url(), } } } fn default_slack_url() -> String { String::from( "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX", ) } /// Configuration for a Discord alerter. #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Builder, )] pub struct DiscordAlerterEndpoint { /// The Discord webhook url #[serde(default = "default_discord_url")] #[builder(default = "default_discord_url()")] pub url: String, } impl Default for DiscordAlerterEndpoint { fn default() -> Self { Self { url: default_discord_url(), } } } fn default_discord_url() -> String { String::from( "https://discord.com/api/webhooks/XXXXXXXXXXXX/XXXX-XXXXXXXXXX", ) } /// Configuration for a Ntfy alerter. #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Builder, )] pub struct NtfyAlerterEndpoint { /// The ntfy topic URL #[serde(default = "default_ntfy_url")] #[builder(default = "default_ntfy_url()")] pub url: String, /// Optional E-Mail Address to enable ntfy email notifications. /// SMTP must be configured on the ntfy server. pub email: Option, } impl Default for NtfyAlerterEndpoint { fn default() -> Self { Self { url: default_ntfy_url(), email: None, } } } fn default_ntfy_url() -> String { String::from("http://localhost:8080/komodo") } /// Configuration for a Pushover alerter. #[typeshare] #[derive( Debug, Clone, PartialEq, Serialize, Deserialize, Builder, )] pub struct PushoverAlerterEndpoint { /// The pushover URL including application and user tokens in parameters. #[serde(default = "default_pushover_url")] #[builder(default = "default_pushover_url()")] pub url: String, } impl Default for PushoverAlerterEndpoint { fn default() -> Self { Self { url: default_pushover_url(), } } } fn default_pushover_url() -> String { String::from( "https://api.pushover.net/1/messages.json?token=XXXXXXXXXXXXX&user=XXXXXXXXXXXXX", ) } // QUERY #[typeshare] pub type AlerterQuery = ResourceQuery; #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, DefaultBuilder, )] pub struct AlerterQuerySpecifics { /// Filter alerters by enabled. /// - `None`: Don't filter by enabled /// - `Some(true)`: Only include alerts with `enabled: true` /// - `Some(false)`: Only include alerts with `enabled: false` pub enabled: Option, /// Only include alerters with these endpoint types. /// If empty, don't filter by enpoint type. pub types: Vec, } impl super::resource::AddFilters for AlerterQuerySpecifics { fn add_filters(&self, filters: &mut Document) { if let Some(enabled) = self.enabled { filters.insert("config.enabled", enabled); } let types = self.types.iter().map(|t| t.as_ref()).collect::>(); if !self.types.is_empty() { filters.insert("config.endpoint.type", doc! { "$in": types }); } } } ================================================ FILE: client/core/rs/src/entities/api_key.rs ================================================ use serde::{Deserialize, Serialize}; use typeshare::typeshare; use super::I64; /// An api key used to authenticate requests via request headers. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] #[cfg_attr( feature = "mongo", derive(mongo_indexed::derive::MongoIndexed) )] pub struct ApiKey { /// Unique key associated with secret #[cfg_attr(feature = "mongo", unique_index)] pub key: String, /// Hash of the secret pub secret: String, /// User associated with the api key #[cfg_attr(feature = "mongo", index)] pub user_id: String, /// Name associated with the api key for management pub name: String, /// Timestamp of key creation pub created_at: I64, /// Expiry of key, or 0 if never expires pub expires: I64, } impl ApiKey { pub fn sanitize(&mut self) { self.secret.clear() } } ================================================ FILE: client/core/rs/src/entities/build.rs ================================================ use std::{fmt::Write, sync::OnceLock}; use bson::{Document, doc}; use derive_builder::Builder; use derive_default_builder::DefaultBuilder; use partial_derive2::Partial; use serde::{Deserialize, Serialize}; use strum::Display; use typeshare::typeshare; use crate::{ deserializers::{ env_vars_deserializer, item_or_vec_deserializer, labels_deserializer, option_env_vars_deserializer, option_item_or_vec_deserializer, option_labels_deserializer, option_string_list_deserializer, string_list_deserializer, }, entities::I64, }; use super::{ SystemCommand, Version, resource::{Resource, ResourceListItem, ResourceQuery}, }; #[typeshare] pub type Build = Resource; impl Build { pub fn get_image_names(&self) -> Vec { let Build { name, config: BuildConfig { image_name, image_registry, .. }, .. } = self; let name = if image_name.is_empty() { name } else { image_name }; // Local only if image_registry.is_empty() { return vec![name.to_string()]; } image_registry .iter() .map( |ImageRegistryConfig { domain, account, organization, }| { match ( !domain.is_empty(), !organization.is_empty(), !account.is_empty(), ) { // If organization and account provided, name under organization. (true, true, true) => { format!("{domain}/{organization}/{name}") } // Just domain / account provided (true, false, true) => { format!("{domain}/{account}/{name}") } // Otherwise, just use name (local only) _ => name.to_string(), } }, ) .collect() } pub fn get_image_tags( &self, image_names: &[String], commit_hash: Option<&str>, additional: &[String], ) -> Vec { let BuildConfig { version, image_tag, include_latest_tag, include_version_tags: include_version_tag, include_commit_tag, .. } = &self.config; let Version { major, minor, .. } = version; let image_tag_postfix = if image_tag.is_empty() { String::new() } else { format!("-{image_tag}") }; let mut tags = Vec::new(); for image_name in image_names { // Pure image tag passthrough when provided if !image_tag.is_empty() { tags.push(format!("{image_name}:{image_tag}")); } // `:latest` / `:latest-tag` if *include_latest_tag { tags.push(format!("{image_name}:latest{image_tag_postfix}")); } // `:1.19.5` + `:1.19` etc. / `1.19.5-tag` if *include_version_tag { tags .push(format!("{image_name}:{version}{image_tag_postfix}")); tags.push(format!( "{image_name}:{major}.{minor}{image_tag_postfix}" )); tags.push(format!("{image_name}:{major}{image_tag_postfix}")); } if *include_commit_tag && let Some(hash) = commit_hash { tags.push(format!("{image_name}:{hash}{image_tag_postfix}")); } for tag in additional { tags.push(format!("{image_name}:{tag}")) } } tags } pub fn get_image_tags_as_arg( &self, commit_hash: Option<&str>, additional: &[String], ) -> anyhow::Result { let mut res = String::new(); for image_tag in self.get_image_tags( &self.get_image_names(), commit_hash, additional, ) { write!(&mut res, " -t {image_tag}")?; } Ok(res) } } #[typeshare] pub type BuildListItem = ResourceListItem; #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BuildListItemInfo { /// State of the build. Reflects whether most recent build successful. pub state: BuildState, /// Unix timestamp in milliseconds of last build pub last_built_at: I64, /// The current version of the build pub version: Version, /// The builder attached to build. pub builder_id: String, /// Whether build is in files on host mode. pub files_on_host: bool, /// Whether build has UI defined dockerfile contents pub dockerfile_contents: bool, /// Linked repo, if one is attached. pub linked_repo: String, /// The git provider domain pub git_provider: String, /// The repo used as the source of the build pub repo: String, /// The branch of the repo pub branch: String, /// Full link to the repo. pub repo_link: String, /// Latest built short commit hash, or null. pub built_hash: Option, /// Latest short commit hash, or null. Only for repo based stacks pub latest_hash: Option, /// The first listed image registry domain pub image_registry_domain: Option, } #[typeshare] #[derive( Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Display, )] pub enum BuildState { /// Currently building Building, /// Last build successful (or never built) Ok, /// Last build failed Failed, /// Other case #[default] Unknown, } #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct BuildInfo { /// The timestamp build was last built. pub last_built_at: I64, /// Latest built short commit hash, or null. pub built_hash: Option, /// Latest built commit message, or null. Only for repo based stacks pub built_message: Option, /// The last built dockerfile contents. /// This is updated whenever Komodo successfully runs the build. pub built_contents: Option, /// The absolute path to the file pub remote_path: Option, /// The remote dockerfile contents, whether on host or in repo. /// This is updated whenever Komodo refreshes the build cache. /// It will be empty if the dockerfile is defined directly in the build config. pub remote_contents: Option, /// If there was an error in getting the remote contents, it will be here. pub remote_error: Option, /// Latest remote short commit hash, or null. pub latest_hash: Option, /// Latest remote commit message, or null pub latest_message: Option, } #[typeshare(serialized_as = "Partial")] pub type _PartialBuildConfig = PartialBuildConfig; /// The build configuration. #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, Builder, Partial)] #[partial_derive(Debug, Clone, Default, Serialize, Deserialize)] #[partial(skip_serializing_none, from, diff)] pub struct BuildConfig { /// Which builder is used to build the image. #[serde(default, alias = "builder")] #[partial_attr(serde(alias = "builder"))] #[builder(default)] pub builder_id: String, /// The current version of the build. #[serde(default)] #[builder(default)] pub version: Version, /// Whether to automatically increment the patch on every build. /// Default is `true` #[serde(default = "default_auto_increment_version")] #[builder(default = "default_auto_increment_version()")] #[partial_default(default_auto_increment_version())] pub auto_increment_version: bool, /// An alternate name for the image pushed to the repository. /// If this is empty, it will use the build name. /// /// Can be used in conjunction with `image_tag` to direct multiple builds /// with different configs to push to the same image registry, under different, /// independantly versioned tags. #[serde(default)] #[builder(default)] pub image_name: String, /// An extra tag put after the build version, for the image pushed to the repository. /// Eg. in image tag of `aarch64` would push to moghtech/komodo-core:1.13.2-aarch64. /// If this is empty, the image tag will just be the build version. /// /// Can be used in conjunction with `image_name` to direct multiple builds /// with different configs to push to the same image registry, under different, /// independantly versioned tags. #[serde(default)] #[builder(default)] pub image_tag: String, /// Push `:latest` / `:latest-image_tag` tags. #[serde(default = "default_include_tag")] #[builder(default = "default_include_tag()")] #[partial_default(default_include_tag())] pub include_latest_tag: bool, /// Push build version semver `:1.19.5` + `1.19` / `:1.19.5-image_tag` tags. #[serde(default = "default_include_tag")] #[builder(default = "default_include_tag()")] #[partial_default(default_include_tag())] pub include_version_tags: bool, /// Push commit hash `:a6v8h83` / `:a6v8h83-image_tag` tags. #[serde(default = "default_include_tag")] #[builder(default = "default_include_tag()")] #[partial_default(default_include_tag())] pub include_commit_tag: bool, /// Configure quick links that are displayed in the resource header #[serde(default, deserialize_with = "string_list_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_string_list_deserializer" ))] #[builder(default)] pub links: Vec, /// Choose a Komodo Repo (Resource) to source the build files. #[serde(default)] #[builder(default)] pub linked_repo: String, /// The git provider domain. Default: github.com #[serde(default = "default_git_provider")] #[builder(default = "default_git_provider()")] #[partial_default(default_git_provider())] pub git_provider: String, /// Whether to use https to clone the repo (versus http). Default: true /// /// Note. Komodo does not currently support cloning repos via ssh. #[serde(default = "default_git_https")] #[builder(default = "default_git_https()")] #[partial_default(default_git_https())] pub git_https: bool, /// The git account used to access private repos. /// Passing empty string can only clone public repos. /// /// Note. A token for the account must be available in the core config or the builder server's periphery config /// for the configured git provider. #[serde(default)] #[builder(default)] pub git_account: String, /// The repo used as the source of the build. #[serde(default)] #[builder(default)] pub repo: String, /// The branch of the repo. #[serde(default = "default_branch")] #[builder(default = "default_branch()")] #[partial_default(default_branch())] pub branch: String, /// Optionally set a specific commit hash. #[serde(default)] #[builder(default)] pub commit: String, /// Whether incoming webhooks actually trigger action. #[serde(default = "default_webhook_enabled")] #[builder(default = "default_webhook_enabled()")] #[partial_default(default_webhook_enabled())] pub webhook_enabled: bool, /// Optionally provide an alternate webhook secret for this build. /// If its an empty string, use the default secret from the config. #[serde(default)] #[builder(default)] pub webhook_secret: String, /// If this is checked, the build will source the files on the host. /// Use `build_path` and `dockerfile_path` to specify the path on the host. /// This is useful for those who wish to setup their files on the host, /// rather than defining the contents in UI or in a git repo. #[serde(default)] #[builder(default)] pub files_on_host: bool, /// The path of the docker build context relative to the root of the repo. /// Default: "." (the root of the repo). #[serde(default = "default_build_path")] #[builder(default = "default_build_path()")] #[partial_default(default_build_path())] pub build_path: String, /// The path of the dockerfile relative to the build path. #[serde(default = "default_dockerfile_path")] #[builder(default = "default_dockerfile_path()")] #[partial_default(default_dockerfile_path())] pub dockerfile_path: String, /// Configuration for the registry/s to push the built image to. /// The first registry in this list will be used with attached Deployments. #[serde(default, deserialize_with = "item_or_vec_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_item_or_vec_deserializer" ))] #[builder(default)] pub image_registry: Vec, /// Whether to skip secret interpolation in the build_args. #[serde(default)] #[builder(default)] pub skip_secret_interp: bool, /// Whether to use buildx to build (eg `docker buildx build ...`) #[serde(default)] #[builder(default)] pub use_buildx: bool, /// Any extra docker cli arguments to be included in the build command #[serde(default, deserialize_with = "string_list_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_string_list_deserializer" ))] #[builder(default)] pub extra_args: Vec, /// The optional command run after repo clone and before docker build. #[serde(default)] #[builder(default)] pub pre_build: SystemCommand, /// UI defined dockerfile contents. /// Supports variable / secret interpolation. #[serde(default)] #[builder(default)] pub dockerfile: String, /// Docker build arguments. /// /// These values are visible in the final image by running `docker inspect`. #[serde(default, deserialize_with = "env_vars_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_env_vars_deserializer" ))] #[builder(default)] pub build_args: String, /// Secret arguments. /// /// These values remain hidden in the final image by using /// docker secret mounts. See . /// /// The values can be used in RUN commands: /// ```sh /// RUN --mount=type=secret,id=SECRET_KEY \ /// SECRET_KEY=$(cat /run/secrets/SECRET_KEY) ... /// ``` #[serde(default, deserialize_with = "env_vars_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_env_vars_deserializer" ))] #[builder(default)] pub secret_args: String, /// Docker labels #[serde(default, deserialize_with = "labels_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_labels_deserializer" ))] #[builder(default)] pub labels: String, } impl BuildConfig { pub fn builder() -> BuildConfigBuilder { BuildConfigBuilder::default() } } fn default_auto_increment_version() -> bool { true } fn default_include_tag() -> bool { true } fn default_git_provider() -> String { String::from("github.com") } fn default_git_https() -> bool { true } fn default_branch() -> String { String::from("main") } fn default_build_path() -> String { String::from(".") } fn default_dockerfile_path() -> String { String::from("Dockerfile") } fn default_webhook_enabled() -> bool { true } impl Default for BuildConfig { fn default() -> Self { Self { builder_id: Default::default(), skip_secret_interp: Default::default(), version: Default::default(), auto_increment_version: default_auto_increment_version(), image_name: Default::default(), image_tag: Default::default(), include_latest_tag: default_include_tag(), include_version_tags: default_include_tag(), include_commit_tag: default_include_tag(), links: Default::default(), linked_repo: Default::default(), git_provider: default_git_provider(), git_https: default_git_https(), repo: Default::default(), branch: default_branch(), commit: Default::default(), git_account: Default::default(), pre_build: Default::default(), build_path: default_build_path(), dockerfile_path: default_dockerfile_path(), build_args: Default::default(), secret_args: Default::default(), labels: Default::default(), extra_args: Default::default(), use_buildx: Default::default(), image_registry: Default::default(), webhook_enabled: default_webhook_enabled(), webhook_secret: Default::default(), dockerfile: Default::default(), files_on_host: Default::default(), } } } /// Configuration for an image registry #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ImageRegistryConfig { /// Specify the registry provider domain, eg `docker.io`. /// If not provided, will not push to any registry. #[serde(default)] pub domain: String, /// Specify an account to use with the registry. #[serde(default)] pub account: String, /// Optional. Specify an organization to push the image under. /// Empty string means no organization. #[serde(default)] pub organization: String, } impl ImageRegistryConfig { pub fn static_default() -> &'static ImageRegistryConfig { static DEFAULT: OnceLock = OnceLock::new(); DEFAULT.get_or_init(Default::default) } } #[typeshare] #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] pub struct BuildActionState { pub building: bool, } #[typeshare] pub type BuildQuery = ResourceQuery; #[typeshare] #[derive( Debug, Clone, Default, Serialize, Deserialize, DefaultBuilder, )] pub struct BuildQuerySpecifics { #[serde(default)] pub builder_ids: Vec, #[serde(default)] pub repos: Vec, /// query for builds last built more recently than this timestamp /// defaults to 0 which is a no op #[serde(default)] pub built_since: I64, } impl super::resource::AddFilters for BuildQuerySpecifics { fn add_filters(&self, filters: &mut Document) { if !self.builder_ids.is_empty() { filters.insert( "config.builder_id", doc! { "$in": &self.builder_ids }, ); } if !self.repos.is_empty() { filters.insert("config.repo", doc! { "$in": &self.repos }); } if self.built_since > 0 { filters.insert( "info.last_built_at", doc! { "$gte": self.built_since }, ); } } } ================================================ FILE: client/core/rs/src/entities/builder.rs ================================================ use derive_builder::Builder; use derive_variants::EnumVariants; use partial_derive2::{Diff, MaybeNone, Partial, PartialDiff}; use serde::{Deserialize, Serialize}; use strum::{Display, EnumString}; use typeshare::typeshare; use crate::deserializers::{ option_string_list_deserializer, string_list_deserializer, }; use super::{ MergePartial, config::{DockerRegistry, GitProvider}, resource::{AddFilters, Resource, ResourceListItem, ResourceQuery}, }; #[typeshare] pub type Builder = Resource; #[typeshare] pub type BuilderListItem = ResourceListItem; #[typeshare(serialized_as = "Partial")] pub type _PartialBuilderConfig = PartialBuilderConfig; #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct BuilderListItemInfo { /// 'Url', 'Server', or 'Aws' pub builder_type: String, /// If 'Url': null /// If 'Server': the server id /// If 'Aws': the instance type (eg. c5.xlarge) pub instance_type: Option, } #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, EnumVariants)] #[variant_derive( Serialize, Deserialize, Debug, Clone, Copy, Display, EnumString )] #[serde(tag = "type", content = "params")] #[allow(clippy::large_enum_variant)] pub enum BuilderConfig { /// Use a Periphery address as a Builder. Url(UrlBuilderConfig), /// Use a connected server as a Builder. Server(ServerBuilderConfig), /// Use EC2 instances spawned on demand as a Builder. Aws(AwsBuilderConfig), } impl Default for BuilderConfig { fn default() -> Self { Self::Aws(Default::default()) } } /// Partial representation of [BuilderConfig] #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, EnumVariants)] #[variant_derive( Serialize, Deserialize, Debug, Clone, Copy, Display, EnumString )] #[serde(tag = "type", content = "params")] #[allow(clippy::large_enum_variant)] pub enum PartialBuilderConfig { Url(#[serde(default)] _PartialUrlBuilderConfig), Server(#[serde(default)] _PartialServerBuilderConfig), Aws(#[serde(default)] _PartialAwsBuilderConfig), } impl Default for PartialBuilderConfig { fn default() -> Self { Self::Url(Default::default()) } } impl MaybeNone for PartialBuilderConfig { fn is_none(&self) -> bool { match self { PartialBuilderConfig::Url(config) => config.is_none(), PartialBuilderConfig::Server(config) => config.is_none(), PartialBuilderConfig::Aws(config) => config.is_none(), } } } #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, Serialize, Deserialize)] pub enum BuilderConfigDiff { Url(UrlBuilderConfigDiff), Server(ServerBuilderConfigDiff), Aws(AwsBuilderConfigDiff), } impl From for PartialBuilderConfig { fn from(value: BuilderConfigDiff) -> Self { match value { BuilderConfigDiff::Url(diff) => { PartialBuilderConfig::Url(diff.into()) } BuilderConfigDiff::Server(diff) => { PartialBuilderConfig::Server(diff.into()) } BuilderConfigDiff::Aws(diff) => { PartialBuilderConfig::Aws(diff.into()) } } } } impl Diff for BuilderConfigDiff { fn iter_field_diffs( &self, ) -> impl Iterator { match self { BuilderConfigDiff::Url(diff) => { diff.iter_field_diffs().collect::>().into_iter() } BuilderConfigDiff::Server(diff) => { diff.iter_field_diffs().collect::>().into_iter() } BuilderConfigDiff::Aws(diff) => { diff.iter_field_diffs().collect::>().into_iter() } } } } impl PartialDiff for BuilderConfig { fn partial_diff( &self, partial: PartialBuilderConfig, ) -> BuilderConfigDiff { match self { BuilderConfig::Url(original) => match partial { PartialBuilderConfig::Url(partial) => { BuilderConfigDiff::Url(original.partial_diff(partial)) } PartialBuilderConfig::Server(partial) => { let default = ServerBuilderConfig::default(); BuilderConfigDiff::Server(default.partial_diff(partial)) } PartialBuilderConfig::Aws(partial) => { let default = AwsBuilderConfig::default(); BuilderConfigDiff::Aws(default.partial_diff(partial)) } }, BuilderConfig::Server(original) => match partial { PartialBuilderConfig::Server(partial) => { BuilderConfigDiff::Server(original.partial_diff(partial)) } PartialBuilderConfig::Url(partial) => { let default = UrlBuilderConfig::default(); BuilderConfigDiff::Url(default.partial_diff(partial)) } PartialBuilderConfig::Aws(partial) => { let default = AwsBuilderConfig::default(); BuilderConfigDiff::Aws(default.partial_diff(partial)) } }, BuilderConfig::Aws(original) => match partial { PartialBuilderConfig::Aws(partial) => { BuilderConfigDiff::Aws(original.partial_diff(partial)) } PartialBuilderConfig::Url(partial) => { let default = UrlBuilderConfig::default(); BuilderConfigDiff::Url(default.partial_diff(partial)) } PartialBuilderConfig::Server(partial) => { let default = ServerBuilderConfig::default(); BuilderConfigDiff::Server(default.partial_diff(partial)) } }, } } } impl MaybeNone for BuilderConfigDiff { fn is_none(&self) -> bool { match self { BuilderConfigDiff::Url(config) => config.is_none(), BuilderConfigDiff::Server(config) => config.is_none(), BuilderConfigDiff::Aws(config) => config.is_none(), } } } impl From for BuilderConfig { fn from(value: PartialBuilderConfig) -> BuilderConfig { match value { PartialBuilderConfig::Url(server) => { BuilderConfig::Url(server.into()) } PartialBuilderConfig::Server(server) => { BuilderConfig::Server(server.into()) } PartialBuilderConfig::Aws(builder) => { BuilderConfig::Aws(builder.into()) } } } } impl From for PartialBuilderConfig { fn from(value: BuilderConfig) -> Self { match value { BuilderConfig::Url(config) => { PartialBuilderConfig::Url(config.into()) } BuilderConfig::Server(config) => { PartialBuilderConfig::Server(config.into()) } BuilderConfig::Aws(config) => { PartialBuilderConfig::Aws(config.into()) } } } } impl MergePartial for BuilderConfig { type Partial = PartialBuilderConfig; fn merge_partial( self, partial: PartialBuilderConfig, ) -> BuilderConfig { match partial { PartialBuilderConfig::Url(partial) => match self { BuilderConfig::Url(config) => { let config = UrlBuilderConfig { address: partial.address.unwrap_or(config.address), passkey: partial.passkey.unwrap_or(config.passkey), }; BuilderConfig::Url(config) } _ => BuilderConfig::Url(partial.into()), }, PartialBuilderConfig::Server(partial) => match self { BuilderConfig::Server(config) => { let config = ServerBuilderConfig { server_id: partial.server_id.unwrap_or(config.server_id), }; BuilderConfig::Server(config) } _ => BuilderConfig::Server(partial.into()), }, PartialBuilderConfig::Aws(partial) => match self { BuilderConfig::Aws(config) => { let config = AwsBuilderConfig { region: partial.region.unwrap_or(config.region), instance_type: partial .instance_type .unwrap_or(config.instance_type), volume_gb: partial.volume_gb.unwrap_or(config.volume_gb), ami_id: partial.ami_id.unwrap_or(config.ami_id), subnet_id: partial.subnet_id.unwrap_or(config.subnet_id), security_group_ids: partial .security_group_ids .unwrap_or(config.security_group_ids), key_pair_name: partial .key_pair_name .unwrap_or(config.key_pair_name), assign_public_ip: partial .assign_public_ip .unwrap_or(config.assign_public_ip), use_public_ip: partial .use_public_ip .unwrap_or(config.use_public_ip), port: partial.port.unwrap_or(config.port), use_https: partial.use_https.unwrap_or(config.use_https), user_data: partial.user_data.unwrap_or(config.user_data), git_providers: partial .git_providers .unwrap_or(config.git_providers), docker_registries: partial .docker_registries .unwrap_or(config.docker_registries), secrets: partial.secrets.unwrap_or(config.secrets), }; BuilderConfig::Aws(config) } _ => BuilderConfig::Aws(partial.into()), }, } } } #[typeshare(serialized_as = "Partial")] pub type _PartialUrlBuilderConfig = PartialUrlBuilderConfig; /// Configuration for a Komodo Url Builder. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Builder, Partial)] #[partial_derive(Serialize, Deserialize, Debug, Clone, Default)] #[partial(skip_serializing_none, from, diff)] pub struct UrlBuilderConfig { /// The address of the Periphery agent #[serde(default = "default_address")] pub address: String, /// A custom passkey to use. Otherwise, use the default passkey. #[serde(default)] pub passkey: String, } fn default_address() -> String { String::from("https://periphery:8120") } impl Default for UrlBuilderConfig { fn default() -> Self { Self { address: default_address(), passkey: Default::default(), } } } impl UrlBuilderConfig { pub fn builder() -> UrlBuilderConfigBuilder { UrlBuilderConfigBuilder::default() } } #[typeshare(serialized_as = "Partial")] pub type _PartialServerBuilderConfig = PartialServerBuilderConfig; /// Configuration for a Komodo Server Builder. #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, Builder, Partial, )] #[partial_derive(Serialize, Deserialize, Debug, Clone, Default)] #[partial(skip_serializing_none, from, diff)] pub struct ServerBuilderConfig { /// The server id of the builder #[serde(default, alias = "server")] #[partial_attr(serde(alias = "server"))] pub server_id: String, } impl ServerBuilderConfig { pub fn builder() -> ServerBuilderConfigBuilder { ServerBuilderConfigBuilder::default() } } #[typeshare(serialized_as = "Partial")] pub type _PartialAwsBuilderConfig = PartialAwsBuilderConfig; /// Configuration for an AWS builder. #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, Builder, Partial)] #[partial_derive(Serialize, Deserialize, Debug, Clone, Default)] #[partial(skip_serializing_none, from, diff)] pub struct AwsBuilderConfig { /// The AWS region to create the instance in #[serde(default = "aws_default_region")] #[builder(default = "aws_default_region()")] #[partial_default(aws_default_region())] pub region: String, /// The instance type to create for the build #[serde(default = "aws_default_instance_type")] #[builder(default = "aws_default_instance_type()")] #[partial_default(aws_default_instance_type())] pub instance_type: String, /// The size of the builder volume in gb #[serde(default = "aws_default_volume_gb")] #[builder(default = "aws_default_volume_gb()")] #[partial_default(aws_default_volume_gb())] pub volume_gb: i32, /// The port periphery will be running on. /// Default: `8120` #[serde(default = "default_port")] #[builder(default = "default_port()")] #[partial_default(default_port())] pub port: i32, #[serde(default = "default_use_https")] #[builder(default = "default_use_https()")] #[partial_default(default_use_https())] pub use_https: bool, /// The EC2 ami id to create. /// The ami should have the periphery client configured to start on startup, /// and should have the necessary github / dockerhub accounts configured. #[serde(default)] #[builder(default)] pub ami_id: String, /// The subnet id to create the instance in. #[serde(default)] #[builder(default)] pub subnet_id: String, /// The key pair name to attach to the instance #[serde(default)] #[builder(default)] pub key_pair_name: String, /// Whether to assign the instance a public IP address. /// Likely needed for the instance to be able to reach the open internet. #[serde(default)] #[builder(default)] pub assign_public_ip: bool, /// Whether core should use the public IP address to communicate with periphery on the builder. /// If false, core will communicate with the instance using the private IP. #[serde(default)] #[builder(default)] pub use_public_ip: bool, /// The security group ids to attach to the instance. /// This should include a security group to allow core inbound access to the periphery port. #[serde(default, deserialize_with = "string_list_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_string_list_deserializer" ))] #[builder(default)] pub security_group_ids: Vec, /// The user data to deploy the instance with. #[serde(default)] #[builder(default)] pub user_data: String, /// Which git providers are available on the AMI #[serde(default)] #[builder(default)] pub git_providers: Vec, /// Which docker registries are available on the AMI. #[serde(default)] #[builder(default)] pub docker_registries: Vec, /// Which secrets are available on the AMI. #[serde(default, deserialize_with = "string_list_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_string_list_deserializer" ))] #[builder(default)] pub secrets: Vec, } impl Default for AwsBuilderConfig { fn default() -> Self { Self { region: aws_default_region(), instance_type: aws_default_instance_type(), volume_gb: aws_default_volume_gb(), port: default_port(), use_https: default_use_https(), ami_id: Default::default(), subnet_id: Default::default(), security_group_ids: Default::default(), key_pair_name: Default::default(), assign_public_ip: Default::default(), use_public_ip: Default::default(), user_data: Default::default(), git_providers: Default::default(), docker_registries: Default::default(), secrets: Default::default(), } } } impl AwsBuilderConfig { pub fn builder() -> AwsBuilderConfigBuilder { AwsBuilderConfigBuilder::default() } } fn aws_default_region() -> String { String::from("us-east-1") } fn aws_default_instance_type() -> String { String::from("c5.2xlarge") } fn aws_default_volume_gb() -> i32 { 20 } fn default_port() -> i32 { 8120 } fn default_use_https() -> bool { true } #[typeshare] pub type BuilderQuery = ResourceQuery; #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct BuilderQuerySpecifics {} impl AddFilters for BuilderQuerySpecifics {} ================================================ FILE: client/core/rs/src/entities/config/cli/args/container.rs ================================================ #[derive(Debug, Clone, clap::Parser)] pub struct Container { /// Other container utilities #[command(subcommand)] pub command: Option, /// List all containers, including stopped ones. /// This overrides 'down'. #[arg(long, short = 'a', default_value_t = false)] pub all: bool, /// Reverse the ordering of results, /// so non-running containers are listed first if --all is passed. #[arg(long, short = 'r', default_value_t = false)] pub reverse: bool, /// List only non-running containers. #[arg(long, short = 'd', default_value_t = false)] pub down: bool, /// Include links. Makes the table very large. #[arg(long, short = 'l', default_value_t = false)] pub links: bool, /// Filter containers by a particular server. /// Supports wildcard syntax. /// Can be specified multiple times. (alias `s`) #[arg(name = "server", long, short = 's')] pub servers: Vec, /// Filter containers by a name. Supports wildcard syntax. /// Can be specified multiple times. (alias `c`) #[arg(name = "container", long, short = 'c')] pub containers: Vec, /// Filter containers by image. Supports wildcard syntax. /// Can be specified multiple times. (alias `i`) #[arg(name = "image", long, short = 'i')] pub images: Vec, /// Filter containers by image. Supports wildcard syntax. /// Can be specified multiple times. (alias `--net`, `n`) #[arg(name = "network", alias = "net", long, short = 'n')] pub networks: Vec, /// Specify the format of the output. #[arg(long, short = 'f', default_value_t = super::CliFormat::Table)] pub format: super::CliFormat, } #[derive(Debug, Clone, clap::Subcommand)] pub enum ContainerCommand { /// Inspect containers #[clap(alias = "i")] Inspect(InspectContainer), } #[derive(Debug, Clone, clap::Parser)] pub struct InspectContainer { /// The container name. If it matches multiple containers and no server is specified, /// each container's inspect info will be logged. pub container: String, /// Select the particular server that container is on. #[arg(name = "server", long, short = 's')] pub servers: Vec, /// Only show the .State part of the inspect response. #[arg(long, short = 'u')] pub state: bool, /// Only show the .Mounts part of the inspect response. #[arg(long, short = 'm')] pub mounts: bool, /// Only show the .HostConfig part of the inspect response. #[arg(long, short = 'f')] pub host_config: bool, /// Only show the .Config part of the inspect response. #[arg(long, short = 'c')] pub config: bool, /// Only show the .NetworkSettings part of the inspect response. #[arg(long, short = 'n')] pub network_settings: bool, } ================================================ FILE: client/core/rs/src/entities/config/cli/args/database.rs ================================================ use std::path::PathBuf; #[derive(Debug, Clone, clap::Subcommand)] pub enum DatabaseCommand { /// Triggers database backup to compressed files /// organized by time the backup was taken. (alias: `bkp`) #[clap(alias = "bkp")] Backup { /// Optionally provide a specific backups folder. /// Default: `/backups` #[arg(long, short = 'f')] backups_folder: Option, /// Always continue on user confirmation prompts. #[arg(long, short = 'y', default_value_t = false)] yes: bool, }, /// Restores the database from backup files. (alias: `rst`) #[clap(alias = "rst")] Restore { /// Optionally provide a specific backups folder. /// Default: `/backups` #[arg(long, short = 'f')] backups_folder: Option, /// Optionally provide a specific restore folder. /// If not provided, will use the most recent backup folder. /// /// Example: `2025-08-01_05-04-53` #[arg(long, short = 'r')] restore_folder: Option, /// Whether to index the target database. Default: true #[arg(long, short = 'i', default_value_t = true)] index: bool, /// Always continue on user confirmation prompts. #[arg(long, short = 'y', default_value_t = false)] yes: bool, }, /// Prunes database backups if there are greater than /// the configured `max_backups` (KOMODO_CLI_MAX_BACKUPS). Prune { /// Optionally provide a specific backups folder. /// Default: `/backups` #[arg(long, short = 'f')] backups_folder: Option, /// Always continue on user confirmation prompts. #[arg(long, short = 'y', default_value_t = false)] yes: bool, }, /// Copy the database to another running database. (alias: `cp`) #[clap(alias = "cp")] Copy { /// The target database uri to copy to. #[arg(long)] uri: Option, /// The target database address to copy to #[arg(long, short = 'a')] address: Option, /// The target database username #[arg(long, short = 'u')] username: Option, /// The target database password #[arg(long, short = 'p')] password: Option, /// The target db name to copy to. #[arg(long, short = 'd')] db_name: Option, /// Whether to index the target database. Default: true #[arg(long, short = 'i', default_value_t = true)] index: bool, /// Always continue on user confirmation prompts. #[arg(long, short = 'y', default_value_t = false)] yes: bool, }, } ================================================ FILE: client/core/rs/src/entities/config/cli/args/list.rs ================================================ use crate::entities::resource::TemplatesQueryBehavior; #[derive(Debug, Clone, clap::Parser)] pub struct List { /// List specific resources #[command(subcommand)] pub command: Option, /// List all resources, including down ones. #[arg(long, short = 'a', default_value_t = false)] pub all: bool, /// Reverse the ordering of results, /// so non-running containers are listed first if --all is passed. #[arg(long, short = 'r', default_value_t = false)] pub reverse: bool, /// List only non-running / non-ok resources. #[arg(long, short = 'd', default_value_t = false)] pub down: bool, /// List only "in progress" / "pending" resources, like Actions / Procedures that are running (alias: `pending`) #[arg( long, short = 'p', alias = "pending", default_value_t = false )] pub in_progress: bool, /// Include links. Makes the table very large. #[arg(long, short = 'l', default_value_t = false)] pub links: bool, /// Whether to include resources marked as templates in results. Default: 'exclude'. #[arg( long, short = 'm', default_value_t = TemplatesQueryBehavior::Exclude, )] pub templates: TemplatesQueryBehavior, /// Filter by a particular name. Supports wildcard. /// Can be specified multiple times. (alias `n`) #[arg(name = "name", long, short = 'n')] pub names: Vec, /// Filter by a particular tag. /// Can be specified multiple times. (alias `t`) #[arg(name = "tag", long, short = 't')] pub tags: Vec, /// Filter by a particular server. Supports wildcard. /// Can be specified multiple times. (alias `s`) #[arg(name = "server", long, short = 's')] pub servers: Vec, /// Filter by a particular builder. Supports wildcard. /// Can be specified multiple times. (alias `b`) #[arg(name = "builder", long, short = 'b')] pub builders: Vec, /// Specify the format of the output. #[arg(long, short = 'f', default_value_t = super::CliFormat::Table)] pub format: super::CliFormat, } impl From for ResourceFilters { fn from(value: List) -> Self { Self { all: value.all, reverse: value.reverse, down: value.down, in_progress: value.in_progress, links: value.links, templates: value.templates, names: value.names, tags: value.tags, servers: value.servers, builders: value.builders, format: value.format, } } } #[derive(Debug, Clone, clap::Subcommand)] pub enum ListCommand { /// List Servers (aliases: `server`, `sv`) #[clap(alias = "server", alias = "sv")] Servers(ResourceFilters), /// List Stacks (aliases: `stack`, `st`) #[clap(alias = "stack", alias = "st")] Stacks(ResourceFilters), /// List Deployments (aliases: `deployment`, `dp`) #[clap(alias = "deployment", alias = "dp")] Deployments(ResourceFilters), /// List Builds (aliases: `build`, `bd`) #[clap(alias = "build", alias = "bd")] Builds(ResourceFilters), /// List Repos (aliases: `repo`, `rp`) #[clap(alias = "repo", alias = "rp")] Repos(ResourceFilters), /// List Procedures (aliases: `procedure`, `pr`) #[clap(alias = "procedure", alias = "pr")] Procedures(ResourceFilters), /// List Actions (aliases: `action`, `ac`) #[clap(alias = "action", alias = "ac")] Actions(ResourceFilters), /// List Syncs (aliases: `sync`, `sn`) #[clap(alias = "sync", alias = "sn")] Syncs(ResourceFilters), /// List scheduled Procedures / Actions (aliases: `sched`, `sc`) #[clap(alias = "sched", alias = "sc")] Schedules(ResourceFilters), /// List Builders (aliases: `builder`, `bldr`) #[clap(alias = "builder", alias = "bldr")] Builders(ResourceFilters), /// List Alerters (aliases: `alerter`, `alrt`) #[clap(alias = "alerter", alias = "alrt")] Alerters(ResourceFilters), } #[derive(Debug, Clone, clap::Parser)] pub struct ResourceFilters { /// List all resources, including down ones. #[arg(long, short = 'a', default_value_t = false)] pub all: bool, /// Reverse the ordering of results, /// so non-running containers are listed first if --all is passed. #[arg(long, short = 'r', default_value_t = false)] pub reverse: bool, /// List only non-running / non-ok resources. #[arg(long, short = 'd', default_value_t = false)] pub down: bool, /// List only "in progress" / "pending" resources, like Actions / Procedures that are running (alias: `pending`) #[arg( long, short = 'p', alias = "pending", default_value_t = false )] pub in_progress: bool, /// Include links. Makes the table very large. #[arg(long, short = 'l', default_value_t = false)] pub links: bool, /// Whether to include resources marked as templates in results. Default: 'exclude'. #[arg( long, short = 'm', default_value_t = TemplatesQueryBehavior::Exclude, )] pub templates: TemplatesQueryBehavior, /// Filter by a particular name. Supports wildcard. /// Can be specified multiple times. (alias `n`) #[arg(name = "name", long, short = 'n')] pub names: Vec, /// Filter by a particular tag. /// Can be specified multiple times. (alias `t`) #[arg(name = "tag", long, short = 't')] pub tags: Vec, /// Filter by a particular server. Supports wildcard. /// Can be specified multiple times. (alias `s`) #[arg(name = "server", long, short = 's')] pub servers: Vec, /// Filter by a particular builder. Supports wildcard. /// Can be specified multiple times. (alias `b`) #[arg(name = "builder", long, short = 'b')] pub builders: Vec, /// Specify the format of the output. #[arg(long, short = 'f', default_value_t = super::CliFormat::Table)] pub format: super::CliFormat, } ================================================ FILE: client/core/rs/src/entities/config/cli/args/mod.rs ================================================ //! Module for parsing the Komodo CLI arguments use std::path::PathBuf; use crate::api::execute::Execution; pub mod container; pub mod database; pub mod list; pub mod update; #[derive(Debug, clap::Parser)] #[command(name = "komodo-cli", version, about = "", author)] pub struct CliArgs { /// The command to run #[command(subcommand)] pub command: Command, /// Choose a custom [[profile]] name / alias set in a `komodo.cli.toml` file. #[arg(long, short = 'p')] pub profile: Option, /// Sets the path of a config file or directory to use. /// Can use multiple times #[arg(long, short = 'c')] pub config_path: Option>, /// Sets the keywords to match directory cli config file names on. /// Supports wildcard syntax. /// Can use multiple times to match multiple patterns independently. #[arg(long, short = 'm')] pub config_keyword: Option>, /// Whether to debug print on configuration load (on startup) #[arg(alias = "debug", long, short = 'd')] pub debug_startup: Option, } #[derive(Debug, Clone, clap::Subcommand)] pub enum Command { /// Print the CLI config being used. (aliases: `cfg`, `cf`) #[clap(alias = "cfg", alias = "cf")] Config { /// Whether to print the additional profiles picked up #[arg(long, short = 'a', default_value_t = false)] all_profiles: bool, /// Whether to print unsanitized config, /// including sensitive credentials. #[arg(long, action)] unsanitized: bool, }, /// Container info (aliases: `ps`, `cn`, `containers`) #[clap(alias = "ps", alias = "cn", alias = "containers")] Container(container::Container), /// Inspect containers (alias: `i`) #[clap(alias = "i")] Inspect(container::InspectContainer), /// List Komodo resources (aliases: `ls`, `resources`) #[clap(alias = "ls", alias = "resources")] List(list::List), /// Run Komodo executions. (aliases: `x`, `run`, `deploy`, `dep`, `send`) #[clap( alias = "x", alias = "run", alias = "deploy", alias = "dep", alias = "send" )] Execute(Execute), /// Update resource configuration. (alias: `set`) #[clap(alias = "set")] Update { #[command(subcommand)] command: update::UpdateCommand, }, /// Database utilities. (alias: `db`) #[clap(alias = "db")] Database { #[command(subcommand)] command: database::DatabaseCommand, }, } #[derive(Debug, Clone, clap::Parser)] pub struct Execute { /// The execution to run. #[command(subcommand)] pub execution: Execution, /// Top priority Komodo host. /// Eg. "https://demo.komo.do" #[arg(long, short = 'a')] pub host: Option, /// Top priority api key. #[arg(long, short = 'k')] pub key: Option, /// Top priority api secret. #[arg(long, short = 's')] pub secret: Option, /// Always continue on user confirmation prompts. #[arg(long, short = 'y', default_value_t = false)] pub yes: bool, } #[derive( Debug, Clone, Copy, Default, strum::Display, clap::ValueEnum, )] #[strum(serialize_all = "lowercase")] pub enum CliFormat { /// Table output format. Default. (alias: `t`) #[default] #[clap(alias = "t")] Table, /// Json output format. (alias: `j`) #[clap(alias = "j")] Json, } #[derive( Debug, Clone, Copy, Default, clap::ValueEnum, strum::Display, )] #[strum(serialize_all = "lowercase")] pub enum CliEnabled { #[default] #[clap(alias = "y", alias = "true", alias = "t")] Yes, #[clap(alias = "n", alias = "false", alias = "f")] No, } impl From for bool { fn from(value: CliEnabled) -> Self { match value { CliEnabled::Yes => true, CliEnabled::No => false, } } } ================================================ FILE: client/core/rs/src/entities/config/cli/args/update.rs ================================================ #[derive(Debug, Clone, clap::Subcommand)] pub enum UpdateCommand { /// Update a Build's configuration. (alias: `bld`) #[clap(alias = "bld")] Build(UpdateResource), /// Update a Deployments's configuration. (alias: `dep`) #[clap(alias = "dep")] Deployment(UpdateResource), /// Update a Repos's configuration. Repo(UpdateResource), /// Update a Servers's configuration. (alias: `srv`) #[clap(alias = "srv")] Server(UpdateResource), /// Update a Stacks's configuration. (alias: `stk`) #[clap(alias = "stk")] Stack(UpdateResource), /// Update a Syncs's configuration. Sync(UpdateResource), /// Update a Variable's value. (alias: `var`) #[clap(alias = "var")] Variable { /// The name of the variable. name: String, /// The value to set variable to. value: String, /// Whether the value should be set to secret. /// If unset, will leave the variable secret setting as-is. #[arg(long, short = 's')] secret: Option, /// Always continue on user confirmation prompts. #[arg(long, short = 'y', default_value_t = false)] yes: bool, }, /// Update a user's configuration, including assigning resetting password and assigning Super Admin User { /// The user to update username: String, #[command(subcommand)] command: UpdateUserCommand, }, } #[derive(Debug, Clone, clap::Parser)] pub struct UpdateResource { /// The name / id of the Resource. pub resource: String, /// The update string, parsed using 'https://docs.rs/serde_qs/latest/serde_qs'. /// /// The fields can be found here: 'https://docs.rs/komodo_client/latest/komodo_client/entities/sync/struct.ResourceSyncConfig.html' /// /// Example: `km update build example-build "branch=testing"` /// /// Note. Should be enclosed in single or double quotes. /// Values containing complex characters (like URLs) /// will need to be url-encoded in order to be parsed correctly. pub update: String, /// Always continue on user confirmation prompts. #[arg(long, short = 'y', default_value_t = false)] pub yes: bool, } #[derive(Debug, Clone, clap::Subcommand)] pub enum UpdateUserCommand { /// Update the users password. Fails if user is not "Local" user (ie OIDC). (alias: `pw`) #[clap(alias = "pw")] Password { /// The new password to use. password: String, /// Whether to print unsanitized config, /// including sensitive credentials. #[arg(long, action)] unsanitized: bool, /// Always continue on user confirmation prompts. #[arg(long, short = 'y', default_value_t = false)] yes: bool, }, /// Un/assign super admin to user. (aliases: `supa`, `sa`) #[clap(alias = "supa", alias = "sa")] SuperAdmin { #[clap(default_value_t = super::CliEnabled::Yes)] enabled: super::CliEnabled, /// Always continue on user confirmation prompts. #[arg(long, short = 'y', default_value_t = false)] yes: bool, }, } ================================================ FILE: client/core/rs/src/entities/config/cli/mod.rs ================================================ use std::{path::PathBuf, str::FromStr}; use serde::{Deserialize, Serialize}; use crate::{ deserializers::string_list_deserializer, entities::{ config::{DatabaseConfig, empty_or_redacted}, logger::{LogConfig, LogLevel, StdioLogMode}, }, }; pub mod args; /// # Komodo CLI Environment Variables /// /// #[derive(Debug, Clone, Deserialize)] pub struct Env { // ============ // Cli specific // ============ /// Specify the config paths (files or folders) used to build up the /// final [CliConfig]. /// If not provided, will use "." (the current working directory). /// /// Note. This is overridden if the equivalent arg is passed in [CliArgs]. #[serde( default = "default_config_paths", alias = "komodo_cli_config_path" )] pub komodo_cli_config_paths: Vec, /// If specifying folders, use this to narrow down which /// files will be matched to parse into the final [CliConfig]. /// Only files inside the folders which have names containing all keywords /// provided to `config_keywords` will be included. /// /// Note. This is overridden if the equivalent arg is passed in [CliArgs]. #[serde( default = "default_config_keywords", alias = "komodo_cli_config_keyword" )] pub komodo_cli_config_keywords: Vec, /// Will merge nested config object (eg. database) across multiple /// config files. Default: `true` /// /// Note. This is overridden if the equivalent arg is passed in [CliArgs]. #[serde(default = "super::default_merge_nested_config")] pub komodo_cli_merge_nested_config: bool, /// Will extend config arrays (eg profiles) across multiple config files. /// Default: `true` /// /// Note. This is overridden if the equivalent arg is passed in [CliArgs]. #[serde(default = "super::default_extend_config_arrays")] pub komodo_cli_extend_config_arrays: bool, /// Extra logs during cli config load. #[serde(default)] pub komodo_cli_debug_startup: bool, // Override `default_profile`. pub komodo_cli_default_profile: Option, /// Override `host` and `KOMODO_HOST`. pub komodo_cli_host: Option, /// Override `cli_key` pub komodo_cli_key: Option, /// Override `cli_secret` pub komodo_cli_secret: Option, /// Override `table_borders` pub komodo_cli_table_borders: Option, /// Override `backups_folder` pub komodo_cli_backups_folder: Option, /// Override `max_backups` pub komodo_cli_max_backups: Option, /// Override `database_target_uri` #[serde(alias = "komodo_cli_database_copy_uri")] pub komodo_cli_database_target_uri: Option, /// Override `database_target_address` #[serde(alias = "komodo_cli_database_copy_address")] pub komodo_cli_database_target_address: Option, /// Override `database_target_username` #[serde(alias = "komodo_cli_database_copy_username")] pub komodo_cli_database_target_username: Option, /// Override `database_target_password` #[serde(alias = "komodo_cli_database_copy_password")] pub komodo_cli_database_target_password: Option, /// Override `database_target_db_name` #[serde(alias = "komodo_cli_database_copy_db_name")] pub komodo_cli_database_target_db_name: Option, // LOGGING /// Override `logging.level` pub komodo_cli_logging_level: Option, /// Override `logging.stdio` pub komodo_cli_logging_stdio: Option, /// Override `logging.pretty` pub komodo_cli_logging_pretty: Option, /// Override `logging.otlp_endpoint` pub komodo_cli_logging_otlp_endpoint: Option, /// Override `logging.opentelemetry_service_name` pub komodo_cli_logging_opentelemetry_service_name: Option, /// Override `pretty_startup_config` pub komodo_cli_pretty_startup_config: Option, // ================ // Same as Core env // ================ /// Override `host` pub komodo_host: Option, // DATABASE /// Override `database.uri` #[serde(alias = "komodo_mongo_uri")] pub komodo_database_uri: Option, /// Override `database.uri` from file #[serde(alias = "komodo_mongo_uri_file")] pub komodo_database_uri_file: Option, /// Override `database.address` #[serde(alias = "komodo_mongo_address")] pub komodo_database_address: Option, /// Override `database.username` #[serde(alias = "komodo_mongo_username")] pub komodo_database_username: Option, /// Override `database.username` with file #[serde(alias = "komodo_mongo_username_file")] pub komodo_database_username_file: Option, /// Override `database.password` #[serde(alias = "komodo_mongo_password")] pub komodo_database_password: Option, /// Override `database.password` with file #[serde(alias = "komodo_mongo_password_file")] pub komodo_database_password_file: Option, /// Override `database.db_name` #[serde(alias = "komodo_mongo_db_name")] pub komodo_database_db_name: Option, } fn default_config_paths() -> Vec { if let Ok(home) = std::env::var("HOME") { vec![ PathBuf::from_str(&home).unwrap().join(".config/komodo"), PathBuf::from_str(".").unwrap(), ] } else { vec![PathBuf::from_str(".").unwrap()] } } fn default_config_keywords() -> Vec { vec![String::from("*komodo.cli*.*")] } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CliConfig { /// Optional. Only relevant for top level CLI config. /// Set a default profile to be used when none is provided. /// This allows for quick switching between profiles while /// not having to explicitly pass `-p profile`. #[serde( alias = "default", skip_serializing_if = "Option::is_none" )] pub default_profile: Option, /// Optional. The profile name. (alias: `name`) /// Configure profiles with name in the komodo.cli.toml, /// and select them using `km -p profile-name ...`. #[serde( default, alias = "name", skip_serializing_if = "String::is_empty" )] pub config_profile: String, /// Optional. The profile aliases. (aliases: `aliases`, `alias`) /// Configure profiles with alias in the komodo.cli.toml, /// and select them using `km -p alias ...`. #[serde( default, alias = "aliases", alias = "alias", deserialize_with = "string_list_deserializer", skip_serializing_if = "Vec::is_empty" )] pub config_aliases: Vec, // Same as Core /// The host Komodo url. /// Eg. "https://demo.komo.do" #[serde(default, skip_serializing_if = "String::is_empty")] pub host: String, /// The api key for the CLI to use #[serde(alias = "key", skip_serializing_if = "Option::is_none")] pub cli_key: Option, /// The api secret for the CLI to use #[serde(alias = "secret", skip_serializing_if = "Option::is_none")] pub cli_secret: Option, /// The format for the tables. #[serde(skip_serializing_if = "Option::is_none")] pub table_borders: Option, /// The root backups folder. /// /// Default: `/backups`. /// /// Backups will be created in timestamped folders eg /// `/backups/2025-08-04_05_05_53` #[serde(default = "default_backups_folder")] pub backups_folder: PathBuf, /// Specify the maximum number of backups to keep, /// or 0 to disable backup pruning. /// Default: `14` /// /// After every backup, the CLI will prune the oldest backups /// if there are more backups than `max_backups` #[serde(default = "default_max_backups")] pub max_backups: u16, // Same as Core /// Configure database connection #[serde( default = "default_database_config", alias = "mongo", skip_serializing_if = "database_config_is_default" )] pub database: DatabaseConfig, /// Configure restore / copy database connection #[serde( default = "default_database_config", alias = "database_copy", skip_serializing_if = "database_config_is_default" )] pub database_target: DatabaseConfig, /// Logging configuration #[serde( default = "default_log_config", skip_serializing_if = "log_config_is_default" )] pub cli_logging: LogConfig, /// Configure additional profiles. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub profile: Vec, } fn default_backups_folder() -> PathBuf { // SAFE: /backups is a valid path. PathBuf::from_str("/backups").unwrap() } fn default_max_backups() -> u16 { 14 } fn default_database_config() -> DatabaseConfig { DatabaseConfig { app_name: String::from("komodo_cli"), ..Default::default() } } fn database_config_is_default(db_config: &DatabaseConfig) -> bool { db_config == &default_database_config() } fn default_log_config() -> LogConfig { LogConfig { location: false, ..Default::default() } } fn log_config_is_default(log_config: &LogConfig) -> bool { log_config == &default_log_config() } impl Default for CliConfig { fn default() -> Self { Self { default_profile: Default::default(), config_profile: Default::default(), config_aliases: Default::default(), cli_key: Default::default(), cli_secret: Default::default(), cli_logging: default_log_config(), table_borders: Default::default(), backups_folder: default_backups_folder(), max_backups: default_max_backups(), database: default_database_config(), database_target: default_database_config(), host: Default::default(), profile: Default::default(), } } } impl CliConfig { pub fn sanitized(&self) -> CliConfig { CliConfig { default_profile: self.default_profile.clone(), config_profile: self.config_profile.clone(), config_aliases: self.config_aliases.clone(), cli_key: self .cli_key .as_ref() .map(|cli_key| empty_or_redacted(cli_key)), cli_secret: self .cli_secret .as_ref() .map(|cli_secret| empty_or_redacted(cli_secret)), cli_logging: self.cli_logging.clone(), table_borders: self.table_borders, backups_folder: self.backups_folder.clone(), max_backups: self.max_backups, database_target: self.database_target.sanitized(), host: self.host.clone(), database: self.database.sanitized(), profile: self .profile .iter() .map(CliConfig::sanitized) .collect(), } } } #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] pub enum CliTableBorders { /// Only horizontal borders. Default. #[default] Horizontal, /// Only vertical borders. Vertical, /// Only borders around the outside of the table. Outside, /// Only borders horizontally / vertically between the rows / columns. Inside, /// All borders All, } ================================================ FILE: client/core/rs/src/entities/config/core.rs ================================================ //! # Configuring the Komodo Core API //! //! Komodo Core is configured by parsing base configuration file ([CoreConfig]), and overriding //! any fields given in the file with ones provided on the environment ([Env]). //! //! The recommended method for running Komodo Core is via the docker image. This image has a default //! configuration file provided in the image, meaning any custom configuration can be provided //! on the environment alone. However, if a custom configuration file is prefered, it can be mounted //! into the image at `/config/config.toml`. //! use std::{collections::HashMap, path::PathBuf, str::FromStr}; use serde::Deserialize; use crate::entities::{ Timelength, config::DatabaseConfig, logger::{LogConfig, LogLevel, StdioLogMode}, }; use super::{DockerRegistry, GitProvider, empty_or_redacted}; /// # Komodo Core Environment Variables /// /// You can override any fields of the [CoreConfig] by passing the associated /// environment variable. The variables should be passed in the traditional `UPPER_SNAKE_CASE` format, /// although the lower case format can still be parsed. /// /// *Note.* The Komodo Core docker image includes the default core configuration found at /// [https://github.com/moghtech/komodo/blob/main/config/core.config.toml](https://github.com/moghtech/komodo/blob/main/config/core.config.toml). /// To configure the core api, you can either mount your own custom configuration file to /// `/config/config.toml` inside the container, /// or simply override whichever fields you need using the environment. #[derive(Debug, Clone, Deserialize)] pub struct Env { /// Specify a custom config path for the core config toml. /// Default: `/config/config.toml` #[serde( default = "default_core_config_paths", alias = "komodo_config_path" )] pub komodo_config_paths: Vec, /// If specifying folders, use this to narrow down which /// files will be matched to parse into the final [PeripheryConfig]. /// Only files inside the folders which have names containing a keywords /// provided to `config_keywords` will be included. /// Keywords support wildcard matching syntax. #[serde( default = "super::default_config_keywords", alias = "komodo_config_keyword" )] pub komodo_config_keywords: Vec, /// Will merge nested config object (eg. secrets, providers) across multiple /// config files. Default: `true` #[serde(default = "super::default_merge_nested_config")] pub komodo_merge_nested_config: bool, /// Will extend config arrays across multiple config files. /// Default: `true` #[serde(default = "super::default_extend_config_arrays")] pub komodo_extend_config_arrays: bool, /// Print some extra logs on startup to debug config loading issues. #[serde(default)] pub komodo_config_debug: bool, /// Override `title` pub komodo_title: Option, /// Override `host` pub komodo_host: Option, /// Override `port` pub komodo_port: Option, /// Override `bind_ip` pub komodo_bind_ip: Option, /// Override `passkey` pub komodo_passkey: Option, /// Override `passkey` with file pub komodo_passkey_file: Option, /// Override `timezone` #[serde(alias = "tz", alias = "TZ")] pub komodo_timezone: Option, /// Override `first_server` pub komodo_first_server: Option, /// Override `first_server_name` pub komodo_first_server_name: Option, /// Override `frontend_path` pub komodo_frontend_path: Option, /// Override `jwt_secret` pub komodo_jwt_secret: Option, /// Override `jwt_secret` from file pub komodo_jwt_secret_file: Option, /// Override `jwt_ttl` pub komodo_jwt_ttl: Option, /// Override `sync_directory` pub komodo_sync_directory: Option, /// Override `repo_directory` pub komodo_repo_directory: Option, /// Override `action_directory` pub komodo_action_directory: Option, /// Override `resource_poll_interval` pub komodo_resource_poll_interval: Option, /// Override `monitoring_interval` pub komodo_monitoring_interval: Option, /// Override `keep_stats_for_days` pub komodo_keep_stats_for_days: Option, /// Override `keep_alerts_for_days` pub komodo_keep_alerts_for_days: Option, /// Override `webhook_secret` pub komodo_webhook_secret: Option, /// Override `webhook_secret` with file pub komodo_webhook_secret_file: Option, /// Override `webhook_base_url` pub komodo_webhook_base_url: Option, /// Override `logging.level` pub komodo_logging_level: Option, /// Override `logging.stdio` pub komodo_logging_stdio: Option, /// Override `logging.pretty` pub komodo_logging_pretty: Option, /// Override `logging.location` pub komodo_logging_location: Option, /// Override `logging.otlp_endpoint` pub komodo_logging_otlp_endpoint: Option, /// Override `logging.opentelemetry_service_name` pub komodo_logging_opentelemetry_service_name: Option, /// Override `pretty_startup_config` pub komodo_pretty_startup_config: Option, /// Override `unsafe_unsanitized_startup_config` pub komodo_unsafe_unsanitized_startup_config: Option, /// Override `transparent_mode` pub komodo_transparent_mode: Option, /// Override `ui_write_disabled` pub komodo_ui_write_disabled: Option, /// Override `enable_new_users` pub komodo_enable_new_users: Option, /// Override `disable_user_registration` pub komodo_disable_user_registration: Option, /// Override `lock_login_credentials_for` pub komodo_lock_login_credentials_for: Option>, /// Override `disable_confirm_dialog` pub komodo_disable_confirm_dialog: Option, /// Override `disable_non_admin_create` pub komodo_disable_non_admin_create: Option, /// Override `disable_websocket_reconnect` pub komodo_disable_websocket_reconnect: Option, /// Override `disable_init_resources` pub komodo_disable_init_resources: Option, /// Override `enable_fancy_toml` pub komodo_enable_fancy_toml: Option, /// Override `local_auth` pub komodo_local_auth: Option, /// Override `init_admin_username` pub komodo_init_admin_username: Option, /// Override `init_admin_username` from file pub komodo_init_admin_username_file: Option, /// Override `init_admin_password` pub komodo_init_admin_password: Option, /// Override `init_admin_password` from file pub komodo_init_admin_password_file: Option, /// Override `oidc_enabled` pub komodo_oidc_enabled: Option, /// Override `oidc_provider` pub komodo_oidc_provider: Option, /// Override `oidc_redirect_host` pub komodo_oidc_redirect_host: Option, /// Override `oidc_client_id` pub komodo_oidc_client_id: Option, /// Override `oidc_client_id` from file pub komodo_oidc_client_id_file: Option, /// Override `oidc_client_secret` pub komodo_oidc_client_secret: Option, /// Override `oidc_client_secret` from file pub komodo_oidc_client_secret_file: Option, /// Override `oidc_use_full_email` pub komodo_oidc_use_full_email: Option, /// Override `oidc_additional_audiences` pub komodo_oidc_additional_audiences: Option>, /// Override `oidc_additional_audiences` from file pub komodo_oidc_additional_audiences_file: Option, /// Override `google_oauth.enabled` pub komodo_google_oauth_enabled: Option, /// Override `google_oauth.id` pub komodo_google_oauth_id: Option, /// Override `google_oauth.id` from file pub komodo_google_oauth_id_file: Option, /// Override `google_oauth.secret` pub komodo_google_oauth_secret: Option, /// Override `google_oauth.secret` from file pub komodo_google_oauth_secret_file: Option, /// Override `github_oauth.enabled` pub komodo_github_oauth_enabled: Option, /// Override `github_oauth.id` pub komodo_github_oauth_id: Option, /// Override `github_oauth.id` from file pub komodo_github_oauth_id_file: Option, /// Override `github_oauth.secret` pub komodo_github_oauth_secret: Option, /// Override `github_oauth.secret` from file pub komodo_github_oauth_secret_file: Option, /// Override `github_webhook_app.app_id` pub komodo_github_webhook_app_app_id: Option, /// Override `github_webhook_app.app_id` from file pub komodo_github_webhook_app_app_id_file: Option, /// Override `github_webhook_app.installations[i].id`. Accepts comma seperated list. /// /// Note. Paired by index with values in `komodo_github_webhook_app_installations_namespaces` pub komodo_github_webhook_app_installations_ids: Option>, /// Override `github_webhook_app.installations[i].id` from file pub komodo_github_webhook_app_installations_ids_file: Option, /// Override `github_webhook_app.installations[i].namespace`. Accepts comma seperated list. /// /// Note. Paired by index with values in `komodo_github_webhook_app_installations_ids` pub komodo_github_webhook_app_installations_namespaces: Option>, /// Override `github_webhook_app.pk_path` pub komodo_github_webhook_app_pk_path: Option, /// Override `database.uri` #[serde(alias = "komodo_mongo_uri")] pub komodo_database_uri: Option, /// Override `database.uri` from file #[serde(alias = "komodo_mongo_uri_file")] pub komodo_database_uri_file: Option, /// Override `database.address` #[serde(alias = "komodo_mongo_address")] pub komodo_database_address: Option, /// Override `database.username` #[serde(alias = "komodo_mongo_username")] pub komodo_database_username: Option, /// Override `database.username` with file #[serde(alias = "komodo_mongo_username_file")] pub komodo_database_username_file: Option, /// Override `database.password` #[serde(alias = "komodo_mongo_password")] pub komodo_database_password: Option, /// Override `database.password` with file #[serde(alias = "komodo_mongo_password_file")] pub komodo_database_password_file: Option, /// Override `database.app_name` #[serde(alias = "komodo_mongo_app_name")] pub komodo_database_app_name: Option, /// Override `database.db_name` #[serde(alias = "komodo_mongo_db_name")] pub komodo_database_db_name: Option, /// Override `aws.access_key_id` pub komodo_aws_access_key_id: Option, /// Override `aws.access_key_id` with file pub komodo_aws_access_key_id_file: Option, /// Override `aws.secret_access_key` pub komodo_aws_secret_access_key: Option, /// Override `aws.secret_access_key` with file pub komodo_aws_secret_access_key_file: Option, /// Override `internet_interface` pub komodo_internet_interface: Option, /// Override `ssl_enabled`. pub komodo_ssl_enabled: Option, /// Override `ssl_key_file` pub komodo_ssl_key_file: Option, /// Override `ssl_cert_file` pub komodo_ssl_cert_file: Option, } fn default_core_config_paths() -> Vec { vec![PathBuf::from_str("/config").unwrap()] } /// # Core Configuration File /// /// The Core API initializes it's configuration by reading the environment, /// parsing the [CoreConfig] schema from the file path specified by `env.komodo_config_path`, /// and then applying any config field overrides specified in the environment. /// /// *Note.* The Komodo Core docker image includes the default core configuration found at /// [https://github.com/moghtech/komodo/blob/main/config/core.config.toml](https://github.com/moghtech/komodo/blob/main/config/core.config.toml). /// To configure the core api, you can either mount your own custom configuration file to /// `/config/config.toml` inside the container, /// or simply override whichever fields you need using the environment. /// /// Refer to the [example file](https://github.com/moghtech/komodo/blob/main/config/core.config.toml) for a full example. #[derive(Debug, Clone, Deserialize)] pub struct CoreConfig { // =========== // = General = // =========== /// The title of this Komodo Core deployment. Will be used in the browser page title. /// Default: 'Komodo' #[serde(default = "default_title")] pub title: String, /// The host to use with oauth redirect url, whatever host /// the user hits to access Komodo. eg `https://komodo.domain.com`. /// Only used if oauth used without user specifying redirect url themselves. #[serde(default = "default_host")] pub host: String, /// Port the core web server runs on. /// Default: 9120. #[serde(default = "default_core_port")] pub port: u16, /// IP address the core server binds to. /// Default: [::]. #[serde(default = "default_core_bind_ip")] pub bind_ip: String, /// Interface to use as default route in multi-NIC environments. #[serde(default)] pub internet_interface: String, /// Sent in auth header with req to periphery. /// Should be some secure hash, maybe 20-40 chars. #[serde(default = "default_passkey")] pub passkey: String, /// A TZ Identifier. If not provided, will use Core local timezone. /// https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. /// This will be populated by TZ env variable in addition to KOMODO_TIMEZONE. #[serde(default)] pub timezone: String, /// Disable user ability to use the UI to update resource configuration. #[serde(default)] pub ui_write_disabled: bool, /// Disable the popup confirm dialogs. All buttons will just be double click. #[serde(default)] pub disable_confirm_dialog: bool, /// Disable the UI websocket from automatically reconnecting. #[serde(default)] pub disable_websocket_reconnect: bool, /// Disable init system resource creation on fresh Komodo launch. /// These include the Backup Core Database and Global Auto Update procedures. #[serde(default)] pub disable_init_resources: bool, /// Enable the fancy TOML syntax highlighting #[serde(default)] pub enable_fancy_toml: bool, /// If defined, ensure an enabled first server exists at this address. /// Example: `http://periphery:8120` #[serde(skip_serializing_if = "Option::is_none")] pub first_server: Option, /// Give the first server this name. /// Default: `Local` #[serde(default = "default_first_server_name")] pub first_server_name: String, /// The path to the built frontend folder. #[serde(default = "default_frontend_path")] pub frontend_path: String, /// Configure database connection #[serde(default, alias = "mongo")] pub database: DatabaseConfig, // ================ // = Auth / Login = // ================ /// enable login with local auth #[serde(default)] pub local_auth: bool, /// Upon fresh launch, initalize an Admin user with this username. /// If this is not provided, no initial user will be created. #[serde(skip_serializing_if = "Option::is_none")] pub init_admin_username: Option, /// Upon fresh launch, initalize an Admin user with this password. /// Default: `changeme` #[serde(default = "default_init_admin_password")] pub init_admin_password: String, /// Enable transparent mode, which gives all (enabled) users read access to all resources. #[serde(default)] pub transparent_mode: bool, /// New users will be automatically enabled. /// Combined with transparent mode, this is suitable for a demo instance. #[serde(default)] pub enable_new_users: bool, /// Normally new users will be registered, but not enabled until an Admin enables them. /// With `disable_user_registration = true`, only the first user to log in will registered as a user. #[serde(default)] pub disable_user_registration: bool, /// List of usernames for which the update username / password /// APIs are disabled. Used by demo to lock the 'demo' : 'demo' login. /// /// To lock the api for all users, use `lock_login_credentials_for = ["__ALL__"]` #[serde(default, skip_serializing_if = "Vec::is_empty")] pub lock_login_credentials_for: Vec, /// Normally all users can create resources. /// If `disable_non_admin_create = true`, only admins will be able to create resources. #[serde(default)] pub disable_non_admin_create: bool, /// Optionally provide a specific jwt secret. /// Passing nothing or an empty string will cause one to be generated. /// Default: "" (empty string) #[serde(default)] pub jwt_secret: String, /// Control how long distributed JWT remain valid for. /// Default: `1-day`. #[serde(default = "default_jwt_ttl")] pub jwt_ttl: Timelength, // ======== // = OIDC = // ======== /// Enable login with configured OIDC provider. #[serde(default)] pub oidc_enabled: bool, /// Configure OIDC provider address for /// communcation directly with Komodo Core. /// /// Note. Needs to be reachable from Komodo Core. /// /// `https://accounts.example.internal/application/o/komodo` #[serde(default)] pub oidc_provider: String, /// Configure OIDC user redirect host. /// /// This is the host address users are redirected to in their browser, /// and may be different from `oidc_provider` host. /// DO NOT include the `path` part, this must be inferred. /// If not provided, the host will be the same as `oidc_provider`. /// Eg. `https://accounts.example.external` #[serde(default)] pub oidc_redirect_host: String, /// Set OIDC client id #[serde(default)] pub oidc_client_id: String, /// Set OIDC client secret #[serde(default)] pub oidc_client_secret: String, /// Use the full email for usernames. /// Otherwise, the @address will be stripped, /// making usernames more concise. #[serde(default)] pub oidc_use_full_email: bool, /// Your OIDC provider may set additional audiences other than `client_id`, /// they must be added here to make claims verification work. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub oidc_additional_audiences: Vec, // ========= // = Oauth = // ========= /// Configure google oauth #[serde(default)] pub google_oauth: OauthCredentials, /// Configure github oauth #[serde(default)] pub github_oauth: OauthCredentials, // ============ // = Webhooks = // ============ /// Used to verify validity from webhooks. /// Should be some secure hash maybe 20-40 chars. /// It is given to git provider when configuring the webhook. #[serde(default)] pub webhook_secret: String, /// Override the webhook listener base url, if None will use the address defined as 'host'. /// Example: `https://webhooks.komo.do` /// /// This can be used if Komodo Core sits on an internal network which is /// unreachable directly from the open internet. /// A reverse proxy in a public network can forward webhooks to Komodo. #[serde(default)] pub webhook_base_url: String, /// Configure a Github Webhook app. /// Allows users to manage repo webhooks from within the Komodo UI. #[serde(default)] pub github_webhook_app: GithubWebhookAppConfig, // =========== // = Logging = // =========== /// Configure logging #[serde(default)] pub logging: LogConfig, /// Pretty-log (multi-line) the startup config /// for easier human readability. #[serde(default)] pub pretty_startup_config: bool, /// Unsafe: logs unsanitized config on startup, /// in order to verify everything is being /// passed correctly. #[serde(default)] pub unsafe_unsanitized_startup_config: bool, // =========== // = Pruning = // =========== /// Number of days to keep stats, or 0 to disable pruning. /// Stats older than this number of days are deleted on a daily cycle /// Default: 14 #[serde(default = "default_prune_days")] pub keep_stats_for_days: u64, /// Number of days to keep alerts, or 0 to disable pruning. /// Alerts older than this number of days are deleted on a daily cycle /// Default: 14 #[serde(default = "default_prune_days")] pub keep_alerts_for_days: u64, // ================== // = Poll Intervals = // ================== /// Interval at which to poll resources for any updates / automated actions. /// Options: `15-sec`, `1-min`, `5-min`, `15-min`, `1-hr` /// Default: `5-min`. #[serde(default = "default_poll_interval")] pub resource_poll_interval: Timelength, /// Interval at which to collect server stats and send any alerts. /// Default: `15-sec` #[serde(default = "default_monitoring_interval")] pub monitoring_interval: Timelength, // =================== // = Cloud Providers = // =================== /// Configure AWS credentials to use with AWS builds / server launches. #[serde(default)] pub aws: AwsCredentials, // ================= // = Git Providers = // ================= /// Configure git credentials used to clone private repos. /// Supports any git provider. #[serde( default, alias = "git_provider", skip_serializing_if = "Vec::is_empty" )] pub git_providers: Vec, // ====================== // = Registry Providers = // ====================== /// Configure docker credentials used to push / pull images. /// Supports any docker image repository. #[serde( default, alias = "docker_registry", skip_serializing_if = "Vec::is_empty" )] pub docker_registries: Vec, // =========== // = Secrets = // =========== /// Configure core-based secrets. These will be preferentially interpolated into /// values if they contain a matching secret. Otherwise, the periphery will have to have the /// secret configured. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub secrets: HashMap, // ======= // = SSL = // ======= /// Whether to enable ssl. #[serde(default)] pub ssl_enabled: bool, /// Path to the ssl key. /// Default: `/config/ssl/key.pem`. #[serde(default = "default_ssl_key_file")] pub ssl_key_file: PathBuf, /// Path to the ssl cert. /// Default: `/config/ssl/cert.pem`. #[serde(default = "default_ssl_cert_file")] pub ssl_cert_file: PathBuf, // ========= // = Other = // ========= /// Configure directory to store sync files. /// Default: `/syncs` #[serde(default = "default_sync_directory")] pub sync_directory: PathBuf, /// Specify the directory used to clone stack / repo / build repos, for latest hash / contents. /// The default is fine when using a container. /// Default: `/repo-cache` #[serde(default = "default_repo_directory")] pub repo_directory: PathBuf, /// Specify the directory used to temporarily write typescript files used with actions. /// Default: `/action-cache` #[serde(default = "default_action_directory")] pub action_directory: PathBuf, } fn default_title() -> String { String::from("Komodo") } fn default_host() -> String { String::from("https://komodo.example.com") } fn default_core_port() -> u16 { 9120 } fn default_core_bind_ip() -> String { "[::]".to_string() } fn default_passkey() -> String { String::from("default-passkey-changeme") } fn default_frontend_path() -> String { "/app/frontend".to_string() } fn default_first_server_name() -> String { String::from("Local") } fn default_jwt_ttl() -> Timelength { Timelength::OneDay } fn default_init_admin_password() -> String { String::from("changeme") } fn default_sync_directory() -> PathBuf { // unwrap ok: `/syncs` will always be valid path PathBuf::from_str("/syncs").unwrap() } fn default_repo_directory() -> PathBuf { // unwrap ok: `/repo-cache` will always be valid path PathBuf::from_str("/repo-cache").unwrap() } fn default_action_directory() -> PathBuf { // unwrap ok: `/action-cache` will always be valid path PathBuf::from_str("/action-cache").unwrap() } fn default_prune_days() -> u64 { 14 } fn default_poll_interval() -> Timelength { Timelength::OneHour } fn default_monitoring_interval() -> Timelength { Timelength::FifteenSeconds } fn default_ssl_key_file() -> PathBuf { "/config/ssl/key.pem".parse().unwrap() } fn default_ssl_cert_file() -> PathBuf { "/config/ssl/cert.pem".parse().unwrap() } impl Default for CoreConfig { fn default() -> Self { Self { title: default_title(), host: default_host(), port: default_core_port(), bind_ip: default_core_bind_ip(), internet_interface: Default::default(), passkey: default_passkey(), timezone: Default::default(), ui_write_disabled: Default::default(), disable_confirm_dialog: Default::default(), disable_websocket_reconnect: Default::default(), disable_init_resources: Default::default(), enable_fancy_toml: Default::default(), first_server: Default::default(), first_server_name: default_first_server_name(), frontend_path: default_frontend_path(), database: Default::default(), local_auth: Default::default(), init_admin_username: Default::default(), init_admin_password: default_init_admin_password(), transparent_mode: Default::default(), enable_new_users: Default::default(), disable_user_registration: Default::default(), lock_login_credentials_for: Default::default(), disable_non_admin_create: Default::default(), jwt_secret: Default::default(), jwt_ttl: default_jwt_ttl(), oidc_enabled: Default::default(), oidc_provider: Default::default(), oidc_redirect_host: Default::default(), oidc_client_id: Default::default(), oidc_client_secret: Default::default(), oidc_use_full_email: Default::default(), oidc_additional_audiences: Default::default(), google_oauth: Default::default(), github_oauth: Default::default(), webhook_secret: Default::default(), webhook_base_url: Default::default(), github_webhook_app: Default::default(), logging: Default::default(), pretty_startup_config: Default::default(), unsafe_unsanitized_startup_config: Default::default(), keep_stats_for_days: default_prune_days(), keep_alerts_for_days: default_prune_days(), resource_poll_interval: default_poll_interval(), monitoring_interval: default_monitoring_interval(), aws: Default::default(), git_providers: Default::default(), docker_registries: Default::default(), secrets: Default::default(), ssl_enabled: Default::default(), ssl_key_file: default_ssl_key_file(), ssl_cert_file: default_ssl_cert_file(), sync_directory: default_sync_directory(), repo_directory: default_repo_directory(), action_directory: default_action_directory(), } } } impl CoreConfig { pub fn sanitized(&self) -> CoreConfig { let config = self.clone(); CoreConfig { title: config.title, host: config.host, port: config.port, bind_ip: config.bind_ip, passkey: empty_or_redacted(&config.passkey), timezone: config.timezone, first_server: config.first_server, first_server_name: config.first_server_name, frontend_path: config.frontend_path, jwt_secret: empty_or_redacted(&config.jwt_secret), jwt_ttl: config.jwt_ttl, repo_directory: config.repo_directory, action_directory: config.action_directory, sync_directory: config.sync_directory, internet_interface: config.internet_interface, resource_poll_interval: config.resource_poll_interval, monitoring_interval: config.monitoring_interval, keep_stats_for_days: config.keep_stats_for_days, keep_alerts_for_days: config.keep_alerts_for_days, logging: config.logging, pretty_startup_config: config.pretty_startup_config, unsafe_unsanitized_startup_config: config .unsafe_unsanitized_startup_config, transparent_mode: config.transparent_mode, ui_write_disabled: config.ui_write_disabled, disable_confirm_dialog: config.disable_confirm_dialog, disable_websocket_reconnect: config.disable_websocket_reconnect, disable_init_resources: config.disable_init_resources, enable_fancy_toml: config.enable_fancy_toml, enable_new_users: config.enable_new_users, disable_user_registration: config.disable_user_registration, disable_non_admin_create: config.disable_non_admin_create, lock_login_credentials_for: config.lock_login_credentials_for, local_auth: config.local_auth, init_admin_username: config .init_admin_username .map(|u| empty_or_redacted(&u)), init_admin_password: empty_or_redacted( &config.init_admin_password, ), oidc_enabled: config.oidc_enabled, oidc_provider: config.oidc_provider, oidc_redirect_host: config.oidc_redirect_host, oidc_client_id: empty_or_redacted(&config.oidc_client_id), oidc_client_secret: empty_or_redacted( &config.oidc_client_secret, ), oidc_use_full_email: config.oidc_use_full_email, oidc_additional_audiences: config .oidc_additional_audiences .iter() .map(|aud| empty_or_redacted(aud)) .collect(), google_oauth: OauthCredentials { enabled: config.google_oauth.enabled, id: empty_or_redacted(&config.google_oauth.id), secret: empty_or_redacted(&config.google_oauth.id), }, github_oauth: OauthCredentials { enabled: config.github_oauth.enabled, id: empty_or_redacted(&config.github_oauth.id), secret: empty_or_redacted(&config.github_oauth.id), }, webhook_secret: empty_or_redacted(&config.webhook_secret), webhook_base_url: config.webhook_base_url, github_webhook_app: config.github_webhook_app, database: config.database.sanitized(), aws: AwsCredentials { access_key_id: empty_or_redacted(&config.aws.access_key_id), secret_access_key: empty_or_redacted( &config.aws.secret_access_key, ), }, secrets: config .secrets .into_iter() .map(|(id, secret)| (id, empty_or_redacted(&secret))) .collect(), git_providers: config .git_providers .into_iter() .map(|mut provider| { provider.accounts.iter_mut().for_each(|account| { account.token = empty_or_redacted(&account.token); }); provider }) .collect(), docker_registries: config .docker_registries .into_iter() .map(|mut provider| { provider.accounts.iter_mut().for_each(|account| { account.token = empty_or_redacted(&account.token); }); provider }) .collect(), ssl_enabled: config.ssl_enabled, ssl_key_file: config.ssl_key_file, ssl_cert_file: config.ssl_cert_file, } } } /// Generic Oauth credentials #[derive(Debug, Clone, Default, Deserialize)] pub struct OauthCredentials { /// Whether this oauth method is available for usage. #[serde(default)] pub enabled: bool, /// The Oauth client id. #[serde(default)] pub id: String, /// The Oauth client secret. #[serde(default)] pub secret: String, } /// Provide AWS credentials for Komodo to use. #[derive(Debug, Clone, Default, Deserialize)] pub struct AwsCredentials { /// The aws ACCESS_KEY_ID pub access_key_id: String, /// The aws SECRET_ACCESS_KEY pub secret_access_key: String, } /// Provide configuration for a Github Webhook app. #[derive(Debug, Clone, Deserialize)] pub struct GithubWebhookAppConfig { /// Github app id pub app_id: i64, /// Configure the app installations on multiple accounts / organizations. pub installations: Vec, /// Private key path. Default: /github-private-key.pem. #[serde(default = "default_private_key_path")] pub pk_path: String, } fn default_private_key_path() -> String { String::from("/github/private-key.pem") } impl Default for GithubWebhookAppConfig { fn default() -> Self { GithubWebhookAppConfig { app_id: 0, installations: Default::default(), pk_path: default_private_key_path(), } } } /// Provide configuration for a Github Webhook app installation. #[derive(Debug, Clone, Deserialize)] pub struct GithubWebhookAppInstallationConfig { /// The installation ID pub id: i64, /// The user or organization name pub namespace: String, } ================================================ FILE: client/core/rs/src/entities/config/mod.rs ================================================ use std::sync::OnceLock; use serde::{Deserialize, Serialize}; use typeshare::typeshare; pub mod cli; pub mod core; pub mod periphery; fn default_config_keywords() -> Vec { vec![String::from("*config.*")] } fn default_merge_nested_config() -> bool { true } fn default_extend_config_arrays() -> bool { true } /// Provide database connection information. /// Komodo uses the MongoDB api driver for database communication, /// and FerretDB to support Postgres and Sqlite storage options. /// /// Must provide ONE of: /// 1. `uri` /// 2. `address` + `username` + `password` #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DatabaseConfig { /// Full mongo uri string, eg. `mongodb://username:password@your.mongo.int:27017` #[serde(default, skip_serializing_if = "String::is_empty")] pub uri: String, /// Just the address part of the mongo uri, eg `your.mongo.int:27017` #[serde( default = "default_database_address", skip_serializing_if = "String::is_empty" )] pub address: String, /// Mongo user username #[serde(default, skip_serializing_if = "String::is_empty")] pub username: String, /// Mongo user password #[serde(default, skip_serializing_if = "String::is_empty")] pub password: String, /// Mongo app name. default: `komodo_core` #[serde(default = "default_database_app_name")] pub app_name: String, /// Mongo db name. Which mongo database to create the collections in. /// Default: `komodo`. #[serde(default = "default_database_db_name")] pub db_name: String, } fn default_database_address() -> String { String::from("localhost:27017") } fn default_database_app_name() -> String { "komodo_core".to_string() } fn default_database_db_name() -> String { "komodo".to_string() } impl Default for DatabaseConfig { fn default() -> Self { Self { uri: Default::default(), address: default_database_address(), username: Default::default(), password: Default::default(), app_name: default_database_app_name(), db_name: default_database_db_name(), } } } fn default_database_config() -> &'static DatabaseConfig { static DEFAULT_DATABASE_CONFIG: OnceLock = OnceLock::new(); DEFAULT_DATABASE_CONFIG.get_or_init(Default::default) } impl DatabaseConfig { pub fn sanitized(&self) -> DatabaseConfig { DatabaseConfig { uri: empty_or_redacted(&self.uri), address: self.address.clone(), username: empty_or_redacted(&self.username), password: empty_or_redacted(&self.password), app_name: self.app_name.clone(), db_name: self.db_name.clone(), } } pub fn is_default(&self) -> bool { self == default_database_config() } } #[typeshare] #[derive( Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, )] pub struct GitProvider { /// The git provider domain. Default: `github.com`. #[serde(default = "default_git_provider")] pub domain: String, /// Whether to use https. Default: true. #[serde(default = "default_git_https")] pub https: bool, /// The accounts on the git provider. Required. #[serde(alias = "account")] pub accounts: Vec, } fn default_git_provider() -> String { String::from("github.com") } fn default_git_https() -> bool { true } #[typeshare] #[derive( Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, )] pub struct DockerRegistry { /// The docker provider domain. Default: `docker.io`. #[serde(default = "default_docker_provider")] pub domain: String, /// The accounts on the registry. Required. #[serde(alias = "account")] pub accounts: Vec, /// Available organizations on the registry provider. /// Used to push an image under an organization's repo rather than an account's repo. #[serde(default, alias = "organization")] pub organizations: Vec, } fn default_docker_provider() -> String { String::from("docker.io") } #[typeshare] #[derive( Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, )] pub struct ProviderAccount { /// The account username. Required. #[serde(alias = "account")] pub username: String, /// The account access token. Required. #[serde(default, skip_serializing)] pub token: String, } pub fn empty_or_redacted(src: &str) -> String { if src.is_empty() { String::new() } else { String::from("##############") } } ================================================ FILE: client/core/rs/src/entities/config/periphery.rs ================================================ //! # Configuring the Komodo Periphery Agent //! //! The periphery configuration is passed in three ways: //! 1. Command line args ([CliArgs]) //! 2. Environment Variables ([Env]) //! 3. Configuration File ([PeripheryConfig]) //! //! The final configuration is built by combining parameters //! passed through the different methods. The priority of the args is //! strictly hierarchical, meaning params passed with [CliArgs] have top priority, //! followed by those passed in the environment, followed by those passed in //! the configuration file. //! use clap::Parser; use ipnetwork::IpNetwork; use serde::Deserialize; use std::{collections::HashMap, path::PathBuf}; use crate::{ deserializers::ForgivingVec, entities::{ Timelength, logger::{LogConfig, LogLevel, StdioLogMode}, }, }; use super::{ DockerRegistry, GitProvider, ProviderAccount, empty_or_redacted, }; /// # Periphery Command Line Arguments. /// /// This structure represents the periphery command line arguments used to /// configure the periphery agent. A help manual for the periphery binary /// can be printed using `/path/to/periphery --help`. /// /// Example command: /// ```sh /// periphery \ /// --config-path /path/to/periphery.config.base.toml \ /// --config-path /other_path/to/overide-periphery-config-directory \ /// --config-keyword periphery \ /// --config-keyword config \ /// --merge-nested-config true \ /// --extend-config-arrays false \ /// --log-level info /// ``` #[derive(Parser)] #[command(name = "periphery", author, about, version)] pub struct CliArgs { /// Sets the path of a config file or directory to use. /// Can use multiple times #[arg(long, short = 'c')] pub config_path: Option>, /// Sets the keywords to match directory periphery config file names on. /// Supports wildcard syntax. /// Can use multiple times to match multiple patterns independently. #[arg(long, short = 'm')] pub config_keyword: Option>, /// Merges nested configs, eg. secrets, providers. /// Will override the equivalent env configuration. /// Default: true #[arg(long)] pub merge_nested_config: Option, /// Extends config arrays, eg. allowed_ips, passkeys. /// Will override the equivalent env configuration. /// Default: true #[arg(long)] pub extend_config_arrays: Option, /// Configure the logging level: error, warn, info, debug, trace. /// Default: info /// If passed, will override any other log_level set. #[arg(long)] pub log_level: Option, } /// # Periphery Environment Variables /// /// The variables should be passed in the traditional `UPPER_SNAKE_CASE` format, /// although the lower case format can still be parsed. If equivalent paramater is passed /// in [CliArgs], the value passed to the environment will be ignored in favor of the cli arg. #[derive(Deserialize)] pub struct Env { /// Specify the config paths (files or folders) used to build up the /// final [PeripheryConfig]. /// If not provided, will use Default config. /// /// Note. This is overridden if the equivalent arg is passed in [CliArgs]. #[serde(default, alias = "periphery_config_path")] pub periphery_config_paths: Vec, /// If specifying folders, use this to narrow down which /// files will be matched to parse into the final [PeripheryConfig]. /// Only files inside the folders which have names containing a keywords /// provided to `config_keywords` will be included. /// Keywords support wildcard matching syntax. /// /// Note. This is overridden if the equivalent arg is passed in [CliArgs]. #[serde( default = "super::default_config_keywords", alias = "periphery_config_keyword" )] pub periphery_config_keywords: Vec, /// Will merge nested config object (eg. secrets, providers) across multiple /// config files. Default: `true` /// /// Note. This is overridden if the equivalent arg is passed in [CliArgs]. #[serde(default = "super::default_merge_nested_config")] pub periphery_merge_nested_config: bool, /// Will extend config arrays (eg. `allowed_ips`, `passkeys`) across multiple config files. /// Default: `true` /// /// Note. This is overridden if the equivalent arg is passed in [CliArgs]. #[serde(default = "super::default_extend_config_arrays")] pub periphery_extend_config_arrays: bool, /// Override `port` pub periphery_port: Option, /// Override `bind_ip` pub periphery_bind_ip: Option, /// Override `root_directory` pub periphery_root_directory: Option, /// Override `repo_dir` pub periphery_repo_dir: Option, /// Override `stack_dir` pub periphery_stack_dir: Option, /// Override `build_dir` pub periphery_build_dir: Option, /// Override `disable_terminals` pub periphery_disable_terminals: Option, /// Override `disable_container_exec` pub periphery_disable_container_exec: Option, /// Override `stats_polling_rate` pub periphery_stats_polling_rate: Option, /// Override `container_stats_polling_rate` pub periphery_container_stats_polling_rate: Option, /// Override `legacy_compose_cli` pub periphery_legacy_compose_cli: Option, // LOGGING /// Override `logging.level` pub periphery_logging_level: Option, /// Override `logging.stdio` pub periphery_logging_stdio: Option, /// Override `logging.pretty` pub periphery_logging_pretty: Option, /// Override `logging.location` pub periphery_logging_location: Option, /// Override `logging.otlp_endpoint` pub periphery_logging_otlp_endpoint: Option, /// Override `logging.opentelemetry_service_name` pub periphery_logging_opentelemetry_service_name: Option, /// Override `pretty_startup_config` pub periphery_pretty_startup_config: Option, /// Override `allowed_ips` pub periphery_allowed_ips: Option>, /// Override `passkeys` pub periphery_passkeys: Option>, /// Override `passkeys` from file pub periphery_passkeys_file: Option, /// Override `include_disk_mounts` pub periphery_include_disk_mounts: Option>, /// Override `exclude_disk_mounts` pub periphery_exclude_disk_mounts: Option>, /// Override `ssl_enabled` pub periphery_ssl_enabled: Option, /// Override `ssl_key_file` pub periphery_ssl_key_file: Option, /// Override `ssl_cert_file` pub periphery_ssl_cert_file: Option, } /// # Periphery Configuration File /// /// Refer to the [example file](https://github.com/moghtech/komodo/blob/main/config/periphery.config.toml) for a full example. #[derive(Debug, Clone, Deserialize)] pub struct PeripheryConfig { /// The port periphery will run on. /// Default: `8120` #[serde(default = "default_periphery_port")] pub port: u16, /// IP address the periphery server binds to. /// Default: [::]. #[serde(default = "default_periphery_bind_ip")] pub bind_ip: String, /// The directory Komodo will use as the default root for the specific (repo, stack, build) directories. /// /// repo: ${root_directory}/repos /// stack: ${root_directory}/stacks /// build: ${root_directory}/builds /// /// Note. These can each be overridden with a specific directory /// by specifying `repo_dir`, `stack_dir`, or `build_dir` explicitly /// /// Default: `/etc/komodo` #[serde(default = "default_root_directory")] pub root_directory: PathBuf, /// The system directory where Komodo managed repos will be cloned. /// If not provided, will default to `${root_directory}/repos`. /// Default: empty pub repo_dir: Option, /// The system directory where stacks will managed. /// If not provided, will default to `${root_directory}/stacks`. /// Default: empty pub stack_dir: Option, /// The system directory where builds will managed. /// If not provided, will default to `${root_directory}/builds`. /// Default: empty pub build_dir: Option, /// Whether to disable the create terminal /// and disallow direct remote shell access. /// Default: false #[serde(default)] pub disable_terminals: bool, /// Whether to disable the container exec api /// and disallow remote container shell access. /// Default: false #[serde(default)] pub disable_container_exec: bool, /// The rate at which the system stats will be polled to update the cache. /// Options: https://docs.rs/komodo_client/latest/komodo_client/entities/enum.Timelength.html /// Default: `5-sec` #[serde(default = "default_stats_polling_rate")] pub stats_polling_rate: Timelength, /// The rate at which the container stats will be polled to update the cache. /// Options: https://docs.rs/komodo_client/latest/komodo_client/entities/enum.Timelength.html /// Default: `30-sec` #[serde(default = "default_container_stats_polling_rate")] pub container_stats_polling_rate: Timelength, /// Whether stack actions should use `docker-compose ...` /// instead of `docker compose ...`. /// Default: false #[serde(default)] pub legacy_compose_cli: bool, /// Logging configuration #[serde(default)] pub logging: LogConfig, /// Pretty-log (multi-line) the startup config /// for easier human readability. #[serde(default)] pub pretty_startup_config: bool, /// Limits which IP addresses are allowed to call the api. /// Default: none /// /// Note: this should be configured to increase security. #[serde(default)] pub allowed_ips: ForgivingVec, /// Limits the accepted passkeys. /// Default: none /// /// Note: this should be configured to increase security. #[serde(default)] pub passkeys: Vec, /// If non-empty, only includes specific mount paths in the disk report. #[serde(default)] pub include_disk_mounts: ForgivingVec, /// Exclude specific mount paths in the disk report. #[serde(default)] pub exclude_disk_mounts: ForgivingVec, /// Mapping on local periphery secrets. These can be interpolated into eg. Deployment environment variables. /// Default: none #[serde(default)] pub secrets: HashMap, /// Configure git credentials used to clone private repos. /// Supports any git provider. #[serde(default, alias = "git_provider")] pub git_providers: ForgivingVec, /// Configure docker credentials used to push / pull images. /// Supports any docker image repository. #[serde(default, alias = "docker_registry")] pub docker_registries: ForgivingVec, /// Whether to enable ssl. /// Default: true #[serde(default = "default_ssl_enabled")] pub ssl_enabled: bool, /// Path to the ssl key. /// Default: `${root_directory}/ssl/key.pem`. pub ssl_key_file: Option, /// Path to the ssl cert. /// Default: `${root_directory}/ssl/cert.pem`. pub ssl_cert_file: Option, } fn default_periphery_port() -> u16 { 8120 } fn default_periphery_bind_ip() -> String { "[::]".to_string() } fn default_root_directory() -> PathBuf { "/etc/komodo".parse().unwrap() } fn default_stats_polling_rate() -> Timelength { Timelength::FiveSeconds } fn default_container_stats_polling_rate() -> Timelength { Timelength::ThirtySeconds } fn default_ssl_enabled() -> bool { true } impl Default for PeripheryConfig { fn default() -> Self { Self { port: default_periphery_port(), bind_ip: default_periphery_bind_ip(), root_directory: default_root_directory(), repo_dir: None, stack_dir: None, build_dir: None, disable_terminals: Default::default(), disable_container_exec: Default::default(), stats_polling_rate: default_stats_polling_rate(), container_stats_polling_rate: default_container_stats_polling_rate(), legacy_compose_cli: Default::default(), logging: Default::default(), pretty_startup_config: Default::default(), allowed_ips: Default::default(), passkeys: Default::default(), include_disk_mounts: Default::default(), exclude_disk_mounts: Default::default(), secrets: Default::default(), git_providers: Default::default(), docker_registries: Default::default(), ssl_enabled: default_ssl_enabled(), ssl_key_file: None, ssl_cert_file: None, } } } impl PeripheryConfig { pub fn sanitized(&self) -> PeripheryConfig { PeripheryConfig { port: self.port, bind_ip: self.bind_ip.clone(), root_directory: self.root_directory.clone(), repo_dir: self.repo_dir.clone(), stack_dir: self.stack_dir.clone(), build_dir: self.build_dir.clone(), disable_terminals: self.disable_terminals, disable_container_exec: self.disable_container_exec, stats_polling_rate: self.stats_polling_rate, container_stats_polling_rate: self.container_stats_polling_rate, legacy_compose_cli: self.legacy_compose_cli, logging: self.logging.clone(), pretty_startup_config: self.pretty_startup_config, allowed_ips: self.allowed_ips.clone(), passkeys: self .passkeys .iter() .map(|passkey| empty_or_redacted(passkey)) .collect(), include_disk_mounts: self.include_disk_mounts.clone(), exclude_disk_mounts: self.exclude_disk_mounts.clone(), secrets: self .secrets .iter() .map(|(var, secret)| { (var.to_string(), empty_or_redacted(secret)) }) .collect(), git_providers: self .git_providers .iter() .map(|provider| GitProvider { domain: provider.domain.clone(), https: provider.https, accounts: provider .accounts .iter() .map(|account| ProviderAccount { username: account.username.clone(), token: empty_or_redacted(&account.token), }) .collect(), }) .collect(), docker_registries: self .docker_registries .iter() .map(|provider| DockerRegistry { domain: provider.domain.clone(), organizations: provider.organizations.clone(), accounts: provider .accounts .iter() .map(|account| ProviderAccount { username: account.username.clone(), token: empty_or_redacted(&account.token), }) .collect(), }) .collect(), ssl_enabled: self.ssl_enabled, ssl_key_file: self.ssl_key_file.clone(), ssl_cert_file: self.ssl_cert_file.clone(), } } pub fn repo_dir(&self) -> PathBuf { if let Some(dir) = &self.repo_dir { dir.to_owned() } else { self.root_directory.join("repos") } } pub fn stack_dir(&self) -> PathBuf { if let Some(dir) = &self.stack_dir { dir.to_owned() } else { self.root_directory.join("stacks") } } pub fn build_dir(&self) -> PathBuf { if let Some(dir) = &self.build_dir { dir.to_owned() } else { self.root_directory.join("builds") } } pub fn ssl_key_file(&self) -> PathBuf { if let Some(dir) = &self.ssl_key_file { dir.to_owned() } else { self.root_directory.join("ssl/key.pem") } } pub fn ssl_cert_file(&self) -> PathBuf { if let Some(dir) = &self.ssl_cert_file { dir.to_owned() } else { self.root_directory.join("ssl/cert.pem") } } } ================================================ FILE: client/core/rs/src/entities/deployment.rs ================================================ use anyhow::Context; use bson::{Document, doc}; use derive_builder::Builder; use derive_default_builder::DefaultBuilder; use derive_variants::EnumVariants; use partial_derive2::Partial; use serde::{Deserialize, Serialize}; use strum::{Display, EnumString}; use typeshare::typeshare; use crate::{ deserializers::{ conversions_deserializer, env_vars_deserializer, labels_deserializer, option_conversions_deserializer, option_env_vars_deserializer, option_labels_deserializer, option_string_list_deserializer, option_term_labels_deserializer, string_list_deserializer, term_labels_deserializer, }, entities::{EnvironmentVar, environment_vars_from_str}, parsers::parse_key_value_list, }; use super::{ TerminationSignal, Version, docker::container::ContainerStateStatusEnum, resource::{Resource, ResourceListItem, ResourceQuery}, }; #[typeshare] pub type Deployment = Resource; #[typeshare] pub type DeploymentListItem = ResourceListItem; #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct DeploymentListItemInfo { /// The state of the deployment / underlying docker container. pub state: DeploymentState, /// The status of the docker container (eg. up 12 hours, exited 5 minutes ago.) pub status: Option, /// The image attached to the deployment. pub image: String, /// Whether there is a newer image available at the same tag. pub update_available: bool, /// The server that deployment sits on. pub server_id: String, /// An attached Komodo Build, if it exists. pub build_id: Option, } #[typeshare(serialized_as = "Partial")] pub type _PartialDeploymentConfig = PartialDeploymentConfig; #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Builder, Partial)] #[partial_derive(Serialize, Deserialize, Debug, Clone, Default)] #[partial(skip_serializing_none, from, diff)] pub struct DeploymentConfig { /// The id of server the deployment is deployed on. #[serde(default, alias = "server")] #[partial_attr(serde(alias = "server"))] #[builder(default)] pub server_id: String, /// The image which the deployment deploys. /// Can either be a user inputted image, or a Komodo Build. #[serde(default)] #[builder(default)] pub image: DeploymentImage, /// Configure the account used to pull the image from the registry. /// Used with `docker login`. /// /// - If the field is empty string, will use the same account config as the build, or none at all if using image. /// - If the field contains an account, a token for the account must be available. /// - Will get the registry domain from the build / image #[serde(default)] #[builder(default)] pub image_registry_account: String, /// Whether to skip secret interpolation into the deployment environment variables. #[serde(default)] #[builder(default)] pub skip_secret_interp: bool, /// Whether to redeploy the deployment whenever the attached build finishes. #[serde(default)] #[builder(default)] pub redeploy_on_build: bool, /// Whether to poll for any updates to the image. #[serde(default)] #[builder(default)] pub poll_for_updates: bool, /// Whether to automatically redeploy when /// newer a image is found. Will implicitly /// enable `poll_for_updates`, you don't need to /// enable both. #[serde(default)] #[builder(default)] pub auto_update: bool, /// Whether to send ContainerStateChange alerts for this deployment. #[serde(default = "default_send_alerts")] #[builder(default = "default_send_alerts()")] #[partial_default(default_send_alerts())] pub send_alerts: bool, /// Configure quick links that are displayed in the resource header #[serde(default)] #[builder(default)] pub links: Vec, /// The network attached to the container. /// Default is `host`. #[serde(default = "default_network")] #[builder(default = "default_network()")] #[partial_default(default_network())] pub network: String, /// The restart mode given to the container. #[serde(default)] #[builder(default)] pub restart: RestartMode, /// This is interpolated at the end of the `docker run` command, /// which means they are either passed to the containers inner process, /// or replaces the container command, depending on use of ENTRYPOINT or CMD in dockerfile. /// Empty is no command. #[serde(default)] #[builder(default)] pub command: String, /// The default termination signal to use to stop the deployment. Defaults to SigTerm (default docker signal). #[serde(default)] #[builder(default)] pub termination_signal: TerminationSignal, /// The termination timeout. #[serde(default = "default_termination_timeout")] #[builder(default = "default_termination_timeout()")] #[partial_default(default_termination_timeout())] pub termination_timeout: i32, /// Extra args which are interpolated into the `docker run` command, /// and affect the container configuration. #[serde(default, deserialize_with = "string_list_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_string_list_deserializer" ))] #[builder(default)] pub extra_args: Vec, /// Labels attached to various termination signal options. /// Used to specify different shutdown functionality depending on the termination signal. #[serde(default, deserialize_with = "term_labels_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_term_labels_deserializer" ))] #[builder(default)] pub term_signal_labels: String, /// The container port mapping. /// Irrelevant if container network is `host`. /// Maps ports on host to ports on container. #[serde(default, deserialize_with = "conversions_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_conversions_deserializer" ))] #[builder(default)] pub ports: String, /// The container volume mapping. /// Maps files / folders on host to files / folders in container. #[serde(default, deserialize_with = "conversions_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_conversions_deserializer" ))] #[builder(default)] pub volumes: String, /// The environment variables passed to the container. #[serde(default, deserialize_with = "env_vars_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_env_vars_deserializer" ))] #[builder(default)] pub environment: String, /// The docker labels given to the container. #[serde(default, deserialize_with = "labels_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_labels_deserializer" ))] #[builder(default)] pub labels: String, } impl DeploymentConfig { pub fn builder() -> DeploymentConfigBuilder { DeploymentConfigBuilder::default() } pub fn env_vars(&self) -> anyhow::Result> { environment_vars_from_str(&self.environment) .context("Invalid environment") } } fn default_send_alerts() -> bool { true } fn default_termination_timeout() -> i32 { 10 } fn default_network() -> String { String::from("host") } impl Default for DeploymentConfig { fn default() -> Self { Self { server_id: Default::default(), send_alerts: default_send_alerts(), links: Default::default(), image: Default::default(), image_registry_account: Default::default(), skip_secret_interp: Default::default(), redeploy_on_build: Default::default(), poll_for_updates: Default::default(), auto_update: Default::default(), term_signal_labels: Default::default(), termination_signal: Default::default(), termination_timeout: default_termination_timeout(), ports: Default::default(), volumes: Default::default(), environment: Default::default(), labels: Default::default(), network: default_network(), restart: Default::default(), command: Default::default(), extra_args: Default::default(), } } } #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, EnumVariants, )] #[variant_derive( Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, EnumString )] #[serde(tag = "type", content = "params")] pub enum DeploymentImage { /// Deploy any external image. Image { /// The docker image, can be from any registry that works with docker and that the host server can reach. #[serde(default)] image: String, }, /// Deploy a Komodo Build. Build { /// The id of the Build #[serde(default, alias = "build")] build_id: String, /// Use a custom / older version of the image produced by the build. /// if version is 0.0.0, this means `latest` image. #[serde(default)] version: Version, }, } impl Default for DeploymentImage { fn default() -> Self { Self::Image { image: Default::default(), } } } #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct Conversion { /// reference on the server. pub local: String, /// reference in the container. pub container: String, } pub fn conversions_from_str( input: &str, ) -> anyhow::Result> { parse_key_value_list(input).map(|conversions| { conversions .into_iter() .map(|(local, container)| Conversion { local, container }) .collect() }) } /// Variants de/serialized from/to snake_case. /// /// Eg. /// - NotDeployed -> not_deployed /// - Restarting -> restarting /// - Running -> running. #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Display, EnumString, Serialize, Deserialize, )] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum DeploymentState { /// The deployment is currently re/deploying Deploying, /// Container is running Running, /// Container is created but not running Created, /// Container is in restart loop Restarting, /// Container is being removed Removing, /// Container is paused Paused, /// Container is exited Exited, /// Container is dead Dead, /// The deployment is not deployed (no matching container) NotDeployed, /// Server not reachable for status #[default] Unknown, } impl From for DeploymentState { fn from(value: ContainerStateStatusEnum) -> Self { match value { ContainerStateStatusEnum::Empty => DeploymentState::Unknown, ContainerStateStatusEnum::Created => DeploymentState::Created, ContainerStateStatusEnum::Running => DeploymentState::Running, ContainerStateStatusEnum::Paused => DeploymentState::Paused, ContainerStateStatusEnum::Restarting => { DeploymentState::Restarting } ContainerStateStatusEnum::Removing => DeploymentState::Removing, ContainerStateStatusEnum::Exited => DeploymentState::Exited, ContainerStateStatusEnum::Dead => DeploymentState::Dead, } } } #[typeshare] #[derive( Serialize, Deserialize, Debug, PartialEq, Hash, Eq, Clone, Copy, Default, Display, EnumString, )] pub enum RestartMode { #[default] #[serde(rename = "no")] #[strum(serialize = "no")] NoRestart, #[serde(rename = "on-failure")] #[strum(serialize = "on-failure")] OnFailure, #[serde(rename = "always")] #[strum(serialize = "always")] Always, #[serde(rename = "unless-stopped")] #[strum(serialize = "unless-stopped")] UnlessStopped, } #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, Builder, )] pub struct TerminationSignalLabel { #[builder(default)] pub signal: TerminationSignal, #[builder(default)] pub label: String, } pub fn term_signal_labels_from_str( input: &str, ) -> anyhow::Result> { parse_key_value_list(input).and_then(|list| { list .into_iter() .map(|(signal, label)| { anyhow::Ok(TerminationSignalLabel { signal: signal.parse()?, label, }) }) .collect() }) } #[typeshare] #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] pub struct DeploymentActionState { pub pulling: bool, pub deploying: bool, pub starting: bool, pub restarting: bool, pub pausing: bool, pub unpausing: bool, pub stopping: bool, pub destroying: bool, pub renaming: bool, } #[typeshare] pub type DeploymentQuery = ResourceQuery; #[typeshare] #[derive( Debug, Clone, Default, Serialize, Deserialize, DefaultBuilder, )] pub struct DeploymentQuerySpecifics { /// Query only for Deployments on these Servers. /// If empty, does not filter by Server. /// Only accepts Server id (not name). #[serde(default)] pub server_ids: Vec, /// Query only for Deployments with these Builds attached. /// If empty, does not filter by Build. /// Only accepts Build id (not name). #[serde(default)] pub build_ids: Vec, /// Query only for Deployments with available image updates. #[serde(default)] pub update_available: bool, } impl super::resource::AddFilters for DeploymentQuerySpecifics { fn add_filters(&self, filters: &mut Document) { if !self.server_ids.is_empty() { filters .insert("config.server_id", doc! { "$in": &self.server_ids }); } if !self.build_ids.is_empty() { filters.insert("config.image.type", "Build"); filters.insert( "config.image.params.build_id", doc! { "$in": &self.build_ids }, ); } } } pub fn extract_registry_domain( image_name: &str, ) -> anyhow::Result { let mut split = image_name.split('/'); let maybe_domain = split.next().context("image name cannot be empty string")?; if maybe_domain.contains('.') { Ok(maybe_domain.to_string()) } else { Ok(String::from("docker.io")) } } ================================================ FILE: client/core/rs/src/entities/docker/container.rs ================================================ use std::collections::HashMap; use anyhow::anyhow; use serde::{Deserialize, Serialize}; use strum::Display; use typeshare::typeshare; use crate::entities::{I64, Usize}; use super::{ContainerConfig, GraphDriverData, PortBinding}; /// Container summary returned by container list apis. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ContainerListItem { /// The Server which holds the container. #[serde(skip_serializing_if = "Option::is_none")] pub server_id: Option, /// The first name in Names, not including the initial '/' pub name: String, /// The ID of this container #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, /// The name of the image used when creating this container #[serde(skip_serializing_if = "Option::is_none")] pub image: Option, /// The ID of the image that this container was created from #[serde(skip_serializing_if = "Option::is_none")] pub image_id: Option, /// When the container was created #[serde(skip_serializing_if = "Option::is_none")] pub created: Option, /// The size of files that have been created or changed by this container #[serde(skip_serializing_if = "Option::is_none")] pub size_rw: Option, /// The total size of all the files in this container #[serde(skip_serializing_if = "Option::is_none")] pub size_root_fs: Option, /// The state of this container (e.g. `exited`) pub state: ContainerStateStatusEnum, /// Additional human-readable status of this container (e.g. `Exit 0`) #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, /// The network mode #[serde(skip_serializing_if = "Option::is_none")] pub network_mode: Option, /// The network names attached to container #[serde(default, skip_serializing_if = "Vec::is_empty")] pub networks: Vec, /// Port mappings for the container #[serde(default, skip_serializing_if = "Vec::is_empty")] pub ports: Vec, /// The volume names attached to container #[serde(default, skip_serializing_if = "Vec::is_empty")] pub volumes: Vec, /// The container stats, if they can be retreived. #[serde(skip_serializing_if = "Option::is_none")] pub stats: Option, /// The labels attached to container. /// It's too big to send with container list, /// can get it using InspectContainer #[serde(default, skip_serializing)] pub labels: HashMap, } #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct NameAndId { pub name: String, pub id: String, } /// An open port on a container #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct Port { /// Host IP address that the container's port is mapped to #[serde(rename = "IP")] pub ip: Option, /// Port on the container #[serde(default, rename = "PrivatePort")] pub private_port: u16, /// Port exposed on the host #[serde(rename = "PublicPort")] pub public_port: Option, #[serde(default, rename = "Type")] pub typ: PortTypeEnum, } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize, )] pub enum PortTypeEnum { #[default] #[serde(rename = "")] EMPTY, #[serde(rename = "tcp")] TCP, #[serde(rename = "udp")] UDP, #[serde(rename = "sctp")] SCTP, } #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct Container { /// The ID of the container #[serde(rename = "Id")] pub id: Option, /// The time the container was created #[serde(rename = "Created")] pub created: Option, /// The path to the command being run #[serde(rename = "Path")] pub path: Option, /// The arguments to the command being run #[serde(default, rename = "Args")] pub args: Vec, #[serde(rename = "State")] pub state: Option, /// The container's image ID #[serde(rename = "Image")] pub image: Option, #[serde(rename = "ResolvConfPath")] pub resolv_conf_path: Option, #[serde(rename = "HostnamePath")] pub hostname_path: Option, #[serde(rename = "HostsPath")] pub hosts_path: Option, #[serde(rename = "LogPath")] pub log_path: Option, #[serde(rename = "Name")] pub name: Option, #[serde(rename = "RestartCount")] pub restart_count: Option, #[serde(rename = "Driver")] pub driver: Option, #[serde(rename = "Platform")] pub platform: Option, #[serde(rename = "MountLabel")] pub mount_label: Option, #[serde(rename = "ProcessLabel")] pub process_label: Option, #[serde(rename = "AppArmorProfile")] pub app_armor_profile: Option, /// IDs of exec instances that are running in the container. #[serde(default, rename = "ExecIDs")] pub exec_ids: Vec, #[serde(rename = "HostConfig")] pub host_config: Option, #[serde(rename = "GraphDriver")] pub graph_driver: Option, /// The size of files that have been created or changed by this container. #[serde(rename = "SizeRw")] pub size_rw: Option, /// The total size of all the files in this container. #[serde(rename = "SizeRootFs")] pub size_root_fs: Option, #[serde(default, rename = "Mounts")] pub mounts: Vec, #[serde(rename = "Config")] pub config: Option, #[serde(rename = "NetworkSettings")] pub network_settings: Option, } /// ContainerState stores container's running state. It's part of ContainerJSONBase and will be returned by the \"inspect\" command. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ContainerState { /// String representation of the container state. Can be one of \"created\", \"running\", \"paused\", \"restarting\", \"removing\", \"exited\", or \"dead\". #[serde(default, rename = "Status")] pub status: ContainerStateStatusEnum, /// Whether this container is running. Note that a running container can be _paused_. The `Running` and `Paused` booleans are not mutually exclusive: When pausing a container (on Linux), the freezer cgroup is used to suspend all processes in the container. Freezing the process requires the process to be running. As a result, paused containers are both `Running` _and_ `Paused`. Use the `Status` field instead to determine if a container's state is \"running\". #[serde(rename = "Running")] pub running: Option, /// Whether this container is paused. #[serde(rename = "Paused")] pub paused: Option, /// Whether this container is restarting. #[serde(rename = "Restarting")] pub restarting: Option, /// Whether a process within this container has been killed because it ran out of memory since the container was last started. #[serde(rename = "OOMKilled")] pub oom_killed: Option, #[serde(rename = "Dead")] pub dead: Option, /// The process ID of this container #[serde(rename = "Pid")] pub pid: Option, /// The last exit code of this container #[serde(rename = "ExitCode")] pub exit_code: Option, #[serde(rename = "Error")] pub error: Option, /// The time when this container was last started. #[serde(rename = "StartedAt")] pub started_at: Option, /// The time when this container last exited. #[serde(rename = "FinishedAt")] pub finished_at: Option, #[serde(rename = "Health")] pub health: Option, } #[typeshare] #[derive( Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Display, Serialize, Deserialize, )] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] pub enum ContainerStateStatusEnum { Running, Created, Paused, Restarting, Exited, Removing, Dead, #[default] #[serde(rename = "")] #[strum(serialize = "")] Empty, } impl ::std::str::FromStr for ContainerStateStatusEnum { type Err = anyhow::Error; fn from_str(s: &str) -> Result { match s { "" => Ok(ContainerStateStatusEnum::Empty), "created" => Ok(ContainerStateStatusEnum::Created), "running" => Ok(ContainerStateStatusEnum::Running), "paused" => Ok(ContainerStateStatusEnum::Paused), "restarting" => Ok(ContainerStateStatusEnum::Restarting), "removing" => Ok(ContainerStateStatusEnum::Removing), "exited" => Ok(ContainerStateStatusEnum::Exited), "dead" => Ok(ContainerStateStatusEnum::Dead), x => Err(anyhow!("Invalid container state: {}", x)), } } } /// Health stores information about the container's healthcheck results. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ContainerHealth { /// Status is one of `none`, `starting`, `healthy` or `unhealthy` - \"none\" Indicates there is no healthcheck - \"starting\" Starting indicates that the container is not yet ready - \"healthy\" Healthy indicates that the container is running correctly - \"unhealthy\" Unhealthy indicates that the container has a problem #[serde(default, rename = "Status")] pub status: HealthStatusEnum, /// FailingStreak is the number of consecutive failures #[serde(rename = "FailingStreak")] pub failing_streak: Option, /// Log contains the last few results (oldest first) #[serde(default, rename = "Log")] pub log: Vec, } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize, Eq, Ord, Default, )] pub enum HealthStatusEnum { #[default] #[serde(rename = "")] Empty, #[serde(rename = "none")] None, #[serde(rename = "starting")] Starting, #[serde(rename = "healthy")] Healthy, #[serde(rename = "unhealthy")] Unhealthy, } /// HealthcheckResult stores information about a single run of a healthcheck probe #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct HealthcheckResult { /// Date and time at which this check started in [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. #[serde(rename = "Start")] pub start: Option, /// Date and time at which this check ended in [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. #[serde(rename = "End")] pub end: Option, /// ExitCode meanings: - `0` healthy - `1` unhealthy - `2` reserved (considered unhealthy) - other values: error running probe #[serde(rename = "ExitCode")] pub exit_code: Option, /// Output from last check #[serde(rename = "Output")] pub output: Option, } /// Container configuration that depends on the host we are running on #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct HostConfig { /// An integer value representing this container's relative CPU weight versus other containers. #[serde(rename = "CpuShares")] pub cpu_shares: Option, /// Memory limit in bytes. #[serde(rename = "Memory")] pub memory: Option, /// Path to `cgroups` under which the container's `cgroup` is created. If the path is not absolute, the path is considered to be relative to the `cgroups` path of the init process. Cgroups are created if they do not already exist. #[serde(rename = "CgroupParent")] pub cgroup_parent: Option, /// Block IO weight (relative weight). #[serde(rename = "BlkioWeight")] pub blkio_weight: Option, /// Block IO weight (relative device weight) in the form: ``` [{\"Path\": \"device_path\", \"Weight\": weight}] ``` #[serde(default, rename = "BlkioWeightDevice")] pub blkio_weight_device: Vec, /// Limit read rate (bytes per second) from a device, in the form: ``` [{\"Path\": \"device_path\", \"Rate\": rate}] ``` #[serde(default, rename = "BlkioDeviceReadBps")] pub blkio_device_read_bps: Vec, /// Limit write rate (bytes per second) to a device, in the form: ``` [{\"Path\": \"device_path\", \"Rate\": rate}] ``` #[serde(default, rename = "BlkioDeviceWriteBps")] pub blkio_device_write_bps: Vec, /// Limit read rate (IO per second) from a device, in the form: ``` [{\"Path\": \"device_path\", \"Rate\": rate}] ``` #[serde(default, rename = "BlkioDeviceReadIOps")] pub blkio_device_read_iops: Vec, /// Limit write rate (IO per second) to a device, in the form: ``` [{\"Path\": \"device_path\", \"Rate\": rate}] ``` #[serde(default, rename = "BlkioDeviceWriteIOps")] pub blkio_device_write_iops: Vec, /// The length of a CPU period in microseconds. #[serde(rename = "CpuPeriod")] pub cpu_period: Option, /// Microseconds of CPU time that the container can get in a CPU period. #[serde(rename = "CpuQuota")] pub cpu_quota: Option, /// The length of a CPU real-time period in microseconds. Set to 0 to allocate no time allocated to real-time tasks. #[serde(rename = "CpuRealtimePeriod")] pub cpu_realtime_period: Option, /// The length of a CPU real-time runtime in microseconds. Set to 0 to allocate no time allocated to real-time tasks. #[serde(rename = "CpuRealtimeRuntime")] pub cpu_realtime_runtime: Option, /// CPUs in which to allow execution (e.g., `0-3`, `0,1`). #[serde(rename = "CpusetCpus")] pub cpuset_cpus: Option, /// Memory nodes (MEMs) in which to allow execution (0-3, 0,1). Only effective on NUMA systems. #[serde(rename = "CpusetMems")] pub cpuset_mems: Option, /// A list of devices to add to the container. #[serde(default, rename = "Devices")] pub devices: Vec, /// a list of cgroup rules to apply to the container #[serde(default, rename = "DeviceCgroupRules")] pub device_cgroup_rules: Vec, /// A list of requests for devices to be sent to device drivers. #[serde(default, rename = "DeviceRequests")] pub device_requests: Vec, /// Hard limit for kernel TCP buffer memory (in bytes). Depending on the OCI runtime in use, this option may be ignored. It is no longer supported by the default (runc) runtime. This field is omitted when empty. #[serde(rename = "KernelMemoryTCP")] pub kernel_memory_tcp: Option, /// Memory soft limit in bytes. #[serde(rename = "MemoryReservation")] pub memory_reservation: Option, /// Total memory limit (memory + swap). Set as `-1` to enable unlimited swap. #[serde(rename = "MemorySwap")] pub memory_swap: Option, /// Tune a container's memory swappiness behavior. Accepts an integer between 0 and 100. #[serde(rename = "MemorySwappiness")] pub memory_swappiness: Option, /// CPU quota in units of 10-9 CPUs. #[serde(rename = "NanoCpus")] pub nano_cpus: Option, /// Disable OOM Killer for the container. #[serde(rename = "OomKillDisable")] pub oom_kill_disable: Option, /// Run an init inside the container that forwards signals and reaps processes. This field is omitted if empty, and the default (as configured on the daemon) is used. #[serde(rename = "Init")] pub init: Option, /// Tune a container's PIDs limit. Set `0` or `-1` for unlimited, or `null` to not change. #[serde(rename = "PidsLimit")] pub pids_limit: Option, /// A list of resource limits to set in the container. For example: ``` {\"Name\": \"nofile\", \"Soft\": 1024, \"Hard\": 2048} ``` #[serde(default, rename = "Ulimits")] pub ulimits: Vec, /// The number of usable CPUs (Windows only). On Windows Server containers, the processor resource controls are mutually exclusive. The order of precedence is `CPUCount` first, then `CPUShares`, and `CPUPercent` last. #[serde(rename = "CpuCount")] pub cpu_count: Option, /// The usable percentage of the available CPUs (Windows only). On Windows Server containers, the processor resource controls are mutually exclusive. The order of precedence is `CPUCount` first, then `CPUShares`, and `CPUPercent` last. #[serde(rename = "CpuPercent")] pub cpu_percent: Option, /// Maximum IOps for the container system drive (Windows only) #[serde(rename = "IOMaximumIOps")] pub io_maximum_iops: Option, /// Maximum IO in bytes per second for the container system drive (Windows only). #[serde(rename = "IOMaximumBandwidth")] pub io_maximum_bandwidth: Option, /// A list of volume bindings for this container. Each volume binding is a string in one of these forms: - `host-src:container-dest[:options]` to bind-mount a host path into the container. Both `host-src`, and `container-dest` must be an _absolute_ path. - `volume-name:container-dest[:options]` to bind-mount a volume managed by a volume driver into the container. `container-dest` must be an _absolute_ path. `options` is an optional, comma-delimited list of: - `nocopy` disables automatic copying of data from the container path to the volume. The `nocopy` flag only applies to named volumes. - `[ro|rw]` mounts a volume read-only or read-write, respectively. If omitted or set to `rw`, volumes are mounted read-write. - `[z|Z]` applies SELinux labels to allow or deny multiple containers to read and write to the same volume. - `z`: a _shared_ content label is applied to the content. This label indicates that multiple containers can share the volume content, for both reading and writing. - `Z`: a _private unshared_ label is applied to the content. This label indicates that only the current container can use a private volume. Labeling systems such as SELinux require proper labels to be placed on volume content that is mounted into a container. Without a label, the security system can prevent a container's processes from using the content. By default, the labels set by the host operating system are not modified. - `[[r]shared|[r]slave|[r]private]` specifies mount [propagation behavior](https://www.kernel.org/doc/Documentation/filesystems/sharedsubtree.txt). This only applies to bind-mounted volumes, not internal volumes or named volumes. Mount propagation requires the source mount point (the location where the source directory is mounted in the host operating system) to have the correct propagation properties. For shared volumes, the source mount point must be set to `shared`. For slave volumes, the mount must be set to either `shared` or `slave`. #[serde(default, rename = "Binds")] pub binds: Vec, /// Path to a file where the container ID is written #[serde(rename = "ContainerIDFile")] pub container_id_file: Option, #[serde(rename = "LogConfig")] pub log_config: Option, /// Network mode to use for this container. Supported standard values are: `bridge`, `host`, `none`, and `container:`. Any other value is taken as a custom network's name to which this container should connect to. #[serde(rename = "NetworkMode")] pub network_mode: Option, #[serde(default, rename = "PortBindings")] pub port_bindings: HashMap>, #[serde(rename = "RestartPolicy")] pub restart_policy: Option, /// Automatically remove the container when the container's process exits. This has no effect if `RestartPolicy` is set. #[serde(rename = "AutoRemove")] pub auto_remove: Option, /// Driver that this container uses to mount volumes. #[serde(rename = "VolumeDriver")] pub volume_driver: Option, /// A list of volumes to inherit from another container, specified in the form `[:]`. #[serde(default, rename = "VolumesFrom")] pub volumes_from: Vec, /// Specification for mounts to be added to the container. #[serde(default, rename = "Mounts")] pub mounts: Vec, /// Initial console size, as an `[height, width]` array. #[serde(default, rename = "ConsoleSize")] pub console_size: Vec, /// Arbitrary non-identifying metadata attached to container and provided to the runtime when the container is started. #[serde(default, rename = "Annotations")] pub annotations: HashMap, /// A list of kernel capabilities to add to the container. Conflicts with option 'Capabilities'. #[serde(default, rename = "CapAdd")] pub cap_add: Vec, /// A list of kernel capabilities to drop from the container. Conflicts with option 'Capabilities'. #[serde(default, rename = "CapDrop")] pub cap_drop: Vec, /// cgroup namespace mode for the container. Possible values are: - `\"private\"`: the container runs in its own private cgroup namespace - `\"host\"`: use the host system's cgroup namespace If not specified, the daemon default is used, which can either be `\"private\"` or `\"host\"`, depending on daemon version, kernel support and configuration. #[serde(rename = "CgroupnsMode")] pub cgroupns_mode: Option, /// A list of DNS servers for the container to use. #[serde(default, rename = "Dns")] pub dns: Vec, /// A list of DNS options. #[serde(default, rename = "DnsOptions")] pub dns_options: Vec, /// A list of DNS search domains. #[serde(default, rename = "DnsSearch")] pub dns_search: Vec, /// A list of hostnames/IP mappings to add to the container's `/etc/hosts` file. Specified in the form `[\"hostname:IP\"]`. #[serde(default, rename = "ExtraHosts")] pub extra_hosts: Vec, /// A list of additional groups that the container process will run as. #[serde(default, rename = "GroupAdd")] pub group_add: Vec, /// IPC sharing mode for the container. Possible values are: - `\"none\"`: own private IPC namespace, with /dev/shm not mounted - `\"private\"`: own private IPC namespace - `\"shareable\"`: own private IPC namespace, with a possibility to share it with other containers - `\"container:\"`: join another (shareable) container's IPC namespace - `\"host\"`: use the host system's IPC namespace If not specified, daemon default is used, which can either be `\"private\"` or `\"shareable\"`, depending on daemon version and configuration. #[serde(rename = "IpcMode")] pub ipc_mode: Option, /// Cgroup to use for the container. #[serde(rename = "Cgroup")] pub cgroup: Option, /// A list of links for the container in the form `container_name:alias`. #[serde(default, rename = "Links")] pub links: Vec, /// An integer value containing the score given to the container in order to tune OOM killer preferences. #[serde(rename = "OomScoreAdj")] pub oom_score_adj: Option, /// Set the PID (Process) Namespace mode for the container. It can be either: - `\"container:\"`: joins another container's PID namespace - `\"host\"`: use the host's PID namespace inside the container #[serde(rename = "PidMode")] pub pid_mode: Option, /// Gives the container full access to the host. #[serde(rename = "Privileged")] pub privileged: Option, /// Allocates an ephemeral host port for all of a container's exposed ports. Ports are de-allocated when the container stops and allocated when the container starts. The allocated port might be changed when restarting the container. The port is selected from the ephemeral port range that depends on the kernel. For example, on Linux the range is defined by `/proc/sys/net/ipv4/ip_local_port_range`. #[serde(rename = "PublishAllPorts")] pub publish_all_ports: Option, /// Mount the container's root filesystem as read only. #[serde(rename = "ReadonlyRootfs")] pub readonly_rootfs: Option, /// A list of string values to customize labels for MLS systems, such as SELinux. #[serde(default, rename = "SecurityOpt")] pub security_opt: Vec, /// Storage driver options for this container, in the form `{\"size\": \"120G\"}`. #[serde(default, rename = "StorageOpt")] pub storage_opt: HashMap, /// A map of container directories which should be replaced by tmpfs mounts, and their corresponding mount options. For example: ``` { \"/run\": \"rw,noexec,nosuid,size=65536k\" } ``` #[serde(default, rename = "Tmpfs")] pub tmpfs: HashMap, /// UTS namespace to use for the container. #[serde(rename = "UTSMode")] pub uts_mode: Option, /// Sets the usernamespace mode for the container when usernamespace remapping option is enabled. #[serde(rename = "UsernsMode")] pub userns_mode: Option, /// Size of `/dev/shm` in bytes. If omitted, the system uses 64MB. #[serde(rename = "ShmSize")] pub shm_size: Option, /// A list of kernel parameters (sysctls) to set in the container. For example: ``` {\"net.ipv4.ip_forward\": \"1\"} ``` #[serde(default, rename = "Sysctls")] pub sysctls: HashMap, /// Runtime to use with this container. #[serde(rename = "Runtime")] pub runtime: Option, /// Isolation technology of the container. (Windows only) #[serde(default, rename = "Isolation")] pub isolation: HostConfigIsolationEnum, /// The list of paths to be masked inside the container (this overrides the default set of paths). #[serde(default, rename = "MaskedPaths")] pub masked_paths: Vec, /// The list of paths to be set as read-only inside the container (this overrides the default set of paths). #[serde(default, rename = "ReadonlyPaths")] pub readonly_paths: Vec, } #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ResourcesBlkioWeightDevice { #[serde(rename = "Path")] pub path: Option, #[serde(rename = "Weight")] pub weight: Option, } #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ThrottleDevice { /// Device path #[serde(rename = "Path")] pub path: Option, /// Rate #[serde(rename = "Rate")] pub rate: Option, } /// A device mapping between the host and container #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct DeviceMapping { #[serde(rename = "PathOnHost")] pub path_on_host: Option, #[serde(rename = "PathInContainer")] pub path_in_container: Option, #[serde(rename = "CgroupPermissions")] pub cgroup_permissions: Option, } /// A request for devices to be sent to device drivers #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct DeviceRequest { #[serde(rename = "Driver")] pub driver: Option, #[serde(rename = "Count")] pub count: Option, #[serde(default, rename = "DeviceIDs")] pub device_ids: Vec, /// A list of capabilities; an OR list of AND lists of capabilities. #[serde(default, rename = "Capabilities")] pub capabilities: Vec>, /// Driver-specific options, specified as a key/value pairs. These options are passed directly to the driver. #[serde(default, rename = "Options")] pub options: HashMap, } #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ResourcesUlimits { /// Name of ulimit #[serde(rename = "Name")] pub name: Option, /// Soft limit #[serde(rename = "Soft")] pub soft: Option, /// Hard limit #[serde(rename = "Hard")] pub hard: Option, } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize, Eq, Ord, Default, )] pub enum HostConfigIsolationEnum { #[default] #[serde(rename = "")] Empty, #[serde(rename = "default")] Default, #[serde(rename = "process")] Process, #[serde(rename = "hyperv")] Hyperv, } /// The logging configuration for this container #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct HostConfigLogConfig { #[serde(rename = "Type")] pub typ: Option, #[serde(default, rename = "Config")] pub config: HashMap, } /// The behavior to apply when the container exits. The default is not to restart. An ever increasing delay (double the previous delay, starting at 100ms) is added before each restart to prevent flooding the server. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct RestartPolicy { /// - Empty string means not to restart - `no` Do not automatically restart - `always` Always restart - `unless-stopped` Restart always except when the user has manually stopped the container - `on-failure` Restart only when the container exit code is non-zero #[serde(default, rename = "Name")] pub name: RestartPolicyNameEnum, /// If `on-failure` is used, the number of times to retry before giving up. #[serde(rename = "MaximumRetryCount")] pub maximum_retry_count: Option, } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize, Eq, Ord, Default, )] pub enum RestartPolicyNameEnum { #[default] #[serde(rename = "")] Empty, #[serde(rename = "no")] No, #[serde(rename = "always")] Always, #[serde(rename = "unless-stopped")] UnlessStopped, #[serde(rename = "on-failure")] OnFailure, } #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ContainerMount { /// Container path. #[serde(rename = "Target")] pub target: Option, /// Mount source (e.g. a volume name, a host path). #[serde(rename = "Source")] pub source: Option, /// The mount type. Available types: - `bind` Mounts a file or directory from the host into the container. Must exist prior to creating the container. - `volume` Creates a volume with the given name and options (or uses a pre-existing volume with the same name and options). These are **not** removed when the container is removed. - `tmpfs` Create a tmpfs with the given options. The mount source cannot be specified for tmpfs. - `npipe` Mounts a named pipe from the host into the container. Must exist prior to creating the container. - `cluster` a Swarm cluster volume #[serde(default, rename = "Type")] pub typ: MountTypeEnum, /// Whether the mount should be read-only. #[serde(rename = "ReadOnly")] pub read_only: Option, /// The consistency requirement for the mount: `default`, `consistent`, `cached`, or `delegated`. #[serde(rename = "Consistency")] pub consistency: Option, #[serde(rename = "BindOptions")] pub bind_options: Option, #[serde(rename = "VolumeOptions")] pub volume_options: Option, #[serde(rename = "TmpfsOptions")] pub tmpfs_options: Option, } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize, Eq, Ord, Default, )] pub enum MountTypeEnum { #[default] #[serde(rename = "")] Empty, #[serde(rename = "bind")] Bind, #[serde(rename = "volume")] Volume, #[serde(rename = "image")] Image, #[serde(rename = "tmpfs")] Tmpfs, #[serde(rename = "npipe")] Npipe, #[serde(rename = "cluster")] Cluster, } /// Optional configuration for the `bind` type. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct MountBindOptions { /// A propagation mode with the value `[r]private`, `[r]shared`, or `[r]slave`. #[serde(default, rename = "Propagation")] pub propagation: MountBindOptionsPropagationEnum, /// Disable recursive bind mount. #[serde(rename = "NonRecursive")] pub non_recursive: Option, /// Create mount point on host if missing #[serde(rename = "CreateMountpoint")] pub create_mountpoint: Option, /// Make the mount non-recursively read-only, but still leave the mount recursive (unless NonRecursive is set to `true` in conjunction). Addded in v1.44, before that version all read-only mounts were non-recursive by default. To match the previous behaviour this will default to `true` for clients on versions prior to v1.44. #[serde(rename = "ReadOnlyNonRecursive")] pub read_only_non_recursive: Option, /// Raise an error if the mount cannot be made recursively read-only. #[serde(rename = "ReadOnlyForceRecursive")] pub read_only_force_recursive: Option, } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize, Eq, Ord, Default, )] pub enum MountBindOptionsPropagationEnum { #[default] #[serde(rename = "")] Empty, #[serde(rename = "private")] Private, #[serde(rename = "rprivate")] Rprivate, #[serde(rename = "shared")] Shared, #[serde(rename = "rshared")] Rshared, #[serde(rename = "slave")] Slave, #[serde(rename = "rslave")] Rslave, } /// Optional configuration for the `volume` type. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct MountVolumeOptions { /// Populate volume with data from the target. #[serde(rename = "NoCopy")] pub no_copy: Option, /// User-defined key/value metadata. #[serde(default, rename = "Labels")] pub labels: HashMap, #[serde(rename = "DriverConfig")] pub driver_config: Option, /// Source path inside the volume. Must be relative without any back traversals. #[serde(rename = "Subpath")] pub subpath: Option, } /// Map of driver specific options #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct MountVolumeOptionsDriverConfig { /// Name of the driver to use to create the volume. #[serde(rename = "Name")] pub name: Option, /// key/value map of driver specific options. #[serde(default, rename = "Options")] pub options: HashMap, } /// Optional configuration for the `tmpfs` type. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct MountTmpfsOptions { /// The size for the tmpfs mount in bytes. #[serde(rename = "SizeBytes")] pub size_bytes: Option, /// The permission mode for the tmpfs mount in an integer. #[serde(rename = "Mode")] pub mode: Option, } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize, Eq, Ord, Default, )] pub enum HostConfigCgroupnsModeEnum { #[default] #[serde(rename = "")] Empty, #[serde(rename = "private")] Private, #[serde(rename = "host")] Host, } /// MountPoint represents a mount point configuration inside the container. This is used for reporting the mountpoints in use by a container. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct MountPoint { /// The mount type: - `bind` a mount of a file or directory from the host into the container. - `volume` a docker volume with the given `Name`. - `tmpfs` a `tmpfs`. - `npipe` a named pipe from the host into the container. - `cluster` a Swarm cluster volume #[serde(default, rename = "Type")] pub typ: MountTypeEnum, /// Name is the name reference to the underlying data defined by `Source` e.g., the volume name. #[serde(rename = "Name")] pub name: Option, /// Source location of the mount. For volumes, this contains the storage location of the volume (within `/var/lib/docker/volumes/`). For bind-mounts, and `npipe`, this contains the source (host) part of the bind-mount. For `tmpfs` mount points, this field is empty. #[serde(rename = "Source")] pub source: Option, /// Destination is the path relative to the container root (`/`) where the `Source` is mounted inside the container. #[serde(rename = "Destination")] pub destination: Option, /// Driver is the volume driver used to create the volume (if it is a volume). #[serde(rename = "Driver")] pub driver: Option, /// Mode is a comma separated list of options supplied by the user when creating the bind/volume mount. The default is platform-specific (`\"z\"` on Linux, empty on Windows). #[serde(rename = "Mode")] pub mode: Option, /// Whether the mount is mounted writable (read-write). #[serde(rename = "RW")] pub rw: Option, /// Propagation describes how mounts are propagated from the host into the mount point, and vice-versa. Refer to the [Linux kernel documentation](https://www.kernel.org/doc/Documentation/filesystems/sharedsubtree.txt) for details. This field is not used on Windows. #[serde(rename = "Propagation")] pub propagation: Option, } /// NetworkSettings exposes the network settings in the API #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct NetworkSettings { /// Name of the default bridge interface when dockerd's --bridge flag is set. #[serde(rename = "Bridge")] pub bridge: Option, /// SandboxID uniquely represents a container's network stack. #[serde(rename = "SandboxID")] pub sandbox_id: Option, #[serde(default, rename = "Ports")] pub ports: HashMap>, /// SandboxKey is the full path of the netns handle #[serde(rename = "SandboxKey")] pub sandbox_key: Option, /// Information about all networks that the container is connected to. #[serde(default, rename = "Networks")] pub networks: HashMap, } /// Configuration for a network endpoint. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct EndpointSettings { #[serde(rename = "IPAMConfig")] pub ipam_config: Option, #[serde(default, rename = "Links")] pub links: Vec, /// MAC address for the endpoint on this network. The network driver might ignore this parameter. #[serde(rename = "MacAddress")] pub mac_address: Option, #[serde(default, rename = "Aliases")] pub aliases: Vec, /// Unique ID of the network. #[serde(rename = "NetworkID")] pub network_id: Option, /// Unique ID for the service endpoint in a Sandbox. #[serde(rename = "EndpointID")] pub endpoint_id: Option, /// Gateway address for this network. #[serde(rename = "Gateway")] pub gateway: Option, /// IPv4 address. #[serde(rename = "IPAddress")] pub ip_address: Option, /// Mask length of the IPv4 address. #[serde(rename = "IPPrefixLen")] pub ip_prefix_len: Option, /// IPv6 gateway address. #[serde(rename = "IPv6Gateway")] pub ipv6_gateway: Option, /// Global IPv6 address. #[serde(rename = "GlobalIPv6Address")] pub global_ipv6_address: Option, /// Mask length of the global IPv6 address. #[serde(rename = "GlobalIPv6PrefixLen")] pub global_ipv6_prefix_len: Option, /// DriverOpts is a mapping of driver options and values. These options are passed directly to the driver and are driver specific. #[serde(default, rename = "DriverOpts")] pub driver_opts: HashMap, /// List of all DNS names an endpoint has on a specific network. This list is based on the container name, network aliases, container short ID, and hostname. These DNS names are non-fully qualified but can contain several dots. You can get fully qualified DNS names by appending `.`. For instance, if container name is `my.ctr` and the network is named `testnet`, `DNSNames` will contain `my.ctr` and the FQDN will be `my.ctr.testnet`. #[serde(default, rename = "DNSNames")] pub dns_names: Vec, } /// EndpointIPAMConfig represents an endpoint's IPAM configuration. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct EndpointIpamConfig { #[serde(rename = "IPv4Address")] pub ipv4_address: Option, #[serde(rename = "IPv6Address")] pub ipv6_address: Option, #[serde(default, rename = "LinkLocalIPs")] pub link_local_ips: Vec, } #[typeshare] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ContainerStats { #[serde(alias = "Name")] pub name: String, #[serde(alias = "CPUPerc")] pub cpu_perc: String, #[serde(alias = "MemPerc")] pub mem_perc: String, #[serde(alias = "MemUsage")] pub mem_usage: String, #[serde(alias = "NetIO")] pub net_io: String, #[serde(alias = "BlockIO")] pub block_io: String, #[serde(alias = "PIDs")] pub pids: String, } ================================================ FILE: client/core/rs/src/entities/docker/image.rs ================================================ use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::I64; use super::{ContainerConfig, GraphDriverData}; #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ImageListItem { /// The first tag in `repo_tags`, or Id if no tags. pub name: String, /// ID is the content-addressable ID of an image. This identifier is a content-addressable digest calculated from the image's configuration (which includes the digests of layers used by the image). Note that this digest differs from the `RepoDigests` below, which holds digests of image manifests that reference the image. pub id: String, /// ID of the parent image. Depending on how the image was created, this field may be empty and is only set for images that were built/created locally. This field is empty if the image was pulled from an image registry. pub parent_id: String, /// Date and time at which the image was created as a Unix timestamp (number of seconds sinds EPOCH). pub created: I64, /// Total size of the image including all layers it is composed of. pub size: I64, /// Whether the image is in use by any container pub in_use: bool, } /// Information about an image in the local image cache. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct Image { /// ID is the content-addressable ID of an image. This identifier is a content-addressable digest calculated from the image's configuration (which includes the digests of layers used by the image). Note that this digest differs from the `RepoDigests` below, which holds digests of image manifests that reference the image. #[serde(rename = "Id")] pub id: Option, /// List of image names/tags in the local image cache that reference this image. Multiple image tags can refer to the same image, and this list may be empty if no tags reference the image, in which case the image is \"untagged\", in which case it can still be referenced by its ID. #[serde(default, rename = "RepoTags")] pub repo_tags: Vec, /// List of content-addressable digests of locally available image manifests that the image is referenced from. Multiple manifests can refer to the same image. These digests are usually only available if the image was either pulled from a registry, or if the image was pushed to a registry, which is when the manifest is generated and its digest calculated. #[serde(default, rename = "RepoDigests")] pub repo_digests: Vec, /// ID of the parent image. Depending on how the image was created, this field may be empty and is only set for images that were built/created locally. This field is empty if the image was pulled from an image registry. #[serde(rename = "Parent")] pub parent: Option, /// Optional message that was set when committing or importing the image. #[serde(rename = "Comment")] pub comment: Option, /// Date and time at which the image was created, formatted in [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. This information is only available if present in the image, and omitted otherwise. #[serde(rename = "Created")] pub created: Option, /// The version of Docker that was used to build the image. Depending on how the image was created, this field may be empty. #[serde(rename = "DockerVersion")] pub docker_version: Option, /// Name of the author that was specified when committing the image, or as specified through MAINTAINER (deprecated) in the Dockerfile. #[serde(rename = "Author")] pub author: Option, /// Configuration for a container that is portable between hosts. #[serde(rename = "Config")] #[serde(skip_serializing_if = "Option::is_none")] pub config: Option, /// Hardware CPU architecture that the image runs on. #[serde(rename = "Architecture")] pub architecture: Option, /// CPU architecture variant (presently ARM-only). #[serde(rename = "Variant")] pub variant: Option, /// Operating System the image is built to run on. #[serde(rename = "Os")] pub os: Option, /// Operating System version the image is built to run on (especially for Windows). #[serde(rename = "OsVersion")] pub os_version: Option, /// Total size of the image including all layers it is composed of. #[serde(rename = "Size")] pub size: Option, #[serde(rename = "GraphDriver")] pub graph_driver: Option, #[serde(rename = "RootFS")] pub root_fs: Option, #[serde(rename = "Metadata")] pub metadata: Option, } /// Information about the image's RootFS, including the layer IDs. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ImageInspectRootFs { #[serde(default, rename = "Type")] pub typ: String, #[serde(default, rename = "Layers")] pub layers: Vec, } /// Additional metadata of the image in the local cache. This information is local to the daemon, and not part of the image itself. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ImageInspectMetadata { /// Date and time at which the image was last tagged in [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. This information is only available if the image was tagged locally, and omitted otherwise. #[serde(rename = "LastTagTime")] pub last_tag_time: Option, } /// individual image layer information in response to ImageHistory operation #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ImageHistoryResponseItem { #[serde(rename = "Id")] pub id: String, #[serde(rename = "Created")] pub created: I64, #[serde(rename = "CreatedBy")] pub created_by: String, #[serde(default, rename = "Tags")] pub tags: Vec, #[serde(rename = "Size")] pub size: I64, #[serde(rename = "Comment")] pub comment: String, } ================================================ FILE: client/core/rs/src/entities/docker/mod.rs ================================================ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use super::I64; pub mod container; pub mod image; pub mod network; pub mod stats; pub mod volume; /// PortBinding represents a binding between a host IP address and a host port. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct PortBinding { /// Host IP address that the container's port is mapped to. #[serde(rename = "HostIp")] pub host_ip: Option, /// Host port number that the container's port is mapped to. #[serde(rename = "HostPort")] pub host_port: Option, } /// Information about the storage driver used to store the container's and image's filesystem. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct GraphDriverData { /// Name of the storage driver. #[serde(default, rename = "Name")] pub name: String, /// Low-level storage metadata, provided as key/value pairs. This information is driver-specific, and depends on the storage-driver in use, and should be used for informational purposes only. #[serde(default, rename = "Data")] pub data: HashMap, } /// Configuration for a container that is portable between hosts. When used as `ContainerConfig` field in an image, `ContainerConfig` is an optional field containing the configuration of the container that was last committed when creating the image. Previous versions of Docker builder used this field to store build cache, and it is not in active use anymore. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ContainerConfig { /// The hostname to use for the container, as a valid RFC 1123 hostname. #[serde(rename = "Hostname")] pub hostname: Option, /// The domain name to use for the container. #[serde(rename = "Domainname")] pub domainname: Option, /// The user that commands are run as inside the container. #[serde(rename = "User")] pub user: Option, /// Whether to attach to `stdin`. #[serde(rename = "AttachStdin")] pub attach_stdin: Option, /// Whether to attach to `stdout`. #[serde(rename = "AttachStdout")] pub attach_stdout: Option, /// Whether to attach to `stderr`. #[serde(rename = "AttachStderr")] pub attach_stderr: Option, /// An object mapping ports to an empty object in the form: `{\"/\": {}}` #[serde(default, rename = "ExposedPorts")] pub exposed_ports: HashMap>, /// Attach standard streams to a TTY, including `stdin` if it is not closed. #[serde(rename = "Tty")] pub tty: Option, /// Open `stdin` #[serde(rename = "OpenStdin")] pub open_stdin: Option, /// Close `stdin` after one attached client disconnects #[serde(rename = "StdinOnce")] pub stdin_once: Option, /// A list of environment variables to set inside the container in the form `[\"VAR=value\", ...]`. A variable without `=` is removed from the environment, rather than to have an empty value. #[serde(default, rename = "Env")] pub env: Vec, /// Command to run specified as a string or an array of strings. #[serde(default, rename = "Cmd")] pub cmd: Vec, #[serde(rename = "Healthcheck")] pub healthcheck: Option, /// Command is already escaped (Windows only) #[serde(rename = "ArgsEscaped")] pub args_escaped: Option, /// The name (or reference) of the image to use when creating the container, or which was used when the container was created. #[serde(rename = "Image")] pub image: Option, /// An object mapping mount point paths inside the container to empty objects. #[serde(default, rename = "Volumes")] pub volumes: HashMap>, /// The working directory for commands to run in. #[serde(rename = "WorkingDir")] pub working_dir: Option, /// The entry point for the container as a string or an array of strings. If the array consists of exactly one empty string (`[\"\"]`) then the entry point is reset to system default (i.e., the entry point used by docker when there is no `ENTRYPOINT` instruction in the `Dockerfile`). #[serde(default, rename = "Entrypoint")] pub entrypoint: Vec, /// Disable networking for the container. #[serde(rename = "NetworkDisabled")] pub network_disabled: Option, /// MAC address of the container. Deprecated: this field is deprecated in API v1.44 and up. Use EndpointSettings.MacAddress instead. #[serde(rename = "MacAddress")] pub mac_address: Option, /// `ONBUILD` metadata that were defined in the image's `Dockerfile`. #[serde(default, rename = "OnBuild")] pub on_build: Vec, /// User-defined key/value metadata. #[serde(default, rename = "Labels")] pub labels: HashMap, /// Signal to stop a container as a string or unsigned integer. #[serde(rename = "StopSignal")] pub stop_signal: Option, /// Timeout to stop a container in seconds. #[serde(rename = "StopTimeout")] pub stop_timeout: Option, /// Shell for when `RUN`, `CMD`, and `ENTRYPOINT` uses a shell. #[serde(default, rename = "Shell")] pub shell: Vec, } /// A test to perform to check that the container is healthy. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct HealthConfig { /// The test to perform. Possible values are: - `[]` inherit healthcheck from image or parent image - `[\"NONE\"]` disable healthcheck - `[\"CMD\", args...]` exec arguments directly - `[\"CMD-SHELL\", command]` run command with system's default shell #[serde(default, rename = "Test")] pub test: Vec, /// The time to wait between checks in nanoseconds. It should be 0 or at least 1000000 (1 ms). 0 means inherit. #[serde(rename = "Interval")] pub interval: Option, /// The time to wait before considering the check to have hung. It should be 0 or at least 1000000 (1 ms). 0 means inherit. #[serde(rename = "Timeout")] pub timeout: Option, /// The number of consecutive failures needed to consider a container as unhealthy. 0 means inherit. #[serde(rename = "Retries")] pub retries: Option, /// Start period for the container to initialize before starting health-retries countdown in nanoseconds. It should be 0 or at least 1000000 (1 ms). 0 means inherit. #[serde(rename = "StartPeriod")] pub start_period: Option, /// The time to wait between checks in nanoseconds during the start period. It should be 0 or at least 1000000 (1 ms). 0 means inherit. #[serde(rename = "StartInterval")] pub start_interval: Option, } ================================================ FILE: client/core/rs/src/entities/docker/network.rs ================================================ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use typeshare::typeshare; #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NetworkListItem { pub name: Option, pub id: Option, pub created: Option, pub scope: Option, pub driver: Option, pub enable_ipv6: Option, pub ipam_driver: Option, pub ipam_subnet: Option, pub ipam_gateway: Option, pub internal: Option, pub attachable: Option, pub ingress: Option, /// Whether the network is attached to one or more containers pub in_use: bool, } #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct Network { #[serde(rename = "Name")] pub name: Option, #[serde(rename = "Id")] pub id: Option, #[serde(rename = "Created")] pub created: Option, #[serde(rename = "Scope")] pub scope: Option, #[serde(rename = "Driver")] pub driver: Option, #[serde(rename = "EnableIPv6")] pub enable_ipv6: Option, #[serde(rename = "IPAM")] pub ipam: Option, #[serde(rename = "Internal")] pub internal: Option, #[serde(rename = "Attachable")] pub attachable: Option, #[serde(rename = "Ingress")] pub ingress: Option, /// This field is turned from map into array for easier usability. #[serde(rename = "Containers")] pub containers: Vec, #[serde(default, rename = "Options")] pub options: HashMap, #[serde(default, rename = "Labels")] pub labels: HashMap, } #[typeshare] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct Ipam { /// Name of the IPAM driver to use. #[serde(rename = "Driver")] pub driver: Option, /// List of IPAM configuration options, specified as a map: ``` {\"Subnet\": , \"IPRange\": , \"Gateway\": , \"AuxAddress\": } ``` #[serde(rename = "Config")] pub config: Vec, /// Driver-specific options, specified as a map. #[serde(rename = "Options")] pub options: HashMap, } #[typeshare] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct IpamConfig { #[serde(rename = "Subnet")] pub subnet: Option, #[serde(rename = "IPRange")] pub ip_range: Option, #[serde(rename = "Gateway")] pub gateway: Option, #[serde(rename = "AuxiliaryAddresses")] pub auxiliary_addresses: HashMap, } #[typeshare] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct NetworkContainer { /// This is the key on the incoming map of NetworkContainer #[serde(default, rename = "ContainerID")] pub container_id: String, #[serde(rename = "Name")] pub name: Option, #[serde(rename = "EndpointID")] pub endpoint_id: Option, #[serde(rename = "MacAddress")] pub mac_address: Option, #[serde(rename = "IPv4Address")] pub ipv4_address: Option, #[serde(rename = "IPv6Address")] pub ipv6_address: Option, } ================================================ FILE: client/core/rs/src/entities/docker/stats.rs ================================================ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::U64; /// Statistics sample for a container. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct FullContainerStats { /// Name of the container pub name: String, /// ID of the container pub id: Option, /// Date and time at which this sample was collected. /// The value is formatted as [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) with nano-seconds. pub read: Option, /// Date and time at which this first sample was collected. /// This field is not propagated if the \"one-shot\" option is set. /// If the \"one-shot\" option is set, this field may be omitted, empty, /// or set to a default date (`0001-01-01T00:00:00Z`). /// The value is formatted as [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) with nano-seconds. pub preread: Option, /// PidsStats contains Linux-specific stats of a container's process-IDs (PIDs). /// This type is Linux-specific and omitted for Windows containers. pub pids_stats: Option, /// BlkioStats stores all IO service stats for data read and write. /// This type is Linux-specific and holds many fields that are specific to cgroups v1. /// On a cgroup v2 host, all fields other than `io_service_bytes_recursive` are omitted or `null`. /// This type is only populated on Linux and omitted for Windows containers. pub blkio_stats: Option, /// The number of processors on the system. /// This field is Windows-specific and always zero for Linux containers. pub num_procs: Option, #[serde(rename = "storage_stats")] #[serde(skip_serializing_if = "Option::is_none")] pub storage_stats: Option, #[serde(rename = "cpu_stats")] #[serde(skip_serializing_if = "Option::is_none")] pub cpu_stats: Option, #[serde(rename = "precpu_stats")] #[serde(skip_serializing_if = "Option::is_none")] pub precpu_stats: Option, #[serde(rename = "memory_stats")] #[serde(skip_serializing_if = "Option::is_none")] pub memory_stats: Option, /// Network statistics for the container per interface. This field is omitted if the container has no networking enabled. #[serde(rename = "networks")] #[serde(skip_serializing_if = "Option::is_none")] pub networks: Option>, } /// PidsStats contains Linux-specific stats of a container's process-IDs (PIDs). This type is Linux-specific and omitted for Windows containers. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ContainerPidsStats { /// Current is the number of PIDs in the cgroup. pub current: Option, /// Limit is the hard limit on the number of pids in the cgroup. A \"Limit\" of 0 means that there is no limit. pub limit: Option, } /// BlkioStats stores all IO service stats for data read and write. /// This type is Linux-specific and holds many fields that are specific to cgroups v1. /// On a cgroup v2 host, all fields other than `io_service_bytes_recursive` are omitted or `null`. /// This type is only populated on Linux and omitted for Windows containers. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ContainerBlkioStats { #[serde(rename = "io_service_bytes_recursive")] #[serde(skip_serializing_if = "Option::is_none")] pub io_service_bytes_recursive: Option>, /// This field is only available when using Linux containers with cgroups v1. /// It is omitted or `null` when using cgroups v2. #[serde(rename = "io_serviced_recursive")] #[serde(skip_serializing_if = "Option::is_none")] pub io_serviced_recursive: Option>, /// This field is only available when using Linux containers with cgroups v1. /// It is omitted or `null` when using cgroups v2. #[serde(rename = "io_queue_recursive")] #[serde(skip_serializing_if = "Option::is_none")] pub io_queue_recursive: Option>, /// This field is only available when using Linux containers with cgroups v1. /// It is omitted or `null` when using cgroups v2. #[serde(rename = "io_service_time_recursive")] #[serde(skip_serializing_if = "Option::is_none")] pub io_service_time_recursive: Option>, /// This field is only available when using Linux containers with cgroups v1. /// It is omitted or `null` when using cgroups v2. #[serde(rename = "io_wait_time_recursive")] #[serde(skip_serializing_if = "Option::is_none")] pub io_wait_time_recursive: Option>, /// This field is only available when using Linux containers with cgroups v1. /// It is omitted or `null` when using cgroups v2. #[serde(rename = "io_merged_recursive")] #[serde(skip_serializing_if = "Option::is_none")] pub io_merged_recursive: Option>, /// This field is only available when using Linux containers with cgroups v1. /// It is omitted or `null` when using cgroups v2. #[serde(rename = "io_time_recursive")] #[serde(skip_serializing_if = "Option::is_none")] pub io_time_recursive: Option>, /// This field is only available when using Linux containers with cgroups v1. /// It is omitted or `null` when using cgroups v2. #[serde(rename = "sectors_recursive")] #[serde(skip_serializing_if = "Option::is_none")] pub sectors_recursive: Option>, } /// Blkio stats entry. This type is Linux-specific and omitted for Windows containers. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ContainerBlkioStatEntry { pub major: Option, pub minor: Option, pub op: Option, pub value: Option, } /// StorageStats is the disk I/O stats for read/write on Windows. /// This type is Windows-specific and omitted for Linux containers. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ContainerStorageStats { pub read_count_normalized: Option, pub read_size_bytes: Option, pub write_count_normalized: Option, pub write_size_bytes: Option, } /// CPU related info of the container #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ContainerCpuStats { /// All CPU stats aggregated since container inception. pub cpu_usage: Option, /// System Usage. /// This field is Linux-specific and omitted for Windows containers. pub system_cpu_usage: Option, /// Number of online CPUs. /// This field is Linux-specific and omitted for Windows containers. pub online_cpus: Option, /// CPU throttling stats of the container. /// This type is Linux-specific and omitted for Windows containers. pub throttling_data: Option, } /// All CPU stats aggregated since container inception. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ContainerCpuUsage { /// Total CPU time consumed in nanoseconds (Linux) or 100's of nanoseconds (Windows). pub total_usage: Option, /// Total CPU time (in nanoseconds) consumed per core (Linux). /// This field is Linux-specific when using cgroups v1. /// It is omitted when using cgroups v2 and Windows containers. pub percpu_usage: Option>, /// Time (in nanoseconds) spent by tasks of the cgroup in kernel mode (Linux), /// or time spent (in 100's of nanoseconds) by all container processes in kernel mode (Windows). /// Not populated for Windows containers using Hyper-V isolation. pub usage_in_kernelmode: Option, /// Time (in nanoseconds) spent by tasks of the cgroup in user mode (Linux), /// or time spent (in 100's of nanoseconds) by all container processes in kernel mode (Windows). /// Not populated for Windows containers using Hyper-V isolation. pub usage_in_usermode: Option, } /// CPU throttling stats of the container. /// This type is Linux-specific and omitted for Windows containers. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ContainerThrottlingData { /// Number of periods with throttling active. pub periods: Option, /// Number of periods when the container hit its throttling limit. pub throttled_periods: Option, /// Aggregated time (in nanoseconds) the container was throttled for. pub throttled_time: Option, } /// Aggregates all memory stats since container inception on Linux. /// Windows returns stats for commit and private working set only. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ContainerMemoryStats { /// Current `res_counter` usage for memory. /// This field is Linux-specific and omitted for Windows containers. pub usage: Option, /// Maximum usage ever recorded. /// This field is Linux-specific and only supported on cgroups v1. /// It is omitted when using cgroups v2 and for Windows containers. pub max_usage: Option, /// All the stats exported via memory.stat. when using cgroups v2. /// This field is Linux-specific and omitted for Windows containers. pub stats: Option>, /// Number of times memory usage hits limits. This field is Linux-specific and only supported on cgroups v1. It is omitted when using cgroups v2 and for Windows containers. pub failcnt: Option, /// This field is Linux-specific and omitted for Windows containers. pub limit: Option, /// Committed bytes. /// This field is Windows-specific and omitted for Linux containers. pub commitbytes: Option, /// Peak committed bytes. /// This field is Windows-specific and omitted for Linux containers. pub commitpeakbytes: Option, /// Private working set. /// This field is Windows-specific and omitted for Linux containers. pub privateworkingset: Option, } /// Aggregates the network stats of one container #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ContainerNetworkStats { /// Bytes received. Windows and Linux. pub rx_bytes: Option, /// Packets received. Windows and Linux. pub rx_packets: Option, /// Received errors. Not used on Windows. /// This field is Linux-specific and always zero for Windows containers. pub rx_errors: Option, /// Incoming packets dropped. Windows and Linux. pub rx_dropped: Option, /// Bytes sent. Windows and Linux. pub tx_bytes: Option, /// Packets sent. Windows and Linux. pub tx_packets: Option, /// Sent errors. Not used on Windows. /// This field is Linux-specific and always zero for Windows containers. pub tx_errors: Option, /// Outgoing packets dropped. Windows and Linux. pub tx_dropped: Option, /// Endpoint ID. Not used on Linux. /// This field is Windows-specific and omitted for Linux containers. pub endpoint_id: Option, /// Instance ID. Not used on Linux. /// This field is Windows-specific and omitted for Linux containers. pub instance_id: Option, } ================================================ FILE: client/core/rs/src/entities/docker/volume.rs ================================================ use std::collections::HashMap; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{I64, U64}; use super::PortBinding; #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct VolumeListItem { /// The name of the volume pub name: String, pub driver: String, pub mountpoint: String, pub created: Option, pub scope: VolumeScopeEnum, /// Amount of disk space used by the volume (in bytes). This information is only available for volumes created with the `\"local\"` volume driver. For volumes created with other volume drivers, this field is set to `-1` (\"not available\") pub size: Option, /// Whether the volume is currently attached to any container pub in_use: bool, } #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct Volume { /// Name of the volume. #[serde(rename = "Name")] pub name: String, /// Name of the volume driver used by the volume. #[serde(rename = "Driver")] pub driver: String, /// Mount path of the volume on the host. #[serde(rename = "Mountpoint")] pub mountpoint: String, /// Date/Time the volume was created. #[serde(rename = "CreatedAt")] pub created_at: Option, /// Low-level details about the volume, provided by the volume driver. Details are returned as a map with key/value pairs: `{\"key\":\"value\",\"key2\":\"value2\"}`. The `Status` field is optional, and is omitted if the volume driver does not support this feature. #[serde(default, rename = "Status")] pub status: HashMap>, /// User-defined key/value metadata. #[serde(default, rename = "Labels")] pub labels: HashMap, /// The level at which the volume exists. Either `global` for cluster-wide, or `local` for machine level. #[serde(default, rename = "Scope")] pub scope: VolumeScopeEnum, #[serde(rename = "ClusterVolume")] pub cluster_volume: Option, /// The driver specific options used when creating the volume. #[serde(default, rename = "Options")] pub options: HashMap, #[serde(rename = "UsageData")] pub usage_data: Option, } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize, Eq, Ord, Default, )] pub enum VolumeScopeEnum { #[default] #[serde(rename = "")] Empty, #[serde(rename = "local")] Local, #[serde(rename = "global")] Global, } /// Options and information specific to, and only present on, Swarm CSI cluster volumes. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ClusterVolume { /// The Swarm ID of this volume. Because cluster volumes are Swarm objects, they have an ID, unlike non-cluster volumes. This ID can be used to refer to the Volume instead of the name. #[serde(rename = "ID")] pub id: Option, #[serde(rename = "Version")] pub version: Option, #[serde(rename = "CreatedAt")] pub created_at: Option, #[serde(rename = "UpdatedAt")] pub updated_at: Option, #[serde(rename = "Spec")] pub spec: Option, #[serde(rename = "Info")] pub info: Option, /// The status of the volume as it pertains to its publishing and use on specific nodes #[serde(default, rename = "PublishStatus")] pub publish_status: Vec, } /// Information about the global status of the volume. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ClusterVolumeInfo { /// The capacity of the volume in bytes. A value of 0 indicates that the capacity is unknown. #[serde(rename = "CapacityBytes")] pub capacity_bytes: Option, /// A map of strings to strings returned from the storage plugin when the volume is created. #[serde(default, rename = "VolumeContext")] pub volume_context: HashMap, /// The ID of the volume as returned by the CSI storage plugin. This is distinct from the volume's ID as provided by Docker. This ID is never used by the user when communicating with Docker to refer to this volume. If the ID is blank, then the Volume has not been successfully created in the plugin yet. #[serde(rename = "VolumeID")] pub volume_id: Option, /// The topology this volume is actually accessible from. #[serde(default, rename = "AccessibleTopology")] pub accessible_topology: Vec, } #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ClusterVolumePublishStatus { /// The ID of the Swarm node the volume is published on. #[serde(rename = "NodeID")] pub node_id: Option, /// The published state of the volume. * `pending-publish` The volume should be published to this node, but the call to the controller plugin to do so has not yet been successfully completed. * `published` The volume is published successfully to the node. * `pending-node-unpublish` The volume should be unpublished from the node, and the manager is awaiting confirmation from the worker that it has done so. * `pending-controller-unpublish` The volume is successfully unpublished from the node, but has not yet been successfully unpublished on the controller. #[serde(default, rename = "State")] pub state: ClusterVolumePublishStatusStateEnum, /// A map of strings to strings returned by the CSI controller plugin when a volume is published. #[serde(default, rename = "PublishContext")] pub publish_context: HashMap, } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize, Eq, Ord, Default, )] pub enum ClusterVolumePublishStatusStateEnum { #[default] #[serde(rename = "")] Empty, #[serde(rename = "pending-publish")] PendingPublish, #[serde(rename = "published")] Published, #[serde(rename = "pending-node-unpublish")] PendingNodeUnpublish, #[serde(rename = "pending-controller-unpublish")] PendingControllerUnpublish, } /// The version number of the object such as node, service, etc. This is needed to avoid conflicting writes. The client must send the version number along with the modified specification when updating these objects. This approach ensures safe concurrency and determinism in that the change on the object may not be applied if the version number has changed from the last read. In other words, if two update requests specify the same base version, only one of the requests can succeed. As a result, two separate update requests that happen at the same time will not unintentionally overwrite each other. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ObjectVersion { #[serde(rename = "Index")] pub index: Option, } /// Cluster-specific options used to create the volume. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ClusterVolumeSpec { /// Group defines the volume group of this volume. Volumes belonging to the same group can be referred to by group name when creating Services. Referring to a volume by group instructs Swarm to treat volumes in that group interchangeably for the purpose of scheduling. Volumes with an empty string for a group technically all belong to the same, emptystring group. #[serde(rename = "Group")] pub group: Option, #[serde(rename = "AccessMode")] pub access_mode: Option, } /// Defines how the volume is used by tasks. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ClusterVolumeSpecAccessMode { /// The set of nodes this volume can be used on at one time. - `single` The volume may only be scheduled to one node at a time. - `multi` the volume may be scheduled to any supported number of nodes at a time. #[serde(default, rename = "Scope")] pub scope: ClusterVolumeSpecAccessModeScopeEnum, /// The number and way that different tasks can use this volume at one time. - `none` The volume may only be used by one task at a time. - `readonly` The volume may be used by any number of tasks, but they all must mount the volume as readonly - `onewriter` The volume may be used by any number of tasks, but only one may mount it as read/write. - `all` The volume may have any number of readers and writers. #[serde(default, rename = "Sharing")] pub sharing: ClusterVolumeSpecAccessModeSharingEnum, /// Swarm Secrets that are passed to the CSI storage plugin when operating on this volume. #[serde(default, rename = "Secrets")] pub secrets: Vec, #[serde(rename = "AccessibilityRequirements")] pub accessibility_requirements: Option, #[serde(rename = "CapacityRange")] pub capacity_range: Option, /// The availability of the volume for use in tasks. - `active` The volume is fully available for scheduling on the cluster - `pause` No new workloads should use the volume, but existing workloads are not stopped. - `drain` All workloads using this volume should be stopped and rescheduled, and no new ones should be started. #[serde(default, rename = "Availability")] pub availability: ClusterVolumeSpecAccessModeAvailabilityEnum, } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize, Eq, Ord, Default, )] pub enum ClusterVolumeSpecAccessModeScopeEnum { #[default] #[serde(rename = "")] Empty, #[serde(rename = "single")] Single, #[serde(rename = "multi")] Multi, } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize, Eq, Ord, Default, )] pub enum ClusterVolumeSpecAccessModeSharingEnum { #[default] #[serde(rename = "")] Empty, #[serde(rename = "none")] None, #[serde(rename = "readonly")] Readonly, #[serde(rename = "onewriter")] Onewriter, #[serde(rename = "all")] All, } /// One cluster volume secret entry. Defines a key-value pair that is passed to the plugin. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ClusterVolumeSpecAccessModeSecrets { /// Key is the name of the key of the key-value pair passed to the plugin. #[serde(rename = "Key")] pub key: Option, /// Secret is the swarm Secret object from which to read data. This can be a Secret name or ID. The Secret data is retrieved by swarm and used as the value of the key-value pair passed to the plugin. #[serde(rename = "Secret")] pub secret: Option, } /// Requirements for the accessible topology of the volume. These fields are optional. For an in-depth description of what these fields mean, see the CSI specification. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ClusterVolumeSpecAccessModeAccessibilityRequirements { /// A list of required topologies, at least one of which the volume must be accessible from. #[serde(default, rename = "Requisite")] pub requisite: Vec, /// A list of topologies that the volume should attempt to be provisioned in. #[serde(default, rename = "Preferred")] pub preferred: Vec, } #[typeshare] pub type Topology = HashMap>; /// The desired capacity that the volume should be created with. If empty, the plugin will decide the capacity. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct ClusterVolumeSpecAccessModeCapacityRange { /// The volume must be at least this big. The value of 0 indicates an unspecified minimum #[serde(rename = "RequiredBytes")] pub required_bytes: Option, /// The volume must not be bigger than this. The value of 0 indicates an unspecified maximum. #[serde(rename = "LimitBytes")] pub limit_bytes: Option, } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, PartialOrd, Serialize, Deserialize, Eq, Ord, Default, )] pub enum ClusterVolumeSpecAccessModeAvailabilityEnum { #[default] #[serde(rename = "")] Empty, #[serde(rename = "active")] Active, #[serde(rename = "pause")] Pause, #[serde(rename = "drain")] Drain, } /// Usage details about the volume. This information is used by the `GET /system/df` endpoint, and omitted in other endpoints. #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, )] pub struct VolumeUsageData { /// Amount of disk space used by the volume (in bytes). This information is only available for volumes created with the `\"local\"` volume driver. For volumes created with other volume drivers, this field is set to `-1` (\"not available\") #[serde(rename = "Size")] pub size: I64, /// The number of containers referencing this volume. This field is set to `-1` if the reference-count is not available. #[serde(rename = "RefCount")] pub ref_count: I64, } ================================================ FILE: client/core/rs/src/entities/logger.rs ================================================ use std::sync::OnceLock; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct LogConfig { /// The logging level. default: info #[serde(default)] pub level: LogLevel, /// Controls logging to stdout / stderr #[serde(default)] pub stdio: StdioLogMode, /// Use tracing-subscriber's pretty logging output option. #[serde(default)] pub pretty: bool, /// Including information about the log location (ie the function which produced the log). /// Tracing refers to this as the 'target'. #[serde(default = "default_location")] pub location: bool, /// Enable opentelemetry exporting #[serde(default)] pub otlp_endpoint: String, #[serde(default = "default_opentelemetry_service_name")] pub opentelemetry_service_name: String, } fn default_opentelemetry_service_name() -> String { String::from("Komodo") } fn default_location() -> bool { true } impl Default for LogConfig { fn default() -> Self { Self { level: Default::default(), stdio: Default::default(), pretty: Default::default(), location: default_location(), otlp_endpoint: Default::default(), opentelemetry_service_name: default_opentelemetry_service_name( ), } } } fn default_log_config() -> &'static LogConfig { static DEFAULT_LOG_CONFIG: OnceLock = OnceLock::new(); DEFAULT_LOG_CONFIG.get_or_init(Default::default) } impl LogConfig { pub fn is_default(&self) -> bool { self == default_log_config() } } #[derive( Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, )] #[serde(rename_all = "lowercase")] pub enum LogLevel { Trace, Debug, #[default] Info, Warn, Error, } impl From for tracing::Level { fn from(value: LogLevel) -> Self { match value { LogLevel::Trace => tracing::Level::TRACE, LogLevel::Debug => tracing::Level::DEBUG, LogLevel::Info => tracing::Level::INFO, LogLevel::Warn => tracing::Level::WARN, LogLevel::Error => tracing::Level::ERROR, } } } impl From for LogLevel { fn from(value: tracing::Level) -> Self { match value.as_str() { "trace" => LogLevel::Trace, "debug" => LogLevel::Debug, "info" => LogLevel::Info, "warn" => LogLevel::Warn, "error" => LogLevel::Error, _ => LogLevel::Info, } } } #[derive( Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, )] #[serde(rename_all = "lowercase")] pub enum StdioLogMode { #[default] Standard, Json, None, } ================================================ FILE: client/core/rs/src/entities/mod.rs ================================================ use std::{ path::{Path, PathBuf}, str::FromStr, }; use anyhow::Context; use async_timing_util::unix_timestamp_ms; use clap::Parser; use derive_empty_traits::EmptyTraits; use derive_variants::{EnumVariants, ExtractVariant}; use serde::{ Deserialize, Serialize, de::{Visitor, value::MapAccessDeserializer}, }; use serror::Serror; use strum::{AsRefStr, Display, EnumString}; use typeshare::typeshare; use crate::{ deserializers::file_contents_deserializer, entities::update::Log, parsers::parse_key_value_list, }; /// Subtypes of [Action][action::Action]. pub mod action; /// Subtypes of [Alert][alert::Alert]. pub mod alert; /// Subtypes of [Alerter][alerter::Alerter]. pub mod alerter; /// Subtypes of [ApiKey][api_key::ApiKey]. pub mod api_key; /// Subtypes of [Build][build::Build]. pub mod build; /// Subtypes of [Builder][builder::Builder]. pub mod builder; /// [core config][config::core] and [periphery config][config::periphery] pub mod config; /// Subtypes of [Deployment][deployment::Deployment]. pub mod deployment; /// Networks, Images, Containers. pub mod docker; /// Subtypes of [LogConfig][logger::LogConfig]. pub mod logger; /// Subtypes of [Permission][permission::Permission]. pub mod permission; /// Subtypes of [Procedure][procedure::Procedure]. pub mod procedure; /// Subtypes of [GitProviderAccount][provider::GitProviderAccount] and [DockerRegistryAccount][provider::DockerRegistryAccount] pub mod provider; /// Subtypes of [Repo][repo::Repo]. pub mod repo; /// Subtypes of [Resource][resource::Resource]. pub mod resource; /// Subtypes of [Schedule][schedule::Schedule] pub mod schedule; /// Subtypes of [Server][server::Server]. pub mod server; /// Subtypes of [Stack][stack::Stack] pub mod stack; /// Subtypes for server stats reporting. pub mod stats; /// Subtypes of [ResourceSync][sync::ResourceSync] pub mod sync; /// Subtypes of [Tag][tag::Tag]. pub mod tag; /// Subtypes of [ResourcesToml][toml::ResourcesToml]. pub mod toml; /// Subtypes of [Update][update::Update]. pub mod update; /// Subtypes of [User][user::User]. pub mod user; /// Subtypes of [UserGroup][user_group::UserGroup]. pub mod user_group; /// Subtypes of [Variable][variable::Variable] pub mod variable; #[typeshare(serialized_as = "number")] pub type I64 = i64; #[typeshare(serialized_as = "number")] pub type U64 = u64; #[typeshare(serialized_as = "number")] pub type Usize = usize; #[typeshare(serialized_as = "any")] pub type MongoDocument = bson::Document; #[typeshare(serialized_as = "any")] pub type JsonValue = serde_json::Value; #[typeshare(serialized_as = "any")] pub type JsonObject = serde_json::Map; #[typeshare(serialized_as = "MongoIdObj")] pub type MongoId = String; #[typeshare(serialized_as = "__Serror")] pub type _Serror = Serror; /// Represents an empty json object: `{}` #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Serialize, Deserialize, Parser, EmptyTraits, )] pub struct NoData {} pub trait MergePartial: Sized { type Partial; fn merge_partial(self, partial: Self::Partial) -> Self; } pub fn all_logs_success(logs: &[update::Log]) -> bool { for log in logs { if !log.success { return false; } } true } pub fn optional_string(string: impl Into) -> Option { let string = string.into(); if string.is_empty() { None } else { Some(string) } } pub fn to_general_name(name: &str) -> String { name.trim().replace('\n', "_").to_string() } pub fn to_path_compatible_name(name: &str) -> String { name.trim().replace([' ', '\n'], "_").to_string() } /// Enforce common container naming rules. /// [a-zA-Z0-9_.-] pub fn to_container_compatible_name(name: &str) -> String { name.trim().replace([' ', ',', '\n', '&'], "_").to_string() } /// Enforce common docker naming rules, such as only lowercase, and no '.'. /// These apply to: /// - Stacks (docker project name) /// - Builds (docker image name) /// - Networks /// - Volumes pub fn to_docker_compatible_name(name: &str) -> String { name .to_lowercase() .replace([' ', '.', ',', '\n', '&'], "_") .trim() .to_string() } /// Unix timestamp in milliseconds as i64 pub fn komodo_timestamp() -> i64 { unix_timestamp_ms() as i64 } #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MongoIdObj { #[serde(rename = "$oid")] pub oid: String, } #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct __Serror { pub error: String, pub trace: Vec, } #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, )] pub struct SystemCommand { #[serde(default)] pub path: String, #[serde(default, deserialize_with = "file_contents_deserializer")] pub command: String, } impl SystemCommand { pub fn command(&self) -> Option { if self.is_none() { None } else { Some(format!("cd {} && {}", self.path, self.command)) } } pub fn into_option(self) -> Option { if self.is_none() { None } else { Some(self) } } pub fn is_none(&self) -> bool { self.command.is_empty() } } #[typeshare] #[derive(Serialize, Debug, Clone, Copy, Default, PartialEq)] pub struct Version { pub major: i32, pub minor: i32, pub patch: i32, } impl<'de> Deserialize<'de> for Version { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { #[derive(Deserialize)] struct VersionInner { major: i32, minor: i32, patch: i32, } impl From for Version { fn from( VersionInner { major, minor, patch, }: VersionInner, ) -> Self { Version { major, minor, patch, } } } struct VersionVisitor; impl<'de> Visitor<'de> for VersionVisitor { type Value = Version; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!( formatter, "version string or object | example: '0.2.4' or {{ \"major\": 0, \"minor\": 2, \"patch\": 4, }}" ) } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { v.try_into() .map_err(|e| serde::de::Error::custom(format!("{e:#}"))) } fn visit_map(self, map: A) -> Result where A: serde::de::MapAccess<'de>, { Ok( VersionInner::deserialize(MapAccessDeserializer::new(map))? .into(), ) } } deserializer.deserialize_any(VersionVisitor) } } impl std::fmt::Display for Version { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_fmt(format_args!( "{}.{}.{}", self.major, self.minor, self.patch )) } } impl TryFrom<&str> for Version { type Error = anyhow::Error; fn try_from(value: &str) -> Result { let mut split = value.split('.'); let major = split .next() .context("must provide at least major version")? .parse::() .context("major version must be integer")?; let minor = split .next() .map(|minor| minor.parse::()) .transpose() .context("minor version must be integer")? .unwrap_or_default(); let patch = split .next() .map(|patch| patch.parse::()) .transpose() .context("patch version must be integer")? .unwrap_or_default(); Ok(Version { major, minor, patch, }) } } impl Version { pub fn increment(&mut self) { self.patch += 1; } pub fn is_none(&self) -> bool { self.major == 0 && self.minor == 0 && self.patch == 0 } } #[typeshare] #[derive( Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, )] pub struct EnvironmentVar { pub variable: String, pub value: String, } pub fn environment_vars_from_str( input: &str, ) -> anyhow::Result> { parse_key_value_list(input).map(|list| { list .into_iter() .map(|(variable, value)| EnvironmentVar { variable, value }) .collect() }) } #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LatestCommit { pub hash: String, pub message: String, } #[typeshare] #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct FileContents { /// The path to the file pub path: String, /// The contents of the file pub contents: String, } /// Represents a scheduled maintenance window #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct MaintenanceWindow { /// Name for the maintenance window (required) pub name: String, /// Description of what maintenance is performed (optional) #[serde(default)] pub description: String, /// The type of maintenance schedule: /// - Daily (default) /// - Weekly /// - OneTime #[serde(default)] pub schedule_type: MaintenanceScheduleType, /// For Weekly schedules: Specify the day of the week (Monday, Tuesday, etc.) #[serde(default)] pub day_of_week: String, /// For OneTime window: ISO 8601 date format (YYYY-MM-DD) #[serde(default)] pub date: String, /// Start hour in 24-hour format (0-23) (optional, defaults to 0) #[serde(default)] pub hour: u8, /// Start minute (0-59) (optional, defaults to 0) #[serde(default)] pub minute: u8, /// Duration of the maintenance window in minutes (required) pub duration_minutes: u32, /// Timezone for maintenance window specificiation. /// If empty, will use Core timezone. #[serde(default)] pub timezone: String, /// Whether this maintenance window is currently enabled #[serde(default = "default_enabled")] pub enabled: bool, } fn default_enabled() -> bool { true } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, )] pub enum DefaultRepoFolder { /// /${root_directory}/stacks Stacks, /// /${root_directory}/builds Builds, /// /${root_directory}/repos Repos, /// If the repo is only cloned /// in the core repo cache (resource sync), /// this isn't relevant. NotApplicable, } #[typeshare] #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub struct RepoExecutionArgs { /// Resource name (eg Build name, Repo name) pub name: String, /// Git provider domain. Default: `github.com` pub provider: String, /// Use https (vs http). pub https: bool, /// Configure the account used to access repo (if private) pub account: Option, /// Full repo identifier. {namespace}/{repo_name} /// Its optional to force checking and produce error if not defined. pub repo: Option, /// Git Branch. Default: `main` pub branch: String, /// Specific commit hash. Optional pub commit: Option, /// The clone destination path pub destination: Option, /// The default folder to use. /// Depends on the resource type. pub default_folder: DefaultRepoFolder, } impl RepoExecutionArgs { pub fn path(&self, root_repo_dir: &Path) -> PathBuf { match &self.destination { Some(destination) => root_repo_dir .join(to_path_compatible_name(&self.name)) .join(destination), None => root_repo_dir.join(to_path_compatible_name(&self.name)), } .components() .collect() } pub fn remote_url( &self, access_token: Option<&str>, ) -> anyhow::Result { let access_token_at = match access_token { Some(token) => match token.split_once(':') { Some((username, token)) => format!( "{}:{}@", urlencoding::encode(username.trim()), urlencoding::encode(token.trim()) ), None => { format!("token:{}@", urlencoding::encode(token.trim())) } }, None => String::new(), }; let protocol = if self.https { "https" } else { "http" }; let repo = self .repo .as_ref() .context("resource has no repo attached")?; Ok(format!( "{protocol}://{access_token_at}{}/{repo}", self.provider )) } pub fn unique_path( &self, repo_dir: &Path, ) -> anyhow::Result { let repo = self .repo .as_ref() .context("resource has no repo attached")?; let res = repo_dir .join(self.provider.replace('/', "-")) .join(repo.replace('/', "-")) .join(self.branch.replace('/', "-")) .join(self.commit.as_deref().unwrap_or("latest")) .components() .collect(); Ok(res) } } impl From<&self::stack::Stack> for RepoExecutionArgs { fn from(stack: &self::stack::Stack) -> Self { RepoExecutionArgs { name: stack.name.clone(), provider: optional_string(&stack.config.git_provider) .unwrap_or_else(|| String::from("github.com")), https: stack.config.git_https, account: optional_string(&stack.config.git_account), repo: optional_string(&stack.config.repo), branch: optional_string(&stack.config.branch) .unwrap_or_else(|| String::from("main")), commit: optional_string(&stack.config.commit), destination: optional_string(&stack.config.clone_path), default_folder: DefaultRepoFolder::Stacks, } } } impl From<&self::build::Build> for RepoExecutionArgs { fn from(build: &self::build::Build) -> RepoExecutionArgs { RepoExecutionArgs { name: build.name.clone(), provider: optional_string(&build.config.git_provider) .unwrap_or_else(|| String::from("github.com")), https: build.config.git_https, account: optional_string(&build.config.git_account), repo: optional_string(&build.config.repo), branch: optional_string(&build.config.branch) .unwrap_or_else(|| String::from("main")), commit: optional_string(&build.config.commit), destination: None, default_folder: DefaultRepoFolder::Builds, } } } impl From<&self::repo::Repo> for RepoExecutionArgs { fn from(repo: &self::repo::Repo) -> RepoExecutionArgs { RepoExecutionArgs { name: repo.name.clone(), provider: optional_string(&repo.config.git_provider) .unwrap_or_else(|| String::from("github.com")), https: repo.config.git_https, account: optional_string(&repo.config.git_account), repo: optional_string(&repo.config.repo), branch: optional_string(&repo.config.branch) .unwrap_or_else(|| String::from("main")), commit: optional_string(&repo.config.commit), destination: optional_string(&repo.config.path), default_folder: DefaultRepoFolder::Repos, } } } impl From<&self::sync::ResourceSync> for RepoExecutionArgs { fn from(sync: &self::sync::ResourceSync) -> Self { RepoExecutionArgs { name: sync.name.clone(), provider: optional_string(&sync.config.git_provider) .unwrap_or_else(|| String::from("github.com")), https: sync.config.git_https, account: optional_string(&sync.config.git_account), repo: optional_string(&sync.config.repo), branch: optional_string(&sync.config.branch) .unwrap_or_else(|| String::from("main")), commit: optional_string(&sync.config.commit), destination: None, default_folder: DefaultRepoFolder::NotApplicable, } } } #[typeshare] #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct RepoExecutionResponse { /// Response logs pub logs: Vec, /// Absolute path to the repo root on the host. pub path: PathBuf, /// Latest short commit hash, if it could be retrieved pub commit_hash: Option, /// Latest commit message, if it could be retrieved pub commit_message: Option, } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, Display, EnumString, )] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum Timelength { /// `1-sec` #[serde(rename = "1-sec")] #[strum(serialize = "1-sec")] OneSecond, /// `5-sec` #[serde(rename = "5-sec")] #[strum(serialize = "5-sec")] FiveSeconds, /// `10-sec` #[serde(rename = "10-sec")] #[strum(serialize = "10-sec")] TenSeconds, /// `15-sec` #[serde(rename = "15-sec")] #[strum(serialize = "15-sec")] FifteenSeconds, /// `30-sec` #[serde(rename = "30-sec")] #[strum(serialize = "30-sec")] ThirtySeconds, #[default] /// `1-min` #[serde(rename = "1-min")] #[strum(serialize = "1-min")] OneMinute, /// `2-min` #[serde(rename = "2-min")] #[strum(serialize = "2-min")] TwoMinutes, /// `5-min` #[serde(rename = "5-min")] #[strum(serialize = "5-min")] FiveMinutes, /// `10-min` #[serde(rename = "10-min")] #[strum(serialize = "10-min")] TenMinutes, /// `15-min` #[serde(rename = "15-min")] #[strum(serialize = "15-min")] FifteenMinutes, /// `30-min` #[serde(rename = "30-min")] #[strum(serialize = "30-min")] ThirtyMinutes, /// `1-hr` #[serde(rename = "1-hr")] #[strum(serialize = "1-hr")] OneHour, /// `2-hr` #[serde(rename = "2-hr")] #[strum(serialize = "2-hr")] TwoHours, /// `6-hr` #[serde(rename = "6-hr")] #[strum(serialize = "6-hr")] SixHours, /// `8-hr` #[serde(rename = "8-hr")] #[strum(serialize = "8-hr")] EightHours, /// `12-hr` #[serde(rename = "12-hr")] #[strum(serialize = "12-hr")] TwelveHours, /// `1-day` #[serde(rename = "1-day")] #[strum(serialize = "1-day")] OneDay, /// `3-day` #[serde(rename = "3-day")] #[strum(serialize = "3-day")] ThreeDay, /// `1-wk` #[serde(rename = "1-wk")] #[strum(serialize = "1-wk")] OneWeek, /// `2-wk` #[serde(rename = "2-wk")] #[strum(serialize = "2-wk")] TwoWeeks, /// `30-day` #[serde(rename = "30-day")] #[strum(serialize = "30-day")] ThirtyDays, } impl TryInto for Timelength { type Error = anyhow::Error; fn try_into( self, ) -> Result { async_timing_util::Timelength::from_str(&self.to_string()) .context("failed to parse timelength?") } } /// Days of the week #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, Eq, Default, EnumString, Serialize, Deserialize, )] pub enum DayOfWeek { #[default] #[serde(alias = "monday", alias = "Mon", alias = "mon")] #[strum(serialize = "monday", serialize = "Mon", serialize = "mon")] Monday, #[serde(alias = "tuesday", alias = "Tue", alias = "tue")] #[strum( serialize = "tuesday", serialize = "Tue", serialize = "tue" )] Tuesday, #[serde(alias = "wednesday", alias = "Wed", alias = "wed")] #[strum( serialize = "wednesday", serialize = "Wed", serialize = "wed" )] Wednesday, #[serde(alias = "thursday", alias = "Thurs", alias = "thurs")] #[strum( serialize = "thursday", serialize = "Thurs", serialize = "thurs" )] Thursday, #[serde(alias = "friday", alias = "Fri", alias = "fri")] #[strum(serialize = "friday", serialize = "Fri", serialize = "fri")] Friday, #[serde(alias = "saturday", alias = "Sat", alias = "sat")] #[strum( serialize = "saturday", serialize = "Sat", serialize = "sat" )] Saturday, #[serde(alias = "sunday", alias = "Sun", alias = "sun")] #[strum(serialize = "sunday", serialize = "Sun", serialize = "sun")] Sunday, } /// Types of maintenance schedules #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, Default, EnumString, Serialize, Deserialize, )] pub enum MaintenanceScheduleType { /// Daily at the specified time #[default] Daily, /// Weekly on the specified day and time Weekly, /// One-time maintenance on a specific date and time OneTime, // ISO 8601 date format (YYYY-MM-DD) } /// One representative IANA zone for each distinct base UTC offset in the tz database. /// https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. /// /// The `serde`/`strum` renames ensure the canonical identifier is used /// when serializing or parsing from a string such as `"Etc/UTC"`. #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, Default, EnumString, Serialize, Deserialize, )] pub enum IanaTimezone { /// UTC−12:00 #[serde(rename = "Etc/GMT+12")] #[strum(serialize = "Etc/GMT+12")] EtcGmtMinus12, /// UTC−11:00 #[serde(rename = "Pacific/Pago_Pago")] #[strum(serialize = "Pacific/Pago_Pago")] PacificPagoPago, /// UTC−10:00 #[serde(rename = "Pacific/Honolulu")] #[strum(serialize = "Pacific/Honolulu")] PacificHonolulu, /// UTC−09:30 #[serde(rename = "Pacific/Marquesas")] #[strum(serialize = "Pacific/Marquesas")] PacificMarquesas, /// UTC−09:00 #[serde(rename = "America/Anchorage")] #[strum(serialize = "America/Anchorage")] AmericaAnchorage, /// UTC−08:00 #[serde(rename = "America/Los_Angeles")] #[strum(serialize = "America/Los_Angeles")] AmericaLosAngeles, /// UTC−07:00 #[serde(rename = "America/Denver")] #[strum(serialize = "America/Denver")] AmericaDenver, /// UTC−06:00 #[serde(rename = "America/Chicago")] #[strum(serialize = "America/Chicago")] AmericaChicago, /// UTC−05:00 #[serde(rename = "America/New_York")] #[strum(serialize = "America/New_York")] AmericaNewYork, /// UTC−04:00 #[serde(rename = "America/Halifax")] #[strum(serialize = "America/Halifax")] AmericaHalifax, /// UTC−03:30 #[serde(rename = "America/St_Johns")] #[strum(serialize = "America/St_Johns")] AmericaStJohns, /// UTC−03:00 #[serde(rename = "America/Sao_Paulo")] #[strum(serialize = "America/Sao_Paulo")] AmericaSaoPaulo, /// UTC−02:00 #[serde(rename = "America/Noronha")] #[strum(serialize = "America/Noronha")] AmericaNoronha, /// UTC−01:00 #[serde(rename = "Atlantic/Azores")] #[strum(serialize = "Atlantic/Azores")] AtlanticAzores, /// UTC±00:00 #[default] #[serde(rename = "Etc/UTC")] #[strum(serialize = "Etc/UTC")] EtcUtc, /// UTC+01:00 #[serde(rename = "Europe/Berlin")] #[strum(serialize = "Europe/Berlin")] EuropeBerlin, /// UTC+02:00 #[serde(rename = "Europe/Bucharest")] #[strum(serialize = "Europe/Bucharest")] EuropeBucharest, /// UTC+03:00 #[serde(rename = "Europe/Moscow")] #[strum(serialize = "Europe/Moscow")] EuropeMoscow, /// UTC+03:30 #[serde(rename = "Asia/Tehran")] #[strum(serialize = "Asia/Tehran")] AsiaTehran, /// UTC+04:00 #[serde(rename = "Asia/Dubai")] #[strum(serialize = "Asia/Dubai")] AsiaDubai, /// UTC+04:30 #[serde(rename = "Asia/Kabul")] #[strum(serialize = "Asia/Kabul")] AsiaKabul, /// UTC+05:00 #[serde(rename = "Asia/Karachi")] #[strum(serialize = "Asia/Karachi")] AsiaKarachi, /// UTC+05:30 #[serde(rename = "Asia/Kolkata")] #[strum(serialize = "Asia/Kolkata")] AsiaKolkata, /// UTC+05:45 #[serde(rename = "Asia/Kathmandu")] #[strum(serialize = "Asia/Kathmandu")] AsiaKathmandu, /// UTC+06:00 #[serde(rename = "Asia/Dhaka")] #[strum(serialize = "Asia/Dhaka")] AsiaDhaka, /// UTC+06:30 #[serde(rename = "Asia/Yangon")] #[strum(serialize = "Asia/Yangon")] AsiaYangon, /// UTC+07:00 #[serde(rename = "Asia/Bangkok")] #[strum(serialize = "Asia/Bangkok")] AsiaBangkok, /// UTC+08:00 #[serde(rename = "Asia/Shanghai")] #[strum(serialize = "Asia/Shanghai")] AsiaShanghai, /// UTC+08:45 #[serde(rename = "Australia/Eucla")] #[strum(serialize = "Australia/Eucla")] AustraliaEucla, /// UTC+09:00 #[serde(rename = "Asia/Tokyo")] #[strum(serialize = "Asia/Tokyo")] AsiaTokyo, /// UTC+09:30 #[serde(rename = "Australia/Adelaide")] #[strum(serialize = "Australia/Adelaide")] AustraliaAdelaide, /// UTC+10:00 #[serde(rename = "Australia/Sydney")] #[strum(serialize = "Australia/Sydney")] AustraliaSydney, /// UTC+10:30 #[serde(rename = "Australia/Lord_Howe")] #[strum(serialize = "Australia/Lord_Howe")] AustraliaLordHowe, /// UTC+11:00 #[serde(rename = "Pacific/Port_Moresby")] #[strum(serialize = "Pacific/Port_Moresby")] PacificPortMoresby, /// UTC+12:00 #[serde(rename = "Pacific/Auckland")] #[strum(serialize = "Pacific/Auckland")] PacificAuckland, /// UTC+12:45 #[serde(rename = "Pacific/Chatham")] #[strum(serialize = "Pacific/Chatham")] PacificChatham, /// UTC+13:00 #[serde(rename = "Pacific/Tongatapu")] #[strum(serialize = "Pacific/Tongatapu")] PacificTongatapu, /// UTC+14:00 #[serde(rename = "Pacific/Kiritimati")] #[strum(serialize = "Pacific/Kiritimati")] PacificKiritimati, } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default, Display, EnumString, AsRefStr, )] pub enum Operation { // do nothing #[default] None, // server CreateServer, UpdateServer, DeleteServer, RenameServer, StartContainer, RestartContainer, PauseContainer, UnpauseContainer, StopContainer, DestroyContainer, StartAllContainers, RestartAllContainers, PauseAllContainers, UnpauseAllContainers, StopAllContainers, PruneContainers, CreateNetwork, DeleteNetwork, PruneNetworks, DeleteImage, PruneImages, DeleteVolume, PruneVolumes, PruneDockerBuilders, PruneBuildx, PruneSystem, // stack CreateStack, UpdateStack, RenameStack, DeleteStack, WriteStackContents, RefreshStackCache, PullStack, DeployStack, StartStack, RestartStack, PauseStack, UnpauseStack, StopStack, DestroyStack, RunStackService, // stack (service) DeployStackService, PullStackService, StartStackService, RestartStackService, PauseStackService, UnpauseStackService, StopStackService, DestroyStackService, // deployment CreateDeployment, UpdateDeployment, RenameDeployment, DeleteDeployment, Deploy, PullDeployment, StartDeployment, RestartDeployment, PauseDeployment, UnpauseDeployment, StopDeployment, DestroyDeployment, // build CreateBuild, UpdateBuild, RenameBuild, DeleteBuild, RunBuild, CancelBuild, WriteDockerfile, // repo CreateRepo, UpdateRepo, RenameRepo, DeleteRepo, CloneRepo, PullRepo, BuildRepo, CancelRepoBuild, // procedure CreateProcedure, UpdateProcedure, RenameProcedure, DeleteProcedure, RunProcedure, // action CreateAction, UpdateAction, RenameAction, DeleteAction, RunAction, // builder CreateBuilder, UpdateBuilder, RenameBuilder, DeleteBuilder, // alerter CreateAlerter, UpdateAlerter, RenameAlerter, DeleteAlerter, TestAlerter, SendAlert, // sync CreateResourceSync, UpdateResourceSync, RenameResourceSync, DeleteResourceSync, WriteSyncContents, CommitSync, RunSync, // maintenance ClearRepoCache, BackupCoreDatabase, GlobalAutoUpdate, // variable CreateVariable, UpdateVariableValue, DeleteVariable, // git provider CreateGitProviderAccount, UpdateGitProviderAccount, DeleteGitProviderAccount, // docker registry CreateDockerRegistryAccount, UpdateDockerRegistryAccount, DeleteDockerRegistryAccount, } #[typeshare] #[derive( Serialize, Deserialize, Debug, Default, Display, EnumString, PartialEq, Hash, Eq, Clone, Copy, )] pub enum SearchCombinator { #[default] Or, And, } #[typeshare] #[derive( Serialize, Deserialize, Debug, PartialEq, Hash, Eq, Clone, Copy, Default, Display, EnumString, )] #[serde(rename_all = "UPPERCASE")] #[strum(serialize_all = "UPPERCASE")] pub enum TerminationSignal { #[serde(alias = "1")] SigHup, #[serde(alias = "2")] SigInt, #[serde(alias = "3")] SigQuit, #[default] #[serde(alias = "15")] SigTerm, } /// Used to reference a specific resource across all resource types #[typeshare] #[derive( Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, EnumVariants, )] #[variant_derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Display, EnumString, AsRefStr )] #[serde(tag = "type", content = "id")] pub enum ResourceTarget { System(String), Server(String), Stack(String), Deployment(String), Build(String), Repo(String), Procedure(String), Action(String), Builder(String), Alerter(String), ResourceSync(String), } impl ResourceTarget { pub fn system() -> ResourceTarget { Self::System("system".to_string()) } } impl Default for ResourceTarget { fn default() -> Self { ResourceTarget::system() } } impl ResourceTarget { pub fn is_empty(&self) -> bool { match self { ResourceTarget::System(id) => id.is_empty(), ResourceTarget::Server(id) => id.is_empty(), ResourceTarget::Stack(id) => id.is_empty(), ResourceTarget::Deployment(id) => id.is_empty(), ResourceTarget::Build(id) => id.is_empty(), ResourceTarget::Repo(id) => id.is_empty(), ResourceTarget::Procedure(id) => id.is_empty(), ResourceTarget::Action(id) => id.is_empty(), ResourceTarget::Builder(id) => id.is_empty(), ResourceTarget::Alerter(id) => id.is_empty(), ResourceTarget::ResourceSync(id) => id.is_empty(), } } pub fn extract_variant_id( &self, ) -> (ResourceTargetVariant, &String) { let id = match self { ResourceTarget::System(id) => id, ResourceTarget::Server(id) => id, ResourceTarget::Stack(id) => id, ResourceTarget::Build(id) => id, ResourceTarget::Builder(id) => id, ResourceTarget::Deployment(id) => id, ResourceTarget::Repo(id) => id, ResourceTarget::Alerter(id) => id, ResourceTarget::Procedure(id) => id, ResourceTarget::Action(id) => id, ResourceTarget::ResourceSync(id) => id, }; (self.extract_variant(), id) } } impl From<&build::Build> for ResourceTarget { fn from(build: &build::Build) -> Self { Self::Build(build.id.clone()) } } impl From<&deployment::Deployment> for ResourceTarget { fn from(deployment: &deployment::Deployment) -> Self { Self::Deployment(deployment.id.clone()) } } impl From<&server::Server> for ResourceTarget { fn from(server: &server::Server) -> Self { Self::Server(server.id.clone()) } } impl From<&repo::Repo> for ResourceTarget { fn from(repo: &repo::Repo) -> Self { Self::Repo(repo.id.clone()) } } impl From<&builder::Builder> for ResourceTarget { fn from(builder: &builder::Builder) -> Self { Self::Builder(builder.id.clone()) } } impl From<&alerter::Alerter> for ResourceTarget { fn from(alerter: &alerter::Alerter) -> Self { Self::Alerter(alerter.id.clone()) } } impl From<&procedure::Procedure> for ResourceTarget { fn from(procedure: &procedure::Procedure) -> Self { Self::Procedure(procedure.id.clone()) } } impl From<&sync::ResourceSync> for ResourceTarget { fn from(resource_sync: &sync::ResourceSync) -> Self { Self::ResourceSync(resource_sync.id.clone()) } } impl From<&stack::Stack> for ResourceTarget { fn from(stack: &stack::Stack) -> Self { Self::Stack(stack.id.clone()) } } impl From<&action::Action> for ResourceTarget { fn from(action: &action::Action) -> Self { Self::Action(action.id.clone()) } } impl ResourceTargetVariant { /// These need to use snake case pub fn toml_header(&self) -> &'static str { match self { ResourceTargetVariant::System => "system", ResourceTargetVariant::Build => "build", ResourceTargetVariant::Builder => "builder", ResourceTargetVariant::Deployment => "deployment", ResourceTargetVariant::Server => "server", ResourceTargetVariant::Repo => "repo", ResourceTargetVariant::Alerter => "alerter", ResourceTargetVariant::Procedure => "procedure", ResourceTargetVariant::ResourceSync => "resource_sync", ResourceTargetVariant::Stack => "stack", ResourceTargetVariant::Action => "action", } } } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, )] pub enum ScheduleFormat { #[default] English, Cron, } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, )] #[serde(rename_all = "snake_case")] pub enum FileFormat { #[default] KeyValue, Toml, Yaml, Json, } /// Used with ExecuteTerminal to capture the exit code pub const KOMODO_EXIT_CODE: &str = "__KOMODO_EXIT_CODE:"; pub fn resource_link( host: &str, resource_type: ResourceTargetVariant, id: &str, ) -> String { let path = match resource_type { ResourceTargetVariant::System => unreachable!(), ResourceTargetVariant::Build => format!("/builds/{id}"), ResourceTargetVariant::Builder => { format!("/builders/{id}") } ResourceTargetVariant::Deployment => { format!("/deployments/{id}") } ResourceTargetVariant::Stack => { format!("/stacks/{id}") } ResourceTargetVariant::Server => { format!("/servers/{id}") } ResourceTargetVariant::Repo => format!("/repos/{id}"), ResourceTargetVariant::Alerter => { format!("/alerters/{id}") } ResourceTargetVariant::Procedure => { format!("/procedures/{id}") } ResourceTargetVariant::Action => { format!("/actions/{id}") } ResourceTargetVariant::ResourceSync => { format!("/resource-syncs/{id}") } }; format!("{host}{path}") } ================================================ FILE: client/core/rs/src/entities/permission.rs ================================================ use std::fmt::Write; use derive_variants::EnumVariants; use indexmap::IndexSet; use serde::{Deserialize, Serialize}; use strum::{ AsRefStr, Display, EnumString, IntoStaticStr, VariantArray, }; use typeshare::typeshare; use super::{MongoId, ResourceTarget}; /// Representation of a User or UserGroups permission on a resource. #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr( feature = "mongo", derive(mongo_indexed::derive::MongoIndexed) )] // To query for all permissions on user target #[cfg_attr(feature = "mongo", doc_index({ "user_target.type": 1, "user_target.id": 1 }))] // To query for all permissions on a resource target #[cfg_attr(feature = "mongo", doc_index({ "resource_target.type": 1, "resource_target.id": 1 }))] // Only one permission allowed per user / resource target #[cfg_attr(feature = "mongo", unique_doc_index({ "user_target.type": 1, "user_target.id": 1, "resource_target.type": 1, "resource_target.id": 1 }))] pub struct Permission { /// The id of the permission document #[serde( default, rename = "_id", skip_serializing_if = "String::is_empty", with = "bson::serde_helpers::hex_string_as_object_id" )] pub id: MongoId, /// The target User / UserGroup pub user_target: UserTarget, /// The target resource pub resource_target: ResourceTarget, /// The permission level for the [user_target] on the [resource_target]. #[serde(default)] pub level: PermissionLevel, /// Any specific permissions for the [user_target] on the [resource_target]. #[serde(default)] pub specific: IndexSet, } #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, EnumVariants)] #[variant_derive( Debug, Clone, Copy, Serialize, Deserialize, AsRefStr )] #[serde(tag = "type", content = "id")] pub enum UserTarget { /// User Id User(String), /// UserGroup Id UserGroup(String), } impl UserTarget { pub fn extract_variant_id(self) -> (UserTargetVariant, String) { match self { UserTarget::User(id) => (UserTargetVariant::User, id), UserTarget::UserGroup(id) => (UserTargetVariant::UserGroup, id), } } } /// The levels of permission that a User or UserGroup can have on a resource. #[typeshare] #[derive( Serialize, Deserialize, Debug, Display, EnumString, AsRefStr, Hash, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, )] pub enum PermissionLevel { /// No permissions. #[default] None, /// Can read resource information and config Read, /// Can execute actions on the resource Execute, /// Can update the resource configuration Write, } impl Default for &PermissionLevel { fn default() -> Self { &PermissionLevel::None } } /// The specific types of permission that a User or UserGroup can have on a resource. #[typeshare] #[derive( Serialize, Deserialize, Debug, Display, EnumString, AsRefStr, IntoStaticStr, VariantArray, Hash, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, )] pub enum SpecificPermission { /// On **Server** /// - Access the terminal apis /// On **Stack / Deployment** /// - Access the container exec Apis Terminal, /// On **Server** /// - Allowed to attach Stacks, Deployments, Repos, Builders to the Server /// On **Builder** /// - Allowed to attach Builds to the Builder /// On **Build** /// - Allowed to attach Deployments to the Build Attach, /// On **Server** /// - Access the `container inspect` apis /// On **Stack / Deployment** /// - Access `container inspect` apis for associated containers Inspect, /// On **Server** /// - Read all container logs on the server /// On **Stack / Deployment** /// - Read the container logs Logs, /// On **Server** /// - Read all the processes on the host Processes, } impl SpecificPermission { fn all() -> IndexSet { SpecificPermission::VARIANTS.iter().cloned().collect() } } #[typeshare] #[derive(Debug, Clone, Default)] pub struct PermissionLevelAndSpecifics { pub level: PermissionLevel, pub specific: IndexSet, } impl From for PermissionLevelAndSpecifics { fn from(level: PermissionLevel) -> Self { Self { level, specific: IndexSet::new(), } } } impl From<&Permission> for PermissionLevelAndSpecifics { fn from(value: &Permission) -> Self { Self { level: value.level, specific: value.specific.clone(), } } } impl PermissionLevel { /// Add all possible permissions (for use in admin case) pub fn all(self) -> PermissionLevelAndSpecifics { PermissionLevelAndSpecifics { level: self, specific: SpecificPermission::all(), } } pub fn specifics( self, specific: IndexSet, ) -> PermissionLevelAndSpecifics { PermissionLevelAndSpecifics { level: self, specific, } } fn specific( self, specific: SpecificPermission, ) -> PermissionLevelAndSpecifics { PermissionLevelAndSpecifics { level: self, specific: [specific].into_iter().collect(), } } /// Operation requires Terminal permission pub fn terminal(self) -> PermissionLevelAndSpecifics { self.specific(SpecificPermission::Terminal) } /// Operation requires Attach permission pub fn attach(self) -> PermissionLevelAndSpecifics { self.specific(SpecificPermission::Attach) } /// Operation requires Inspect permission pub fn inspect(self) -> PermissionLevelAndSpecifics { self.specific(SpecificPermission::Inspect) } /// Operation requires Logs permission pub fn logs(self) -> PermissionLevelAndSpecifics { self.specific(SpecificPermission::Logs) } /// Operation requires Processes permission pub fn processes(self) -> PermissionLevelAndSpecifics { self.specific(SpecificPermission::Processes) } } impl PermissionLevelAndSpecifics { /// Returns true when self.level >= other.level, /// and has all required specific permissions. pub fn fulfills( &self, other: &PermissionLevelAndSpecifics, ) -> bool { if self.level < other.level { return false; } for specific in other.specific.iter() { if !self.specific.contains(specific) { return false; } } true } /// Returns true when self has all required specific permissions. pub fn fulfills_specific( &self, specifics: &IndexSet, ) -> bool { for specific in specifics.iter() { if !self.specific.contains(specific) { return false; } } true } pub fn specifics_for_log(&self) -> String { let mut res = String::new(); for specific in self.specific.iter() { if res.is_empty() { write!(&mut res, "{specific}").unwrap(); } else { write!(&mut res, ", {specific}").unwrap(); } } res } pub fn specifics( mut self, specific: IndexSet, ) -> PermissionLevelAndSpecifics { self.specific = specific; self } fn specific( mut self, specific: SpecificPermission, ) -> PermissionLevelAndSpecifics { self.specific.insert(specific); PermissionLevelAndSpecifics { level: self.level, specific: self.specific, } } /// Operation requires Terminal permission pub fn terminal(self) -> PermissionLevelAndSpecifics { self.specific(SpecificPermission::Terminal) } /// Operation requires Attach permission pub fn attach(self) -> PermissionLevelAndSpecifics { self.specific(SpecificPermission::Attach) } /// Operation requires Inspect permission pub fn inspect(self) -> PermissionLevelAndSpecifics { self.specific(SpecificPermission::Inspect) } /// Operation requires Logs permission pub fn logs(self) -> PermissionLevelAndSpecifics { self.specific(SpecificPermission::Logs) } /// Operation requires Processes permission pub fn processes(self) -> PermissionLevelAndSpecifics { self.specific(SpecificPermission::Processes) } } ================================================ FILE: client/core/rs/src/entities/procedure.rs ================================================ use bson::Document; use derive_builder::Builder; use derive_default_builder::DefaultBuilder; use partial_derive2::Partial; use serde::{Deserialize, Serialize}; use strum::Display; use typeshare::typeshare; use crate::api::execute::Execution; use super::{ I64, ScheduleFormat, resource::{Resource, ResourceListItem, ResourceQuery}, }; #[typeshare] pub type ProcedureListItem = ResourceListItem; #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ProcedureListItemInfo { /// Number of stages procedure has. pub stages: I64, /// Reflect whether last run successful / currently running. pub state: ProcedureState, /// Procedure last successful run timestamp in ms. pub last_run_at: Option, /// If the procedure has schedule enabled, this is the /// next scheduled run time in unix ms. pub next_scheduled_run: Option, /// If there is an error parsing schedule expression, /// it will be given here. pub schedule_error: Option, } #[typeshare] #[derive( Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Display, )] pub enum ProcedureState { /// Currently running Running, /// Last run successful Ok, /// Last run failed Failed, /// Other case (never run) #[default] Unknown, } /// Procedures run a series of stages sequentially, where /// each stage runs executions in parallel. #[typeshare] pub type Procedure = Resource; #[typeshare(serialized_as = "Partial")] pub type _PartialProcedureConfig = PartialProcedureConfig; /// Config for the [Procedure] #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, Partial, Builder)] #[partial_derive(Debug, Clone, Default, Serialize, Deserialize)] #[partial(skip_serializing_none, from, diff)] pub struct ProcedureConfig { /// The stages to be run by the procedure. #[serde(default, alias = "stage")] #[partial_attr(serde(alias = "stage"))] #[builder(default)] pub stages: Vec, /// Choose whether to specify schedule as regular CRON, or using the english to CRON parser. #[serde(default)] #[builder(default)] pub schedule_format: ScheduleFormat, /// Optionally provide a schedule for the procedure to run on. /// /// There are 2 ways to specify a schedule: /// /// 1. Regular CRON expression: /// /// (second, minute, hour, day, month, day-of-week) /// ```text /// 0 0 0 1,15 * ? /// ``` /// /// 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron): /// /// ```text /// at midnight on the 1st and 15th of the month /// ``` #[serde(default)] #[builder(default)] pub schedule: String, /// Whether schedule is enabled if one is provided. /// Can be used to temporarily disable the schedule. #[serde(default = "default_schedule_enabled")] #[builder(default = "default_schedule_enabled()")] #[partial_default(default_schedule_enabled())] pub schedule_enabled: bool, /// Optional. A TZ Identifier. If not provided, will use Core local timezone. /// https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. #[serde(default)] #[builder(default)] pub schedule_timezone: String, /// Whether to send alerts when the schedule was run. #[serde(default = "default_schedule_alert")] #[builder(default = "default_schedule_alert()")] #[partial_default(default_schedule_alert())] pub schedule_alert: bool, /// Whether to send alerts when this procedure fails. #[serde(default = "default_failure_alert")] #[builder(default = "default_failure_alert()")] #[partial_default(default_failure_alert())] pub failure_alert: bool, /// Whether incoming webhooks actually trigger action. #[serde(default = "default_webhook_enabled")] #[builder(default = "default_webhook_enabled()")] #[partial_default(default_webhook_enabled())] pub webhook_enabled: bool, /// Optionally provide an alternate webhook secret for this procedure. /// If its an empty string, use the default secret from the config. #[serde(default)] #[builder(default)] pub webhook_secret: String, } impl ProcedureConfig { pub fn builder() -> ProcedureConfigBuilder { ProcedureConfigBuilder::default() } } fn default_schedule_enabled() -> bool { true } fn default_schedule_alert() -> bool { true } fn default_failure_alert() -> bool { true } fn default_webhook_enabled() -> bool { true } impl Default for ProcedureConfig { fn default() -> Self { Self { stages: Default::default(), schedule_format: Default::default(), schedule: Default::default(), schedule_enabled: default_schedule_enabled(), schedule_timezone: Default::default(), schedule_alert: default_schedule_alert(), failure_alert: default_failure_alert(), webhook_enabled: default_webhook_enabled(), webhook_secret: Default::default(), } } } /// A single stage of a procedure. Runs a list of executions in parallel. #[typeshare] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ProcedureStage { /// A name for the procedure pub name: String, /// Whether the stage should be run as part of the procedure. #[serde(default = "default_enabled")] pub enabled: bool, /// The executions in the stage #[serde(default, alias = "execution")] pub executions: Vec, } /// Allows to enable / disabled procedures in the sequence / parallel vec on the fly #[typeshare] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct EnabledExecution { /// The execution request to run. pub execution: Execution, /// Whether the execution is enabled to run in the procedure. #[serde(default = "default_enabled")] pub enabled: bool, } fn default_enabled() -> bool { true } #[typeshare] #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] pub struct ProcedureActionState { pub running: bool, } // QUERY #[typeshare] pub type ProcedureQuery = ResourceQuery; #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, DefaultBuilder, )] pub struct ProcedureQuerySpecifics {} impl super::resource::AddFilters for ProcedureQuerySpecifics { fn add_filters(&self, _: &mut Document) {} } ================================================ FILE: client/core/rs/src/entities/provider.rs ================================================ use partial_derive2::Partial; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use super::MongoId; #[typeshare(serialized_as = "Partial")] pub type _PartialGitProviderAccount = PartialGitProviderAccount; /// Configuration to access private git repos from various git providers. /// Note. Cannot create two accounts with the same domain and username. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default, Partial)] #[partial_derive(Serialize, Deserialize, Debug, Clone, Default)] #[partial(skip_serializing_none, from, diff)] #[cfg_attr( feature = "mongo", derive(mongo_indexed::derive::MongoIndexed) )] #[cfg_attr(feature = "mongo", unique_doc_index({ "domain": 1, "username": 1 }))] pub struct GitProviderAccount { /// The Mongo ID of the git provider account. /// This field is de/serialized from/to JSON as /// `{ "_id": { "$oid": "..." }, ...(rest of serialized User) }` #[serde( default, rename = "_id", skip_serializing_if = "String::is_empty", with = "bson::serde_helpers::hex_string_as_object_id" )] pub id: MongoId, /// The domain of the provider. /// /// For git, this cannot include the protocol eg 'http://', /// which is controlled with 'https' field. #[cfg_attr(feature = "mongo", index)] #[serde(default = "default_git_domain")] #[partial_default(default_git_domain())] pub domain: String, /// Whether git provider is accessed over http or https. #[serde(default = "default_https")] #[partial_default(default_https())] pub https: bool, /// The account username #[cfg_attr(feature = "mongo", index)] #[serde(default)] pub username: String, /// The token in plain text on the db. /// If the database / host can be accessed this is insecure. #[serde(default)] pub token: String, } fn default_git_domain() -> String { String::from("github.com") } fn default_https() -> bool { true } #[typeshare(serialized_as = "Partial")] pub type _PartialDockerRegistryAccount = PartialDockerRegistryAccount; /// Configuration to access private image repositories on various registries. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default, Partial)] #[partial_derive(Serialize, Deserialize, Debug, Clone, Default)] #[partial(skip_serializing_none, from, diff)] #[cfg_attr( feature = "mongo", derive(mongo_indexed::derive::MongoIndexed) )] #[cfg_attr(feature = "mongo", unique_doc_index({ "domain": 1, "username": 1 }))] pub struct DockerRegistryAccount { /// The Mongo ID of the docker registry account. /// This field is de/serialized from/to JSON as /// `{ "_id": { "$oid": "..." }, ...(rest of DockerRegistryAccount) }` #[serde( default, rename = "_id", skip_serializing_if = "String::is_empty", with = "bson::serde_helpers::hex_string_as_object_id" )] pub id: MongoId, /// The domain of the provider. /// /// For docker registry, this can include 'http://...', /// however this is not recommended and won't work unless "insecure registries" are enabled /// on your hosts. See . #[cfg_attr(feature = "mongo", index)] #[serde(default = "default_registry_domain")] #[partial_default(default_registry_domain())] pub domain: String, /// The account username #[cfg_attr(feature = "mongo", index)] #[serde(default)] pub username: String, /// The token in plain text on the db. /// If the database / host can be accessed this is insecure. #[serde(default)] pub token: String, } fn default_registry_domain() -> String { String::from("docker.io") } ================================================ FILE: client/core/rs/src/entities/repo.rs ================================================ use anyhow::Context; use bson::{Document, doc}; use derive_builder::Builder; use derive_default_builder::DefaultBuilder; use partial_derive2::Partial; use serde::{Deserialize, Serialize}; use strum::Display; use typeshare::typeshare; use crate::{ deserializers::{ env_vars_deserializer, option_env_vars_deserializer, option_string_list_deserializer, string_list_deserializer, }, entities::I64, }; use super::{ EnvironmentVar, SystemCommand, environment_vars_from_str, resource::{Resource, ResourceListItem, ResourceQuery}, }; #[typeshare] pub type RepoListItem = ResourceListItem; #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct RepoListItemInfo { /// The server that repo sits on. pub server_id: String, /// The builder that builds the repo. pub builder_id: String, /// Repo last cloned / pulled timestamp in ms. pub last_pulled_at: I64, /// Repo last built timestamp in ms. pub last_built_at: I64, /// The git provider domain pub git_provider: String, /// The configured repo pub repo: String, /// The configured branch pub branch: String, /// Full link to the repo. pub repo_link: String, /// The repo state pub state: RepoState, /// If the repo is cloned, will be the cloned short commit hash. pub cloned_hash: Option, /// If the repo is cloned, will be the cloned commit message. pub cloned_message: Option, /// If the repo is built, will be the latest built short commit hash. pub built_hash: Option, /// Will be the latest remote short commit hash. pub latest_hash: Option, } #[typeshare] #[derive( Debug, Clone, Copy, Default, Serialize, Deserialize, Display, )] pub enum RepoState { /// Unknown case #[default] Unknown, /// Last clone / pull successful (or never cloned) Ok, /// Last clone / pull failed Failed, /// Currently cloning Cloning, /// Currently pulling Pulling, /// Currently building Building, } #[typeshare] pub type Repo = Resource; #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct RepoInfo { /// When repo was last pulled #[serde(default)] pub last_pulled_at: I64, /// When repo was last built #[serde(default)] pub last_built_at: I64, /// Latest built short commit hash, or null. pub built_hash: Option, /// Latest built commit message, or null. Only for repo based stacks pub built_message: Option, /// Latest remote short commit hash, or null. pub latest_hash: Option, /// Latest remote commit message, or null pub latest_message: Option, } #[typeshare(serialized_as = "Partial")] pub type _PartialRepoConfig = PartialRepoConfig; #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Builder, Partial)] #[partial_derive(Serialize, Deserialize, Debug, Clone, Default)] #[partial(skip_serializing_none, from, diff)] pub struct RepoConfig { /// The server to clone the repo on. #[serde(default, alias = "server")] #[partial_attr(serde(alias = "server"))] #[builder(default)] pub server_id: String, /// Attach a builder to 'build' the repo. #[serde(default, alias = "builder")] #[partial_attr(serde(alias = "builder"))] #[builder(default)] pub builder_id: String, /// The git provider domain. Default: github.com #[serde(default = "default_git_provider")] #[builder(default = "default_git_provider()")] #[partial_default(default_git_provider())] pub git_provider: String, /// Whether to use https to clone the repo (versus http). Default: true /// /// Note. Komodo does not currently support cloning repos via ssh. #[serde(default = "default_git_https")] #[builder(default = "default_git_https()")] #[partial_default(default_git_https())] pub git_https: bool, /// The git account used to access private repos. /// Passing empty string can only clone public repos. /// /// Note. A token for the account must be available in the core config or the builder server's periphery config /// for the configured git provider. #[serde(default)] #[builder(default)] pub git_account: String, /// The github repo to clone. #[serde(default)] #[builder(default)] pub repo: String, /// The repo branch. #[serde(default = "default_branch")] #[builder(default = "default_branch()")] #[partial_default(default_branch())] pub branch: String, /// Optionally set a specific commit hash. #[serde(default)] #[builder(default)] pub commit: String, /// Explicitly specify the folder to clone the repo in. /// - If absolute (has leading '/') /// - Used directly as the path /// - If relative /// - Taken relative to Periphery `repo_dir` (ie `${root_directory}/repos`) #[serde(default)] #[builder(default)] pub path: String, /// Whether incoming webhooks actually trigger action. #[serde(default = "default_webhook_enabled")] #[builder(default = "default_webhook_enabled()")] #[partial_default(default_webhook_enabled())] pub webhook_enabled: bool, /// Optionally provide an alternate webhook secret for this repo. /// If its an empty string, use the default secret from the config. #[serde(default)] #[builder(default)] pub webhook_secret: String, /// Command to be run after the repo is cloned. /// The path is relative to the root of the repo. #[serde(default)] #[builder(default)] pub on_clone: SystemCommand, /// Command to be run after the repo is pulled. /// The path is relative to the root of the repo. #[serde(default)] #[builder(default)] pub on_pull: SystemCommand, /// Configure quick links that are displayed in the resource header #[serde(default, deserialize_with = "string_list_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_string_list_deserializer" ))] #[builder(default)] pub links: Vec, /// The environment variables passed to the compose file. /// They will be written to path defined in env_file_path, /// which is given relative to the run directory. /// /// If it is empty, no file will be written. #[serde(default, deserialize_with = "env_vars_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_env_vars_deserializer" ))] #[builder(default)] pub environment: String, /// The name of the written environment file before `docker compose up`. /// Relative to the repo root. /// Default: .env #[serde(default = "default_env_file_path")] #[builder(default = "default_env_file_path()")] #[partial_default(default_env_file_path())] pub env_file_path: String, /// Whether to skip secret interpolation into the repo environment variable file. #[serde(default)] #[builder(default)] pub skip_secret_interp: bool, } impl RepoConfig { pub fn builder() -> RepoConfigBuilder { RepoConfigBuilder::default() } pub fn env_vars(&self) -> anyhow::Result> { environment_vars_from_str(&self.environment) .context("Invalid environment") } } fn default_git_provider() -> String { String::from("github.com") } fn default_git_https() -> bool { true } fn default_branch() -> String { String::from("main") } fn default_env_file_path() -> String { String::from(".env") } fn default_webhook_enabled() -> bool { true } impl Default for RepoConfig { fn default() -> Self { Self { server_id: Default::default(), builder_id: Default::default(), git_provider: default_git_provider(), git_https: default_git_https(), repo: Default::default(), branch: default_branch(), commit: Default::default(), git_account: Default::default(), path: Default::default(), on_clone: Default::default(), on_pull: Default::default(), links: Default::default(), environment: Default::default(), env_file_path: default_env_file_path(), skip_secret_interp: Default::default(), webhook_enabled: default_webhook_enabled(), webhook_secret: Default::default(), } } } #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)] pub struct RepoActionState { /// Whether Repo currently cloning on the attached Server pub cloning: bool, /// Whether Repo currently pulling on the attached Server pub pulling: bool, /// Whether Repo currently building using the attached Builder. pub building: bool, /// Whether Repo currently renaming. pub renaming: bool, } #[typeshare] pub type RepoQuery = ResourceQuery; #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, DefaultBuilder, )] pub struct RepoQuerySpecifics { /// Filter repos by their repo. pub repos: Vec, } impl super::resource::AddFilters for RepoQuerySpecifics { fn add_filters(&self, filters: &mut Document) { if !self.repos.is_empty() { filters.insert("config.repo", doc! { "$in": &self.repos }); } } } ================================================ FILE: client/core/rs/src/entities/resource.rs ================================================ use bson::{Document, doc}; use clap::ValueEnum; use derive_builder::Builder; use derive_default_builder::DefaultBuilder; use serde::{Deserialize, Serialize}; use strum::Display; use typeshare::typeshare; use crate::{ deserializers::string_list_deserializer, entities::{I64, MongoId}, }; use super::{ ResourceTargetVariant, permission::PermissionLevelAndSpecifics, }; #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, Builder)] pub struct Resource { /// The Mongo ID of the resource. /// This field is de/serialized from/to JSON as /// `{ "_id": { "$oid": "..." }, ...(rest of serialized Resource) }` #[serde( default, rename = "_id", skip_serializing_if = "String::is_empty", with = "bson::serde_helpers::hex_string_as_object_id" )] #[builder(setter(skip))] pub id: MongoId, /// The resource name. /// This is guaranteed unique among others of the same resource type. pub name: String, /// A description for the resource #[serde(default)] #[builder(default)] pub description: String, /// Mark resource as a template #[serde(default)] #[builder(default)] pub template: bool, /// Tag Ids #[serde(default, deserialize_with = "string_list_deserializer")] #[builder(default)] pub tags: Vec, /// Resource-specific information (not user configurable). #[serde(default)] #[builder(setter(skip))] pub info: Info, /// Resource-specific configuration. #[serde(default)] #[builder(default)] pub config: Config, /// Set a base permission level that all users will have on the /// resource. #[serde(default)] #[builder(default)] pub base_permission: PermissionLevelAndSpecifics, /// When description last updated #[serde(default)] #[builder(setter(skip))] pub updated_at: I64, } impl Default for Resource { fn default() -> Self { Self { id: String::new(), name: String::from("temp-resource"), description: String::new(), template: Default::default(), tags: Vec::new(), info: I::default(), config: C::default(), base_permission: Default::default(), updated_at: 0, } } } #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ResourceListItem { /// The resource id pub id: String, /// The resource type, ie `Server` or `Deployment` #[serde(rename = "type")] pub resource_type: ResourceTargetVariant, /// The resource name pub name: String, /// Whether resource is a template pub template: bool, /// Tag Ids pub tags: Vec, /// Resource specific info pub info: Info, } /// Passing empty Vec is the same as not filtering by that field #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, DefaultBuilder, )] pub struct ResourceQuery { #[serde(default)] pub names: Vec, #[serde(default)] pub templates: TemplatesQueryBehavior, /// Pass Vec of tag ids or tag names #[serde(default, deserialize_with = "string_list_deserializer")] pub tags: Vec, /// 'All' or 'Any' #[serde(default)] pub tag_behavior: TagQueryBehavior, #[serde(default)] pub specific: T, } #[typeshare] #[derive( Debug, Clone, Copy, Default, Serialize, Deserialize, ValueEnum, Display, )] // Only strum serializes lowercase for clap compat. #[strum(serialize_all = "lowercase")] pub enum TemplatesQueryBehavior { /// Include templates in results. Default. #[default] Include, /// Exclude templates from results. Exclude, /// Results *only* includes templates. Only, } #[typeshare] #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] pub enum TagQueryBehavior { /// Returns resources which have strictly all the tags #[default] All, /// Returns resources which have one or more of the tags Any, } pub trait AddFilters { fn add_filters(&self, _filters: &mut Document) {} } impl AddFilters for () {} impl AddFilters for ResourceQuery { fn add_filters(&self, filters: &mut Document) { if !self.names.is_empty() { filters.insert("name", doc! { "$in": &self.names }); } match self.templates { TemplatesQueryBehavior::Exclude => { filters.insert("template", doc! { "$ne": true }); } TemplatesQueryBehavior::Only => { filters.insert("template", true); } TemplatesQueryBehavior::Include => { // No query on template field necessary } }; if !self.tags.is_empty() { match self.tag_behavior { TagQueryBehavior::All => { filters.insert("tags", doc! { "$all": &self.tags }); } TagQueryBehavior::Any => { let ors = self .tags .iter() .map(|tag| doc! { "tags": tag }) .collect::>(); filters.insert("$or", ors); } } } self.specific.add_filters(filters); } } ================================================ FILE: client/core/rs/src/entities/schedule.rs ================================================ use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{I64, ResourceTarget, ScheduleFormat}; /// A scheduled Action / Procedure run. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct Schedule { /// Procedure or Alerter pub target: ResourceTarget, /// Readable name of the target resource pub name: String, /// The format of the schedule expression pub schedule_format: ScheduleFormat, /// The schedule for the run pub schedule: String, /// Whether the scheduled run is enabled pub enabled: bool, /// Custom schedule timezone if it exists pub schedule_timezone: String, /// Last run timestamp in ms. pub last_run_at: Option, /// Next scheduled run time in unix ms. pub next_scheduled_run: Option, /// If there is an error parsing schedule expression, /// it will be given here. pub schedule_error: Option, /// Resource tags. pub tags: Vec, } ================================================ FILE: client/core/rs/src/entities/server.rs ================================================ use std::{collections::HashMap, path::PathBuf}; use derive_builder::Builder; use partial_derive2::Partial; use serde::{Deserialize, Serialize}; use strum::Display; use typeshare::typeshare; use crate::{ deserializers::{ option_string_list_deserializer, string_list_deserializer, }, entities::MaintenanceWindow, }; use super::{ I64, alert::SeverityLevel, resource::{AddFilters, Resource, ResourceListItem, ResourceQuery}, }; #[typeshare] pub type Server = Resource; #[typeshare] pub type ServerListItem = ResourceListItem; #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerListItemInfo { /// The server's state. pub state: ServerState, /// Region of the server. pub region: String, /// Address of the server. pub address: String, /// External address of the server (reachable by users). /// Used with links. #[serde(default)] // API backward compat pub external_address: String, /// The Komodo Periphery version of the server. pub version: String, /// Whether server is configured to send unreachable alerts. pub send_unreachable_alerts: bool, /// Whether server is configured to send cpu alerts. pub send_cpu_alerts: bool, /// Whether server is configured to send mem alerts. pub send_mem_alerts: bool, /// Whether server is configured to send disk alerts. pub send_disk_alerts: bool, /// Whether server is configured to send version mismatch alerts. pub send_version_mismatch_alerts: bool, /// Whether terminals are disabled for this Server. pub terminals_disabled: bool, /// Whether container exec is disabled for this Server. pub container_exec_disabled: bool, } #[typeshare(serialized_as = "Partial")] pub type _PartialServerConfig = PartialServerConfig; /// Server configuration. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Builder, Partial)] #[partial_derive(Serialize, Deserialize, Debug, Clone, Default)] #[partial(skip_serializing_none, from, diff)] pub struct ServerConfig { /// The http address of the periphery client. /// Default: http://localhost:8120 #[serde(default = "default_address")] #[builder(default = "default_address()")] #[partial_default(default_address())] pub address: String, /// The address to use with links for containers on the server. /// If empty, will use the 'address' for links. #[serde(default)] #[builder(default)] pub external_address: String, /// An optional region label #[serde(default)] #[builder(default)] pub region: String, /// Whether a server is enabled. /// If a server is disabled, /// you won't be able to perform any actions on it or see deployment's status. /// Default: false #[serde(default = "default_enabled")] #[builder(default = "default_enabled()")] #[partial_default(default_enabled())] pub enabled: bool, /// The timeout used to reach the server in seconds. /// default: 2 #[serde(default = "default_timeout_seconds")] #[builder(default = "default_timeout_seconds()")] #[partial_default(default_timeout_seconds())] pub timeout_seconds: I64, /// An optional override passkey to use /// to authenticate with periphery agent. /// If this is empty, will use passkey in core config. #[serde(default)] #[builder(default)] pub passkey: String, /// Sometimes the system stats reports a mount path that is not desired. /// Use this field to filter it out from the report. #[serde(default, deserialize_with = "string_list_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_string_list_deserializer" ))] #[builder(default)] pub ignore_mounts: Vec, /// Whether to monitor any server stats beyond passing health check. /// default: true #[serde(default = "default_stats_monitoring")] #[builder(default = "default_stats_monitoring()")] #[partial_default(default_stats_monitoring())] pub stats_monitoring: bool, /// Whether to trigger 'docker image prune -a -f' every 24 hours. /// default: true #[serde(default = "default_auto_prune")] #[builder(default = "default_auto_prune()")] #[partial_default(default_auto_prune())] pub auto_prune: bool, /// Configure quick links that are displayed in the resource header #[serde(default, deserialize_with = "string_list_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_string_list_deserializer" ))] #[builder(default)] pub links: Vec, /// Whether to send alerts about the servers reachability #[serde(default = "default_send_alerts")] #[builder(default = "default_send_alerts()")] #[partial_default(default_send_alerts())] pub send_unreachable_alerts: bool, /// Whether to send alerts about the servers CPU status #[serde(default = "default_send_alerts")] #[builder(default = "default_send_alerts()")] #[partial_default(default_send_alerts())] pub send_cpu_alerts: bool, /// Whether to send alerts about the servers MEM status #[serde(default = "default_send_alerts")] #[builder(default = "default_send_alerts()")] #[partial_default(default_send_alerts())] pub send_mem_alerts: bool, /// Whether to send alerts about the servers DISK status #[serde(default = "default_send_alerts")] #[builder(default = "default_send_alerts()")] #[partial_default(default_send_alerts())] pub send_disk_alerts: bool, /// Whether to send alerts about the servers version mismatch with core #[serde(default = "default_send_alerts")] #[builder(default = "default_send_alerts()")] #[partial_default(default_send_alerts())] pub send_version_mismatch_alerts: bool, /// The percentage threshhold which triggers WARNING state for CPU. #[serde(default = "default_cpu_warning")] #[builder(default = "default_cpu_warning()")] #[partial_default(default_cpu_warning())] pub cpu_warning: f32, /// The percentage threshhold which triggers CRITICAL state for CPU. #[serde(default = "default_cpu_critical")] #[builder(default = "default_cpu_critical()")] #[partial_default(default_cpu_critical())] pub cpu_critical: f32, /// The percentage threshhold which triggers WARNING state for MEM. #[serde(default = "default_mem_warning")] #[builder(default = "default_mem_warning()")] #[partial_default(default_mem_warning())] pub mem_warning: f64, /// The percentage threshhold which triggers CRITICAL state for MEM. #[serde(default = "default_mem_critical")] #[builder(default = "default_mem_critical()")] #[partial_default(default_mem_critical())] pub mem_critical: f64, /// The percentage threshhold which triggers WARNING state for DISK. #[serde(default = "default_disk_warning")] #[builder(default = "default_disk_warning()")] #[partial_default(default_disk_warning())] pub disk_warning: f64, /// The percentage threshhold which triggers CRITICAL state for DISK. #[serde(default = "default_disk_critical")] #[builder(default = "default_disk_critical()")] #[partial_default(default_disk_critical())] pub disk_critical: f64, /// Scheduled maintenance windows during which alerts will be suppressed. #[serde(default)] #[builder(default)] pub maintenance_windows: Vec, } impl ServerConfig { pub fn builder() -> ServerConfigBuilder { ServerConfigBuilder::default() } } fn default_address() -> String { String::from("https://periphery:8120") } fn default_enabled() -> bool { false } fn default_timeout_seconds() -> i64 { 3 } fn default_stats_monitoring() -> bool { true } fn default_auto_prune() -> bool { true } fn default_send_alerts() -> bool { true } fn default_cpu_warning() -> f32 { 90.0 } fn default_cpu_critical() -> f32 { 99.0 } fn default_mem_warning() -> f64 { 75.0 } fn default_mem_critical() -> f64 { 95.0 } fn default_disk_warning() -> f64 { 75.0 } fn default_disk_critical() -> f64 { 95.0 } impl Default for ServerConfig { fn default() -> Self { Self { address: default_address(), external_address: Default::default(), enabled: default_enabled(), timeout_seconds: default_timeout_seconds(), ignore_mounts: Default::default(), stats_monitoring: default_stats_monitoring(), auto_prune: default_auto_prune(), links: Default::default(), send_unreachable_alerts: default_send_alerts(), send_cpu_alerts: default_send_alerts(), send_mem_alerts: default_send_alerts(), send_disk_alerts: default_send_alerts(), send_version_mismatch_alerts: default_send_alerts(), region: Default::default(), passkey: Default::default(), cpu_warning: default_cpu_warning(), cpu_critical: default_cpu_critical(), mem_warning: default_mem_warning(), mem_critical: default_mem_critical(), disk_warning: default_disk_warning(), disk_critical: default_disk_critical(), maintenance_windows: Default::default(), } } } /// The health of a part of the server. #[typeshare] #[derive(Serialize, Deserialize, Default, Debug, Clone)] pub struct ServerHealthState { pub level: SeverityLevel, /// Whether the health is good enough to close an open alert. pub should_close_alert: bool, } /// Summary of the health of the server. #[typeshare] #[derive(Serialize, Deserialize, Default, Debug, Clone)] pub struct ServerHealth { pub cpu: ServerHealthState, pub mem: ServerHealthState, pub disks: HashMap, } /// Info about an active terminal on a server. /// Retrieve with [ListTerminals][crate::api::read::server::ListTerminals]. #[typeshare] #[derive(Serialize, Deserialize, Default, Debug, Clone)] pub struct TerminalInfo { /// The name of the terminal. pub name: String, /// The root program / args of the pty pub command: String, /// The size of the terminal history in memory. pub stored_size_kb: f64, } /// Current pending actions on the server. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)] pub struct ServerActionState { /// Server currently pruning networks pub pruning_networks: bool, /// Server currently pruning containers pub pruning_containers: bool, /// Server currently pruning images pub pruning_images: bool, /// Server currently pruning volumes pub pruning_volumes: bool, /// Server currently pruning docker builders pub pruning_builders: bool, /// Server currently pruning builx cache pub pruning_buildx: bool, /// Server currently pruning system pub pruning_system: bool, /// Server currently starting containers. pub starting_containers: bool, /// Server currently restarting containers. pub restarting_containers: bool, /// Server currently pausing containers. pub pausing_containers: bool, /// Server currently unpausing containers. pub unpausing_containers: bool, /// Server currently stopping containers. pub stopping_containers: bool, } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Display, Serialize, Deserialize, )] #[strum(serialize_all = "kebab-case")] pub enum ServerState { /// Server health check passing. Ok, /// Server is unreachable. #[default] NotOk, /// Server is disabled. Disabled, } /// Server-specific query #[typeshare] pub type ServerQuery = ResourceQuery; #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct ServerQuerySpecifics {} impl AddFilters for ServerQuerySpecifics {} ================================================ FILE: client/core/rs/src/entities/stack.rs ================================================ use std::{collections::HashMap, sync::OnceLock}; use anyhow::Context; use bson::{Document, doc}; use derive_builder::Builder; use derive_default_builder::DefaultBuilder; use indexmap::IndexSet; use partial_derive2::Partial; use serde::{ Deserialize, Serialize, de::{IntoDeserializer, Visitor, value::MapAccessDeserializer}, }; use strum::Display; use typeshare::typeshare; use crate::{ deserializers::{ env_vars_deserializer, file_contents_deserializer, option_env_vars_deserializer, option_file_contents_deserializer, option_maybe_string_i64_deserializer, option_string_list_deserializer, string_list_deserializer, }, entities::{EnvironmentVar, environment_vars_from_str}, }; use super::{ FileContents, SystemCommand, docker::container::ContainerListItem, resource::{Resource, ResourceListItem, ResourceQuery}, }; #[typeshare] pub type Stack = Resource; impl Stack { /// If fresh is passed, it will bypass the deployed project name. /// and get the most up to date one from just project_name field falling back to stack name. pub fn project_name(&self, fresh: bool) -> String { if !fresh && let Some(project_name) = &self.info.deployed_project_name { return project_name.clone(); } if self.config.project_name.is_empty() { self.name.clone() } else { self.config.project_name.clone() } } pub fn compose_file_paths(&self) -> &[String] { if self.config.file_paths.is_empty() { default_stack_file_paths() } else { &self.config.file_paths } } pub fn is_compose_file(&self, path: &str) -> bool { for compose_path in self.compose_file_paths() { if path.ends_with(compose_path) { return true; } } false } pub fn all_file_paths(&self) -> Vec { let mut res = self .compose_file_paths() .iter() .cloned() // Makes sure to dedup them, while maintaining ordering .collect::>(); res.extend(self.config.additional_env_files.clone()); res.extend( self.config.config_files.iter().map(|f| f.path.clone()), ); res.into_iter().collect() } pub fn all_file_dependencies(&self) -> Vec { let mut res = self .compose_file_paths() .iter() .cloned() .map(StackFileDependency::full_redeploy) // Makes sure to dedup them, while maintaining ordering .collect::>(); res.extend( self .config .additional_env_files .iter() .cloned() .map(StackFileDependency::full_redeploy), ); res.extend(self.config.config_files.clone()); res.into_iter().collect() } } fn default_stack_file_paths() -> &'static [String] { static DEFAULT_FILE_PATHS: OnceLock> = OnceLock::new(); DEFAULT_FILE_PATHS .get_or_init(|| vec![String::from("compose.yaml")]) } #[typeshare] pub type StackListItem = ResourceListItem; #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StackListItemInfo { /// The server that stack is deployed on. pub server_id: String, /// Whether stack is using files on host mode pub files_on_host: bool, /// Whether stack has file contents defined. pub file_contents: bool, /// Linked repo, if one is attached. pub linked_repo: String, /// The git provider domain pub git_provider: String, /// The configured repo pub repo: String, /// The configured branch pub branch: String, /// Full link to the repo. pub repo_link: String, /// The stack state pub state: StackState, /// A string given by docker conveying the status of the stack. pub status: Option, /// The services that are part of the stack. /// If deployed, will be `deployed_services`. /// Otherwise, its `latest_services` pub services: Vec, /// Whether the compose project is missing on the host. /// Ie, it does not show up in `docker compose ls`. /// If true, and the stack is not Down, this is an unhealthy state. pub project_missing: bool, /// If any compose files are missing in the repo, the path will be here. /// If there are paths here, this is an unhealthy state, and deploying will fail. pub missing_files: Vec, /// Deployed short commit hash, or null. Only for repo based stacks. pub deployed_hash: Option, /// Latest short commit hash, or null. Only for repo based stacks pub latest_hash: Option, } #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StackServiceWithUpdate { pub service: String, /// The service's image pub image: String, /// Whether there is a newer image available for this service pub update_available: bool, } #[typeshare] #[derive( Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Display, )] // Do this one snake_case in line with DeploymentState. // Also in line with docker terminology. #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] pub enum StackState { /// The stack is currently re/deploying Deploying, /// All containers are running. Running, /// All containers are paused Paused, /// All contianers are stopped Stopped, /// All containers are created Created, /// All containers are restarting Restarting, /// All containers are dead Dead, /// All containers are removing Removing, /// The containers are in a mix of states Unhealthy, /// The stack is not deployed Down, /// Server not reachable for status #[default] Unknown, } #[typeshare] #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct StackInfo { /// If any of the expected compose / additional files are missing in the repo, /// they will be stored here. #[serde(default)] pub missing_files: Vec, /// The deployed project name. /// This is updated whenever Komodo successfully deploys the stack. /// If it is present, Komodo will use it for actions over other options, /// to ensure control is maintained after changing the project name (there is no rename compose project api). pub deployed_project_name: Option, /// Deployed short commit hash, or null. Only for repo based stacks. pub deployed_hash: Option, /// Deployed commit message, or null. Only for repo based stacks pub deployed_message: Option, /// The deployed compose / additional file contents. /// This is updated whenever Komodo successfully deploys the stack. pub deployed_contents: Option>, /// The deployed service names. /// This is updated whenever it is empty, or deployed contents is updated. pub deployed_services: Option>, /// The output of `docker compose config`. /// This is updated whenever Komodo successfully deploys the stack. pub deployed_config: Option, /// The latest service names. /// This is updated whenever the stack cache refreshes, using the latest file contents (either db defined or remote). #[serde(default)] pub latest_services: Vec, /// The remote compose / additional file contents, whether on host or in repo. /// This is updated whenever Komodo refreshes the stack cache. /// It will be empty if the file is defined directly in the stack config. pub remote_contents: Option>, /// If there was an error in getting the remote contents, it will be here. pub remote_errors: Option>, /// Latest commit hash, or null pub latest_hash: Option, /// Latest commit message, or null pub latest_message: Option, } #[typeshare(serialized_as = "Partial")] pub type _PartialStackConfig = PartialStackConfig; /// The compose file configuration. #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, Builder, Partial)] #[partial_derive(Debug, Clone, Default, Serialize, Deserialize)] #[partial(skip_serializing_none, from, diff)] pub struct StackConfig { /// The server to deploy the stack on. #[serde(default, alias = "server")] #[partial_attr(serde(alias = "server"))] #[builder(default)] pub server_id: String, /// Configure quick links that are displayed in the resource header #[serde(default, deserialize_with = "string_list_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_string_list_deserializer" ))] #[builder(default)] pub links: Vec, /// Optionally specify a custom project name for the stack. /// If this is empty string, it will default to the stack name. /// Used with `docker compose -p {project_name}`. /// /// Note. Can be used to import pre-existing stacks. #[serde(default)] #[builder(default)] pub project_name: String, /// Whether to automatically `compose pull` before redeploying stack. /// Ensured latest images are deployed. /// Will fail if the compose file specifies a locally build image. #[serde(default = "default_auto_pull")] #[builder(default = "default_auto_pull()")] #[partial_default(default_auto_pull())] pub auto_pull: bool, /// Whether to `docker compose build` before `compose down` / `compose up`. /// Combine with build_extra_args for custom behaviors. #[serde(default)] #[builder(default)] pub run_build: bool, /// Whether to poll for any updates to the images. #[serde(default)] #[builder(default)] pub poll_for_updates: bool, /// Whether to automatically redeploy when /// newer images are found. Will implicitly /// enable `poll_for_updates`, you don't need to /// enable both. #[serde(default)] #[builder(default)] pub auto_update: bool, /// If auto update is enabled, Komodo will /// by default only update the specific services /// with image updates. If this parameter is set to true, /// Komodo will redeploy the whole Stack (all services). #[serde(default)] #[builder(default)] pub auto_update_all_services: bool, /// Whether to run `docker compose down` before `compose up`. #[serde(default)] #[builder(default)] pub destroy_before_deploy: bool, /// Whether to skip secret interpolation into the stack environment variables. #[serde(default)] #[builder(default)] pub skip_secret_interp: bool, /// Choose a Komodo Repo (Resource) to source the compose files. #[serde(default)] #[builder(default)] pub linked_repo: String, /// The git provider domain. Default: github.com #[serde(default = "default_git_provider")] #[builder(default = "default_git_provider()")] #[partial_default(default_git_provider())] pub git_provider: String, /// Whether to use https to clone the repo (versus http). Default: true /// /// Note. Komodo does not currently support cloning repos via ssh. #[serde(default = "default_git_https")] #[builder(default = "default_git_https()")] #[partial_default(default_git_https())] pub git_https: bool, /// The git account used to access private repos. /// Passing empty string can only clone public repos. /// /// Note. A token for the account must be available in the core config or the builder server's periphery config /// for the configured git provider. #[serde(default)] #[builder(default)] pub git_account: String, /// The repo used as the source of the build. /// {namespace}/{repo_name} #[serde(default)] #[builder(default)] pub repo: String, /// The branch of the repo. #[serde(default = "default_branch")] #[builder(default = "default_branch()")] #[partial_default(default_branch())] pub branch: String, /// Optionally set a specific commit hash. #[serde(default)] #[builder(default)] pub commit: String, /// Optionally set a specific clone path #[serde(default)] #[builder(default)] pub clone_path: String, /// By default, the Stack will `git pull` the repo after it is first cloned. /// If this option is enabled, the repo folder will be deleted and recloned instead. #[serde(default)] #[builder(default)] pub reclone: bool, /// Whether incoming webhooks actually trigger action. #[serde(default = "default_webhook_enabled")] #[builder(default = "default_webhook_enabled()")] #[partial_default(default_webhook_enabled())] pub webhook_enabled: bool, /// Optionally provide an alternate webhook secret for this stack. /// If its an empty string, use the default secret from the config. #[serde(default)] #[builder(default)] pub webhook_secret: String, /// By default, the Stack will `DeployStackIfChanged`. /// If this option is enabled, will always run `DeployStack` without diffing. #[serde(default)] #[builder(default)] pub webhook_force_deploy: bool, /// If this is checked, the stack will source the files on the host. /// Use `run_directory` and `file_paths` to specify the path on the host. /// This is useful for those who wish to setup their files on the host, /// rather than defining the contents in UI or in a git repo. #[serde(default)] #[builder(default)] pub files_on_host: bool, /// Directory to change to (`cd`) before running `docker compose up -d`. #[serde(default)] #[builder(default)] pub run_directory: String, /// Add paths to compose files, relative to the run path. /// If this is empty, will use file `compose.yaml`. #[serde(default, deserialize_with = "string_list_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_string_list_deserializer" ))] #[builder(default)] pub file_paths: Vec, /// The name of the written environment file before `docker compose up`. /// Relative to the run directory root. /// Default: .env #[serde(default = "default_env_file_path")] #[builder(default = "default_env_file_path()")] #[partial_default(default_env_file_path())] pub env_file_path: String, /// Add additional env files to attach with `--env-file`. /// Relative to the run directory root. /// /// Note. It is already included as an `additional_file`. /// Don't add it again there. #[serde(default, deserialize_with = "string_list_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_string_list_deserializer" ))] #[builder(default)] pub additional_env_files: Vec, /// Add additional config files either in repo or on host to track. /// Can add any files associated with the stack to enable editing them in the UI. /// Doing so will also include diffing these when deciding to deploy in `DeployStackIfChanged`. /// Relative to the run directory. /// /// Note. If the config file is .env and should be included in compose command /// using `--env-file`, add it to `additional_env_files` instead. #[serde(default)] #[partial_attr(serde(default))] #[builder(default)] pub config_files: Vec, /// Whether to send StackStateChange alerts for this stack. #[serde(default = "default_send_alerts")] #[builder(default = "default_send_alerts()")] #[partial_default(default_send_alerts())] pub send_alerts: bool, /// Used with `registry_account` to login to a registry before docker compose up. #[serde(default)] #[builder(default)] pub registry_provider: String, /// Used with `registry_provider` to login to a registry before docker compose up. #[serde(default)] #[builder(default)] pub registry_account: String, /// The optional command to run before the Stack is deployed. #[serde(default)] #[builder(default)] pub pre_deploy: SystemCommand, /// The optional command to run after the Stack is deployed. #[serde(default)] #[builder(default)] pub post_deploy: SystemCommand, /// The extra arguments to pass after `docker compose up -d`. /// If empty, no extra arguments will be passed. #[serde(default, deserialize_with = "string_list_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_string_list_deserializer" ))] #[builder(default)] pub extra_args: Vec, /// The extra arguments to pass after `docker compose build`. /// If empty, no extra build arguments will be passed. /// Only used if `run_build: true` #[serde(default, deserialize_with = "string_list_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_string_list_deserializer" ))] #[builder(default)] pub build_extra_args: Vec, /// Ignore certain services declared in the compose file when checking /// the stack status. For example, an init service might be exited, but the /// stack should be healthy. This init service should be in `ignore_services` #[serde(default, deserialize_with = "string_list_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_string_list_deserializer" ))] #[builder(default)] pub ignore_services: Vec, /// The contents of the file directly, for management in the UI. /// If this is empty, it will fall back to checking git config for /// repo based compose file. /// Supports variable / secret interpolation. #[serde(default, deserialize_with = "file_contents_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_file_contents_deserializer" ))] #[builder(default)] pub file_contents: String, /// The environment variables passed to the compose file. /// They will be written to path defined in env_file_path, /// which is given relative to the run directory. /// /// If it is empty, no file will be written. #[serde(default, deserialize_with = "env_vars_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_env_vars_deserializer" ))] #[builder(default)] pub environment: String, } impl StackConfig { pub fn builder() -> StackConfigBuilder { StackConfigBuilder::default() } pub fn env_vars(&self) -> anyhow::Result> { environment_vars_from_str(&self.environment) .context("Invalid environment") } } fn default_env_file_path() -> String { String::from(".env") } fn default_auto_pull() -> bool { true } fn default_git_provider() -> String { String::from("github.com") } fn default_git_https() -> bool { true } fn default_branch() -> String { String::from("main") } fn default_webhook_enabled() -> bool { true } fn default_send_alerts() -> bool { true } impl Default for StackConfig { fn default() -> Self { Self { server_id: Default::default(), project_name: Default::default(), run_directory: Default::default(), file_paths: Default::default(), files_on_host: Default::default(), registry_provider: Default::default(), registry_account: Default::default(), file_contents: Default::default(), auto_pull: default_auto_pull(), poll_for_updates: Default::default(), auto_update: Default::default(), auto_update_all_services: Default::default(), ignore_services: Default::default(), pre_deploy: Default::default(), post_deploy: Default::default(), extra_args: Default::default(), environment: Default::default(), env_file_path: default_env_file_path(), additional_env_files: Default::default(), config_files: Default::default(), run_build: Default::default(), destroy_before_deploy: Default::default(), build_extra_args: Default::default(), skip_secret_interp: Default::default(), linked_repo: Default::default(), git_provider: default_git_provider(), git_https: default_git_https(), repo: Default::default(), branch: default_branch(), commit: Default::default(), clone_path: Default::default(), reclone: Default::default(), git_account: Default::default(), webhook_enabled: default_webhook_enabled(), webhook_secret: Default::default(), webhook_force_deploy: Default::default(), send_alerts: default_send_alerts(), links: Default::default(), } } } #[typeshare] #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ComposeProject { /// The compose project name. pub name: String, /// The status of the project, as returned by docker. pub status: Option, /// The compose files included in the project. pub compose_files: Vec, } #[typeshare] #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct StackServiceNames { /// The name of the service pub service_name: String, /// Will either be the declared container_name in the compose file, /// or a pattern to match auto named containers. /// /// Auto named containers are composed of three parts: /// /// 1. The name of the compose project (top level name field of compose file). /// This defaults to the name of the parent folder of the compose file. /// Komodo will always set it to be the name of the stack, but imported stacks /// will have a different name. /// 2. The service name /// 3. The replica number /// /// Example: stacko-mongo-1. /// /// This stores only 1. and 2., ie stacko-mongo. /// Containers will be matched via regex like `^container_name-?[0-9]*$`` pub container_name: String, /// The services image. #[serde(default)] pub image: String, } #[typeshare] #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct StackService { /// The service name pub service: String, /// The service image pub image: String, /// The container pub container: Option, /// Whether there is an update available for this services image. pub update_available: bool, } #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)] pub struct StackActionState { pub pulling: bool, pub deploying: bool, pub starting: bool, pub restarting: bool, pub pausing: bool, pub unpausing: bool, pub stopping: bool, pub destroying: bool, } #[typeshare] pub type StackQuery = ResourceQuery; #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, DefaultBuilder, )] pub struct StackQuerySpecifics { /// Query only for Stacks on these Servers. /// If empty, does not filter by Server. /// Only accepts Server id (not name). #[serde(default)] pub server_ids: Vec, /// Query only for Stacks with these linked repos. /// Only accepts Repo id (not name). #[serde(default)] pub linked_repos: Vec, /// Filter syncs by their repo. #[serde(default)] pub repos: Vec, /// Query only for Stack with available image updates. #[serde(default)] pub update_available: bool, } impl super::resource::AddFilters for StackQuerySpecifics { fn add_filters(&self, filters: &mut Document) { if !self.server_ids.is_empty() { filters .insert("config.server_id", doc! { "$in": &self.server_ids }); } if !self.linked_repos.is_empty() { filters.insert( "config.linked_repo", doc! { "$in": &self.linked_repos }, ); } if !self.repos.is_empty() { filters.insert("config.repo", doc! { "$in": &self.repos }); } } } /// Keeping this minimal for now as its only needed to parse the service names / container names, /// and replica count. Not a typeshared type. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ComposeFile { /// If not provided, will default to the parent folder holding the compose file. pub name: Option, #[serde(default)] pub services: HashMap, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ComposeService { pub image: Option, pub container_name: Option, pub deploy: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ComposeServiceDeploy { #[serde( default, deserialize_with = "option_maybe_string_i64_deserializer" )] pub replicas: Option, } // PRE-1.19.1 BACKWARD COMPAT NOTE // This was split from general FileContents in 1.19.1, // and must maintain 2 way de/ser backward compatibility // with the mentioned struct. /// Same as [FileContents] with some extra /// info specific to Stacks. #[typeshare] #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct StackRemoteFileContents { /// The path to the file pub path: String, /// The contents of the file pub contents: String, /// The services depending on this file, /// or empty for global requirement (eg all compose files and env files). #[serde(default)] pub services: Vec, /// Whether diff requires Redeploy / Restart / None #[serde(default)] pub requires: StackFileRequires, } #[typeshare] #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize, )] pub enum StackFileRequires { /// Diff requires service redeploy. #[serde(alias = "redeploy")] Redeploy, /// Diff requires service restart #[serde(alias = "restart")] Restart, /// Diff requires no action. Default. #[default] #[serde(alias = "none")] None, } /// Configure additional file dependencies of the Stack. #[typeshare] #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] pub struct StackFileDependency { /// Specify the file pub path: String, /// Specify specific service/s #[serde(default, skip_serializing_if = "Vec::is_empty")] pub services: Vec, /// Specify #[serde(default, skip_serializing_if = "is_none")] pub requires: StackFileRequires, } impl StackFileDependency { pub fn full_redeploy(path: String) -> StackFileDependency { StackFileDependency { path, services: Vec::new(), requires: StackFileRequires::Redeploy, } } } fn is_none(requires: &StackFileRequires) -> bool { matches!(requires, StackFileRequires::None) } /// Used with custom de/serializer for [StackFileDependency] #[derive(Deserialize)] struct __StackFileDependency { path: String, #[serde( default, alias = "service", deserialize_with = "string_list_deserializer" )] services: Vec, #[serde(default, alias = "req")] requires: StackFileRequires, } impl<'de> Deserialize<'de> for StackFileDependency { fn deserialize(deserializer: D) -> Result where D: serde::Deserializer<'de>, { struct StackFileDependencyVisitor; impl<'de> Visitor<'de> for StackFileDependencyVisitor { type Value = StackFileDependency; fn expecting( &self, formatter: &mut std::fmt::Formatter, ) -> std::fmt::Result { write!(formatter, "string or StackFileDependency (object)") } fn visit_string(self, path: String) -> Result where E: serde::de::Error, { Ok(StackFileDependency { path, services: Vec::new(), requires: StackFileRequires::None, }) } fn visit_str(self, v: &str) -> Result where E: serde::de::Error, { Self::visit_string(self, v.to_string()) } fn visit_map(self, map: A) -> Result where A: serde::de::MapAccess<'de>, { __StackFileDependency::deserialize( MapAccessDeserializer::new(map).into_deserializer(), ) .map(|v| StackFileDependency { path: v.path, services: v.services, requires: v.requires, }) } } deserializer.deserialize_any(StackFileDependencyVisitor) } } // // This one is nice for TOML, but annoying to use on frontend // impl Serialize for StackFileDependency { // fn serialize(&self, serializer: S) -> Result // where // S: serde::Serializer, // { // // Serialize to string in default case // if is_redeploy(&self.requires) && self.services.is_empty() { // return serializer.serialize_str(&self.path); // } // __StackFileDependency { // path: self.path.clone(), // services: self.services.clone(), // requires: self.requires, // } // .serialize(serializer) // } // } ================================================ FILE: client/core/rs/src/entities/stats.rs ================================================ use std::path::PathBuf; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{I64, Timelength}; /// System information of a server #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct SystemInformation { /// The system name pub name: Option, /// The system long os version pub os: Option, /// System's kernel version pub kernel: Option, /// Physical core count pub core_count: Option, /// System hostname based off DNS pub host_name: Option, /// The CPU's brand pub cpu_brand: String, /// Whether terminals are disabled on this Periphery server pub terminals_disabled: bool, /// Whether container exec is disabled on this Periphery server pub container_exec_disabled: bool, } /// System stats stored on the database. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] #[cfg_attr( feature = "mongo", derive(mongo_indexed::derive::MongoIndexed) )] #[cfg_attr(feature = "mongo", collection_name(Stats))] pub struct SystemStatsRecord { /// Unix timestamp in milliseconds #[cfg_attr(feature = "mongo", index)] pub ts: I64, /// Server id #[cfg_attr(feature = "mongo", index)] pub sid: String, // basic stats /// Cpu usage percentage pub cpu_perc: f32, /// Load average (1m, 5m, 15m) #[serde(default)] pub load_average: SystemLoadAverage, /// Memory used in GB pub mem_used_gb: f64, /// Total memory in GB pub mem_total_gb: f64, /// Disk used in GB pub disk_used_gb: f64, /// Total disk size in GB pub disk_total_gb: f64, /// Breakdown of individual disks, including their usage, total size, and mount point pub disks: Vec, /// Total network ingress in bytes #[serde(default)] pub network_ingress_bytes: f64, /// Total network egress in bytes #[serde(default)] pub network_egress_bytes: f64, // /// Network usage by interface name (ingress, egress in bytes) // #[serde(default)] // pub network_usage_interface: Vec, // interface -> (ingress, egress) } /// Realtime system stats data. #[typeshare] #[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct SystemStats { /// Cpu usage percentage pub cpu_perc: f32, /// Load average (1m, 5m, 15m) #[serde(default)] pub load_average: SystemLoadAverage, /// [1.15.9+] /// Free memory in GB. /// This is really the 'Free' memory, not the 'Available' memory. /// It may be different than mem_total_gb - mem_used_gb. #[serde(default)] pub mem_free_gb: f64, /// Used memory in GB. 'Total' - 'Available' (not free) memory. pub mem_used_gb: f64, /// Total memory in GB pub mem_total_gb: f64, /// Breakdown of individual disks, ie their usages, sizes, and mount points pub disks: Vec, /// Network ingress usage in MB #[serde(default)] pub network_ingress_bytes: f64, /// Network egress usage in MB #[serde(default)] pub network_egress_bytes: f64, // /// Network usage by interface name (ingress, egress in bytes) // #[serde(default)] // pub network_usage_interface: Vec, // interface -> (ingress, egress) // metadata /// The rate the system stats are being polled from the system pub polling_rate: Timelength, /// Unix timestamp in milliseconds when stats were last polled pub refresh_ts: I64, /// Unix timestamp in milliseconds when disk list was last refreshed pub refresh_list_ts: I64, } /// Info for a single disk mounted on the system. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SingleDiskUsage { /// The mount point of the disk pub mount: PathBuf, /// Detected file system pub file_system: String, /// Used portion of the disk in GB pub used_gb: f64, /// Total size of the disk in GB pub total_gb: f64, } /// Info for network interface usage. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SingleNetworkInterfaceUsage { /// The network interface name pub name: String, /// The ingress in bytes pub ingress_bytes: f64, /// The egress in bytes pub egress_bytes: f64, } pub fn sum_disk_usage(disks: &[SingleDiskUsage]) -> TotalDiskUsage { disks .iter() .fold(TotalDiskUsage::default(), |mut total, disk| { total.used_gb += disk.used_gb; total.total_gb += disk.total_gb; total }) } /// Info for the all system disks combined. #[typeshare] #[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct TotalDiskUsage { /// Used portion in GB pub used_gb: f64, /// Total size in GB pub total_gb: f64, } /// Information about a process on the system. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct SystemProcess { /// The process PID pub pid: u32, /// The process name pub name: String, /// The path to the process executable #[serde(default)] pub exe: String, /// The command used to start the process pub cmd: Vec, /// The time the process was started #[serde(default)] pub start_time: f64, /// The cpu usage percentage of the process. /// This is in core-percentage, eg 100% is 1 full core, and /// an 8 core machine would max at 800%. pub cpu_perc: f32, /// The memory usage of the process in MB pub mem_mb: f64, /// Process disk read in KB/s pub disk_read_kb: f64, /// Process disk write in KB/s pub disk_write_kb: f64, } #[typeshare] #[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct SystemLoadAverage { /// 1m load average pub one: f64, /// 5m load average pub five: f64, /// 15m load average pub fifteen: f64, } ================================================ FILE: client/core/rs/src/entities/sync.rs ================================================ use bson::{Document, doc}; use derive_builder::Builder; use derive_default_builder::DefaultBuilder; use partial_derive2::Partial; use serde::{Deserialize, Serialize}; use strum::Display; use typeshare::typeshare; use crate::deserializers::{ file_contents_deserializer, option_file_contents_deserializer, option_string_list_deserializer, string_list_deserializer, }; use super::{ I64, ResourceTarget, resource::{Resource, ResourceListItem, ResourceQuery}, }; #[typeshare] pub type ResourceSyncListItem = ResourceListItem; #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResourceSyncListItemInfo { /// Unix timestamp of last sync, or 0 pub last_sync_ts: I64, /// Whether sync is `files_on_host` mode. pub files_on_host: bool, /// Whether sync has file contents defined. pub file_contents: bool, /// Whether sync has `managed` mode enabled. pub managed: bool, /// Resource paths to the files. pub resource_path: Vec, /// Linked repo, if one is attached. pub linked_repo: String, /// The git provider domain. pub git_provider: String, /// The Github repo used as the source of the sync resources pub repo: String, /// The branch of the repo pub branch: String, /// Full link to the repo. pub repo_link: String, /// Short commit hash of last sync, or empty string pub last_sync_hash: Option, /// Commit message of last sync, or empty string pub last_sync_message: Option, /// State of the sync. Reflects whether most recent sync successful. pub state: ResourceSyncState, } #[typeshare] #[derive( Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Display, )] pub enum ResourceSyncState { /// Currently syncing Syncing, /// Updates pending Pending, /// Last sync successful (or never synced). No Changes pending Ok, /// Last sync failed Failed, /// Other case #[default] Unknown, } #[typeshare] pub type ResourceSync = Resource; #[typeshare] #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ResourceSyncInfo { /// Unix timestamp of last applied sync #[serde(default)] pub last_sync_ts: I64, /// Short commit hash of last applied sync pub last_sync_hash: Option, /// Commit message of last applied sync pub last_sync_message: Option, /// The list of pending updates to resources #[serde(default)] pub resource_updates: Vec, /// The list of pending updates to variables #[serde(default)] pub variable_updates: Vec, /// The list of pending updates to user groups #[serde(default)] pub user_group_updates: Vec, /// The list of pending deploys to resources. #[serde(default)] pub pending_deploy: SyncDeployUpdate, /// If there is an error, it will be stored here pub pending_error: Option, /// The commit hash which produced these pending updates. pub pending_hash: Option, /// The commit message which produced these pending updates. pub pending_message: Option, /// The current sync files #[serde(default)] pub remote_contents: Vec, /// Any read errors in files by path #[serde(default)] pub remote_errors: Vec, } #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResourceDiff { /// The resource target. /// The target id will be empty if "Create" ResourceDiffType. pub target: ResourceTarget, /// The data associated with the diff. pub data: DiffData, } #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", content = "data")] pub enum DiffData { /// Resource will be created Create { /// The name of resource to create #[serde(default)] name: String, /// The proposed resource to create in TOML proposed: String, }, Update { /// The proposed TOML proposed: String, /// The current TOML current: String, }, Delete { /// The current TOML of the resource to delete current: String, }, } #[typeshare] #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SyncDeployUpdate { /// Resources to deploy pub to_deploy: i32, /// A readable log of all the changes to be applied pub log: String, } #[typeshare(serialized_as = "Partial")] pub type _PartialResourceSyncConfig = PartialResourceSyncConfig; /// The sync configuration. #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, Builder, Partial)] #[partial_derive(Debug, Clone, Default, Serialize, Deserialize)] #[partial(skip_serializing_none, from, diff)] pub struct ResourceSyncConfig { /// Choose a Komodo Repo (Resource) to source the sync files. #[serde(default)] #[builder(default)] pub linked_repo: String, /// The git provider domain. Default: github.com #[serde(default = "default_git_provider")] #[builder(default = "default_git_provider()")] #[partial_default(default_git_provider())] pub git_provider: String, /// Whether to use https to clone the repo (versus http). Default: true /// /// Note. Komodo does not currently support cloning repos via ssh. #[serde(default = "default_git_https")] #[builder(default = "default_git_https()")] #[partial_default(default_git_https())] pub git_https: bool, /// The Github repo used as the source of the build. #[serde(default)] #[builder(default)] pub repo: String, /// The branch of the repo. #[serde(default = "default_branch")] #[builder(default = "default_branch()")] #[partial_default(default_branch())] pub branch: String, /// Optionally set a specific commit hash. #[serde(default)] #[builder(default)] pub commit: String, /// The git account used to access private repos. /// Passing empty string can only clone public repos. /// /// Note. A token for the account must be available in the core config or the builder server's periphery config /// for the configured git provider. #[serde(default)] #[builder(default)] pub git_account: String, /// Whether incoming webhooks actually trigger action. #[serde(default = "default_webhook_enabled")] #[builder(default = "default_webhook_enabled()")] #[partial_default(default_webhook_enabled())] pub webhook_enabled: bool, /// Optionally provide an alternate webhook secret for this sync. /// If its an empty string, use the default secret from the config. #[serde(default)] #[builder(default)] pub webhook_secret: String, /// Files are available on the Komodo Core host. /// Specify the file / folder with [ResourceSyncConfig::resource_path]. #[serde(default)] #[builder(default)] pub files_on_host: bool, /// The path of the resource file(s) to sync. /// - If Files on Host, this is relative to the configured `sync_directory` in core config. /// - If Git Repo based, this is relative to the root of the repo. /// Can be a specific file, or a directory containing multiple files / folders. /// See [https://komo.do/docs/sync-resources](https://komo.do/docs/sync-resources) for more information. #[serde(default, deserialize_with = "string_list_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_string_list_deserializer" ))] #[builder(default)] pub resource_path: Vec, /// Enable "pushes" to the file, /// which exports resources matching tags to single file. /// - If using `files_on_host`, it is stored in the file_contents, which must point to a .toml file path (it will be created if it doesn't exist). /// - If using `file_contents`, it is stored in the database. /// When using this, "delete" mode is always enabled. #[serde(default)] #[builder(default)] pub managed: bool, /// Whether sync should delete resources /// not declared in the resource files #[serde(default)] #[builder(default)] pub delete: bool, /// Whether sync should include resources. /// Default: true #[serde(default = "default_include_resources")] #[builder(default = "default_include_resources()")] #[partial_default(default_include_resources())] pub include_resources: bool, /// When using `managed` resource sync, will only export resources /// matching all of the given tags. If none, will match all resources. #[serde(default, deserialize_with = "string_list_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_string_list_deserializer" ))] #[builder(default)] pub match_tags: Vec, /// Whether sync should include variables. #[serde(default)] #[builder(default)] pub include_variables: bool, /// Whether sync should include user groups. #[serde(default)] #[builder(default)] pub include_user_groups: bool, /// Whether sync should send alert when it enters Pending state. /// Default: true #[serde(default = "default_pending_alert")] #[builder(default = "default_pending_alert()")] #[partial_default(default_pending_alert())] pub pending_alert: bool, /// Manage the file contents in the UI. #[serde(default, deserialize_with = "file_contents_deserializer")] #[partial_attr(serde( default, deserialize_with = "option_file_contents_deserializer" ))] #[builder(default)] pub file_contents: String, } impl ResourceSyncConfig { pub fn builder() -> ResourceSyncConfigBuilder { ResourceSyncConfigBuilder::default() } /// Checks for empty file contents, ignoring whitespace / comments. pub fn file_contents_empty(&self) -> bool { self .file_contents .split('\n') .map(str::trim) .filter(|line| !line.is_empty() && !line.starts_with('#')) .count() == 0 } } fn default_git_provider() -> String { String::from("github.com") } fn default_git_https() -> bool { true } fn default_branch() -> String { String::from("main") } fn default_webhook_enabled() -> bool { true } fn default_include_resources() -> bool { true } fn default_pending_alert() -> bool { true } impl Default for ResourceSyncConfig { fn default() -> Self { Self { linked_repo: Default::default(), git_provider: default_git_provider(), git_https: default_git_https(), repo: Default::default(), branch: default_branch(), commit: Default::default(), git_account: Default::default(), resource_path: Default::default(), files_on_host: Default::default(), file_contents: Default::default(), managed: Default::default(), include_resources: default_include_resources(), match_tags: Default::default(), include_variables: Default::default(), include_user_groups: Default::default(), delete: Default::default(), webhook_enabled: default_webhook_enabled(), webhook_secret: Default::default(), pending_alert: default_pending_alert(), } } } #[typeshare] #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SyncFileContents { /// The base resource path. #[serde(default)] pub resource_path: String, /// The path of the file / error path relative to the resource path. pub path: String, /// The contents of the file pub contents: String, } #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)] pub struct ResourceSyncActionState { /// Whether sync currently syncing pub syncing: bool, } #[typeshare] pub type ResourceSyncQuery = ResourceQuery; #[typeshare] #[derive( Serialize, Deserialize, Debug, Clone, Default, DefaultBuilder, )] pub struct ResourceSyncQuerySpecifics { /// Filter syncs by their repo. pub repos: Vec, } impl super::resource::AddFilters for ResourceSyncQuerySpecifics { fn add_filters(&self, filters: &mut Document) { if !self.repos.is_empty() { filters.insert("config.repo", doc! { "$in": &self.repos }); } } } ================================================ FILE: client/core/rs/src/entities/tag.rs ================================================ use derive_builder::Builder; use partial_derive2::Partial; use serde::{Deserialize, Serialize}; use strum::AsRefStr; use typeshare::typeshare; use crate::entities::MongoId; #[typeshare(serialized_as = "Partial")] pub type _PartialTag = PartialTag; #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Builder, Partial)] #[partial_derive(Serialize, Deserialize, Debug, Clone, Default)] #[cfg_attr( feature = "mongo", derive(mongo_indexed::derive::MongoIndexed) )] pub struct Tag { /// The Mongo ID of the tag. /// This field is de/serialized from/to JSON as /// `{ "_id": { "$oid": "..." }, ...(rest of serialized Tag) }` #[serde( default, rename = "_id", skip_serializing_if = "String::is_empty", with = "bson::serde_helpers::hex_string_as_object_id" )] #[builder(setter(skip))] pub id: MongoId, #[cfg_attr(feature = "mongo", unique_index)] pub name: String, #[serde(default)] #[builder(default)] #[cfg_attr(feature = "mongo", index)] pub owner: String, /// Hex color code with alpha for UI display #[serde(default)] #[builder(default)] pub color: TagColor, // /// This field is not stored on database, // /// but rather populated at query time based on results from the other resources. // #[serde(default, skip_serializing_if = "is_false")] // #[builder(default)] // pub unused: bool, } // fn is_false(b: &bool) -> bool { // !b // } impl Tag { pub fn builder() -> TagBuilder { TagBuilder::default() } } #[typeshare] #[derive(Serialize, Deserialize, Default, Debug, Clone, AsRefStr)] pub enum TagColor { LightSlate, #[default] Slate, DarkSlate, LightRed, Red, DarkRed, LightOrange, Orange, DarkOrange, LightAmber, Amber, DarkAmber, LightYellow, Yellow, DarkYellow, LightLime, Lime, DarkLime, LightGreen, Green, DarkGreen, LightEmerald, Emerald, DarkEmerald, LightTeal, Teal, DarkTeal, LightCyan, Cyan, DarkCyan, LightSky, Sky, DarkSky, LightBlue, Blue, DarkBlue, LightIndigo, Indigo, DarkIndigo, LightViolet, Violet, DarkViolet, LightPurple, Purple, DarkPurple, LightFuchsia, Fuchsia, DarkFuchsia, LightPink, Pink, DarkPink, LightRose, Rose, DarkRose, } ================================================ FILE: client/core/rs/src/entities/toml.rs ================================================ use indexmap::{IndexMap, IndexSet}; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use super::{ ResourceTarget, ResourceTargetVariant, action::_PartialActionConfig, alerter::_PartialAlerterConfig, build::_PartialBuildConfig, builder::_PartialBuilderConfig, deployment::_PartialDeploymentConfig, permission::{ PermissionLevel, PermissionLevelAndSpecifics, SpecificPermission, }, procedure::_PartialProcedureConfig, repo::_PartialRepoConfig, server::_PartialServerConfig, stack::_PartialStackConfig, sync::_PartialResourceSyncConfig, variable::Variable, }; /// Specifies resources to sync on Komodo #[typeshare] #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ResourcesToml { #[serde( default, alias = "server", skip_serializing_if = "Vec::is_empty" )] pub servers: Vec>, #[serde( default, alias = "deployment", skip_serializing_if = "Vec::is_empty" )] pub deployments: Vec>, #[serde( default, alias = "stack", skip_serializing_if = "Vec::is_empty" )] pub stacks: Vec>, #[serde( default, alias = "build", skip_serializing_if = "Vec::is_empty" )] pub builds: Vec>, #[serde( default, alias = "repo", skip_serializing_if = "Vec::is_empty" )] pub repos: Vec>, #[serde( default, alias = "procedure", skip_serializing_if = "Vec::is_empty" )] pub procedures: Vec>, #[serde( default, alias = "action", skip_serializing_if = "Vec::is_empty" )] pub actions: Vec>, #[serde( default, alias = "alerter", skip_serializing_if = "Vec::is_empty" )] pub alerters: Vec>, #[serde( default, alias = "builder", skip_serializing_if = "Vec::is_empty" )] pub builders: Vec>, #[serde( default, alias = "resource_sync", skip_serializing_if = "Vec::is_empty" )] pub resource_syncs: Vec>, #[serde( default, alias = "user_group", skip_serializing_if = "Vec::is_empty" )] pub user_groups: Vec, #[serde( default, alias = "variable", skip_serializing_if = "Vec::is_empty" )] pub variables: Vec, } #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ResourceToml { /// The resource name. Required pub name: String, /// The resource description. Optional. #[serde(default, skip_serializing_if = "String::is_empty")] pub description: String, /// Mark resource as a template #[serde(default, skip_serializing_if = "is_false")] pub template: bool, /// Tag ids or names. Optional #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tags: Vec, /// Optional. Only relevant for deployments / stacks. /// /// Will ensure deployment / stack is running with the latest configuration. /// Deploy actions to achieve this will be included in the sync. /// Default is false. #[serde(default, skip_serializing_if = "is_false")] pub deploy: bool, /// Optional. Only relevant for deployments / stacks using the 'deploy' sync feature. /// /// Specify other deployments / stacks by name as dependencies. /// The sync will ensure the deployment / stack will only be deployed 'after' its dependencies. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub after: Vec, /// Resource specific configuration. #[serde(default)] pub config: PartialConfig, } fn is_false(b: &bool) -> bool { !b } #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserGroupToml { /// User group name pub name: String, /// Whether all users will implicitly have the permissions in this group. #[serde(default)] pub everyone: bool, /// Users in the group #[serde(default)] pub users: Vec, /// Give the user group elevated permissions on all resources of a certain type #[serde(default)] pub all: IndexMap, /// Permissions given to the group #[serde(default, alias = "permission")] pub permissions: Vec, } #[typeshare] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PermissionToml { /// Id can be: /// - resource name. `id = "abcd-build"` /// - regex matching resource names. `id = "\^(.+)-build-([0-9]+)$\"` pub target: ResourceTarget, /// The permission level: /// - None /// - Read /// - Execute /// - Write #[serde(default)] pub level: PermissionLevel, /// Any [SpecificPermissions](SpecificPermission) on the resource #[serde(default, skip_serializing_if = "IndexSet::is_empty")] pub specific: IndexSet, } ================================================ FILE: client/core/rs/src/entities/update.rs ================================================ use async_timing_util::unix_timestamp_ms; use serde::{Deserialize, Serialize}; use strum::{Display, EnumString}; use typeshare::typeshare; use crate::entities::{ I64, MongoId, Operation, all_logs_success, komodo_timestamp, }; use super::{ResourceTarget, Version}; /// Represents an action performed by Komodo. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] #[cfg_attr( feature = "mongo", derive(mongo_indexed::derive::MongoIndexed) )] #[cfg_attr(feature = "mongo", doc_index({ "target.type": 1 }))] #[cfg_attr(feature = "mongo", sparse_doc_index({ "target.id": 1 }))] pub struct Update { /// The Mongo ID of the update. /// This field is de/serialized from/to JSON as /// `{ "_id": { "$oid": "..." }, ...(rest of serialized Update) }` #[serde( default, rename = "_id", skip_serializing_if = "String::is_empty", with = "bson::serde_helpers::hex_string_as_object_id" )] pub id: MongoId, /// The operation performed #[cfg_attr(feature = "mongo", index)] pub operation: Operation, /// The time the operation started #[cfg_attr(feature = "mongo", index)] pub start_ts: I64, /// Whether the operation was successful #[cfg_attr(feature = "mongo", index)] pub success: bool, /// The user id that triggered the update. /// /// Also can take these values for operations triggered automatically: /// - `Procedure`: The operation was triggered as part of a procedure run /// - `Github`: The operation was triggered by a github webhook /// - `Auto Redeploy`: The operation (always `Deploy`) was triggered by an attached build finishing. #[cfg_attr(feature = "mongo", index)] pub operator: String, /// The target resource to which this update refers pub target: ResourceTarget, /// Logs produced as the operation is performed pub logs: Vec, /// The time the operation completed. pub end_ts: Option, /// The status of the update /// - `Queued` /// - `InProgress` /// - `Complete` #[cfg_attr(feature = "mongo", index)] pub status: UpdateStatus, /// An optional version on the update, ie build version or deployed version. #[serde(default, skip_serializing_if = "Version::is_none")] pub version: Version, /// An optional commit hash associated with the update, ie cloned hash or deployed hash. #[serde(default, skip_serializing_if = "String::is_empty")] pub commit_hash: String, /// Some unstructured, operation specific data. Not for general usage. #[serde(default, skip_serializing_if = "String::is_empty")] pub other_data: String, /// If the update is for resource config update, give the previous toml contents #[serde(default, skip_serializing_if = "String::is_empty")] pub prev_toml: String, /// If the update is for resource config update, give the current (at time of Update) toml contents #[serde(default, skip_serializing_if = "String::is_empty")] pub current_toml: String, } impl Update { pub fn push_simple_log( &mut self, stage: &str, msg: impl Into, ) { self.logs.push(Log::simple(stage, msg.into())); } pub fn push_error_log( &mut self, stage: &str, msg: impl Into, ) { self.logs.push(Log::error(stage, msg.into())); } pub fn in_progress(&mut self) { self.status = UpdateStatus::InProgress; } pub fn finalize(&mut self) { self.success = all_logs_success(&self.logs); self.end_ts = Some(komodo_timestamp()); self.status = UpdateStatus::Complete; } } /// Minimal representation of an action performed by Komodo. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone)] pub struct UpdateListItem { /// The id of the update pub id: String, /// Which operation was run pub operation: Operation, /// The starting time of the operation pub start_ts: I64, /// Whether the operation was successful pub success: bool, /// The username of the user performing update pub username: String, /// The user id that triggered the update. /// /// Also can take these values for operations triggered automatically: /// - `Procedure`: The operation was triggered as part of a procedure run /// - `Github`: The operation was triggered by a github webhook /// - `Auto Redeploy`: The operation (always `Deploy`) was triggered by an attached build finishing. pub operator: String, /// The target resource to which this update refers pub target: ResourceTarget, /// The status of the update /// - `Queued` /// - `InProgress` /// - `Complete` pub status: UpdateStatus, /// An optional version on the update, ie build version or deployed version. #[serde(default, skip_serializing_if = "Version::is_none")] pub version: Version, /// Some unstructured, operation specific data. Not for general usage. #[serde(default, skip_serializing_if = "String::is_empty")] pub other_data: String, } /// Represents the output of some command being run #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct Log { /// A label for the log pub stage: String, /// The command which was executed pub command: String, /// The output of the command in the standard channel pub stdout: String, /// The output of the command in the error channel pub stderr: String, /// Whether the command run was successful pub success: bool, /// The start time of the command execution pub start_ts: I64, /// The end time of the command execution pub end_ts: I64, } impl Log { pub fn simple(stage: &str, msg: String) -> Log { let ts = unix_timestamp_ms() as i64; Log { stage: stage.to_string(), stdout: msg, success: true, start_ts: ts, end_ts: ts, ..Default::default() } } pub fn error(stage: &str, msg: String) -> Log { let ts = unix_timestamp_ms() as i64; Log { stage: stage.to_string(), stderr: msg, start_ts: ts, end_ts: ts, success: false, ..Default::default() } } /// Combines stdout / stderr into one log pub fn combined(&self) -> String { match (self.stdout.is_empty(), self.stderr.is_empty()) { (true, true) => { format!("stdout: {}\n\nstderr: {}", self.stdout, self.stderr) } (true, false) => self.stdout.to_string(), (false, true) => self.stderr.to_string(), (false, false) => String::from("No log"), } } } /// An update's status #[typeshare] #[derive( Serialize, Deserialize, Debug, Display, EnumString, PartialEq, Hash, Eq, Clone, Copy, Default, )] pub enum UpdateStatus { /// The run is in the system but hasn't started yet Queued, /// The run is currently running InProgress, /// The run is complete #[default] Complete, } ================================================ FILE: client/core/rs/src/entities/user.rs ================================================ use std::{collections::HashMap, sync::OnceLock}; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::entities::{I64, MongoId}; use super::{ ResourceTargetVariant, permission::PermissionLevelAndSpecifics, }; #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] #[cfg_attr( feature = "mongo", derive(mongo_indexed::derive::MongoIndexed) )] #[cfg_attr(feature = "mongo", doc_index({ "config.type": 1 }))] #[cfg_attr(feature = "mongo", sparse_doc_index({ "config.data.google_id": 1 }))] #[cfg_attr(feature = "mongo", sparse_doc_index({ "config.data.github_id": 1 }))] pub struct User { /// The Mongo ID of the User. /// This field is de/serialized from/to JSON as /// `{ "_id": { "$oid": "..." }, ...(rest of User schema) }` #[serde( default, rename = "_id", skip_serializing_if = "String::is_empty", with = "bson::serde_helpers::hex_string_as_object_id" )] pub id: MongoId, /// The globally unique username for the user. #[cfg_attr(feature = "mongo", unique_index)] pub username: String, /// Whether user is enabled / able to access the api. #[cfg_attr(feature = "mongo", index)] #[serde(default)] pub enabled: bool, /// Can give / take other users admin priviledges. #[serde(default)] pub super_admin: bool, /// Whether the user has global admin permissions. #[serde(default)] pub admin: bool, /// Whether the user has permission to create servers. #[serde(default)] pub create_server_permissions: bool, /// Whether the user has permission to create builds #[serde(default)] pub create_build_permissions: bool, /// The user-type specific config. pub config: UserConfig, /// When the user last opened updates dropdown. #[serde(default)] pub last_update_view: I64, /// Recently viewed ids #[serde(default)] pub recents: HashMap>, /// Give the user elevated permissions on all resources of a certain type #[serde(default)] pub all: IndexMap, #[serde(default)] pub updated_at: I64, } impl User { /// Prepares user object for transport by removing any sensitive fields pub fn sanitize(&mut self) { if let UserConfig::Local { .. } = &self.config { self.config = UserConfig::default(); } } /// Returns whether user is an inbuilt service user /// /// NOTE: ALSO UPDATE `frontend/src/lib/utils/is_service_user` to match pub fn is_service_user(user_id: &str) -> bool { matches!( user_id, "System" | "000000000000000000000000" | "Procedure" | "000000000000000000000001" | "Action" | "000000000000000000000002" | "Git Webhook" | "000000000000000000000003" | "Auto Redeploy" | "000000000000000000000004" | "Resource Sync" | "000000000000000000000005" | "Stack Wizard" | "000000000000000000000006" | "Build Manager" | "000000000000000000000007" | "Repo Manager" | "000000000000000000000008" ) } } pub fn admin_service_user(user_id: &str) -> Option { match user_id { "000000000000000000000000" | "System" => { system_user().to_owned().into() } "000000000000000000000001" | "Procedure" => { procedure_user().to_owned().into() } "000000000000000000000002" | "Action" => { action_user().to_owned().into() } "000000000000000000000003" | "Git Webhook" => { git_webhook_user().to_owned().into() } "000000000000000000000004" | "Auto Redeploy" => { auto_redeploy_user().to_owned().into() } "000000000000000000000005" | "Resource Sync" => { sync_user().to_owned().into() } "000000000000000000000006" | "Stack Wizard" => { stack_user().to_owned().into() } "000000000000000000000007" | "Build Manager" => { build_user().to_owned().into() } "000000000000000000000008" | "Repo Manager" => { repo_user().to_owned().into() } _ => None, } } pub fn system_user() -> &'static User { static SYSTEM_USER: OnceLock = OnceLock::new(); SYSTEM_USER.get_or_init(|| { let id_name = String::from("System"); User { id: "000000000000000000000000".to_string(), username: id_name, enabled: true, admin: true, ..Default::default() } }) } pub fn procedure_user() -> &'static User { static PROCEDURE_USER: OnceLock = OnceLock::new(); PROCEDURE_USER.get_or_init(|| { let id_name = String::from("Procedure"); User { id: "000000000000000000000001".to_string(), username: id_name, enabled: true, admin: true, ..Default::default() } }) } pub fn action_user() -> &'static User { static ACTION_USER: OnceLock = OnceLock::new(); ACTION_USER.get_or_init(|| { let id_name = String::from("Action"); User { id: "000000000000000000000002".to_string(), username: id_name, enabled: true, admin: true, ..Default::default() } }) } pub fn git_webhook_user() -> &'static User { static GIT_WEBHOOK_USER: OnceLock = OnceLock::new(); GIT_WEBHOOK_USER.get_or_init(|| { let id_name = String::from("Git Webhook"); User { id: "000000000000000000000003".to_string(), username: id_name, enabled: true, admin: true, ..Default::default() } }) } pub fn auto_redeploy_user() -> &'static User { static AUTO_REDEPLOY_USER: OnceLock = OnceLock::new(); AUTO_REDEPLOY_USER.get_or_init(|| { let id_name = String::from("Auto Redeploy"); User { id: "000000000000000000000004".to_string(), username: id_name, enabled: true, admin: true, ..Default::default() } }) } pub fn sync_user() -> &'static User { static SYNC_USER: OnceLock = OnceLock::new(); SYNC_USER.get_or_init(|| { let id_name = String::from("Resource Sync"); User { id: "000000000000000000000005".to_string(), username: id_name, enabled: true, admin: true, ..Default::default() } }) } pub fn stack_user() -> &'static User { static STACK_USER: OnceLock = OnceLock::new(); STACK_USER.get_or_init(|| { let id_name = String::from("Stack Wizard"); User { id: "000000000000000000000006".to_string(), username: id_name, enabled: true, admin: true, ..Default::default() } }) } pub fn build_user() -> &'static User { static BUILD_USER: OnceLock = OnceLock::new(); BUILD_USER.get_or_init(|| { let id_name = String::from("Build Manager"); User { id: "000000000000000000000007".to_string(), username: id_name, enabled: true, admin: true, ..Default::default() } }) } pub fn repo_user() -> &'static User { static REPO_USER: OnceLock = OnceLock::new(); REPO_USER.get_or_init(|| { let id_name = String::from("Repo Manager"); User { id: "000000000000000000000008".to_string(), username: id_name, enabled: true, admin: true, ..Default::default() } }) } #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", content = "data")] pub enum UserConfig { /// User that logs in with username / password Local { password: String }, /// User that logs in via Google Oauth Google { google_id: String, avatar: String }, /// User that logs in via Github Oauth Github { github_id: String, avatar: String }, /// User that logs in via Oidc provider Oidc { provider: String, user_id: String }, /// Non-human managed user, can have it's own permissions / api keys Service { description: String }, } impl Default for UserConfig { fn default() -> Self { Self::Local { password: String::new(), } } } ================================================ FILE: client/core/rs/src/entities/user_group.rs ================================================ use indexmap::IndexMap; use serde::{Deserialize, Serialize}; use typeshare::typeshare; use crate::deserializers::string_list_deserializer; use super::{ I64, MongoId, ResourceTargetVariant, permission::PermissionLevelAndSpecifics, }; /// Permission users at the group level. /// /// All users that are part of a group inherit the group's permissions. /// A user can be a part of multiple groups. A user's permission on a particular resource /// will be resolved to be the maximum permission level between the user's own permissions and /// any groups they are a part of. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] #[cfg_attr( feature = "mongo", derive(mongo_indexed::derive::MongoIndexed) )] pub struct UserGroup { /// The Mongo ID of the UserGroup. /// This field is de/serialized from/to JSON as /// `{ "_id": { "$oid": "..." }, ...(rest of serialized User) }` #[serde( default, rename = "_id", skip_serializing_if = "String::is_empty", with = "bson::serde_helpers::hex_string_as_object_id" )] pub id: MongoId, /// A name for the user group #[cfg_attr(feature = "mongo", unique_index)] pub name: String, /// Whether all users will implicitly have the permissions in this group. #[cfg_attr(feature = "mongo", index)] #[serde(default)] pub everyone: bool, /// User ids of group members #[cfg_attr(feature = "mongo", index)] #[serde(default, deserialize_with = "string_list_deserializer")] pub users: Vec, /// Give the user group elevated permissions on all resources of a certain type #[serde(default)] pub all: IndexMap, /// Unix time (ms) when user group last updated #[serde(default)] pub updated_at: I64, } ================================================ FILE: client/core/rs/src/entities/variable.rs ================================================ use serde::{Deserialize, Serialize}; use typeshare::typeshare; /// A non-secret global variable which can be interpolated into deployment /// environment variable values and build argument values. #[typeshare] #[derive(Serialize, Deserialize, Debug, Clone, Default)] #[cfg_attr( feature = "mongo", derive(mongo_indexed::derive::MongoIndexed) )] pub struct Variable { /// Unique name associated with the variable. /// Instances of '[[variable.name]]' in value will be replaced with 'variable.value'. #[cfg_attr(feature = "mongo", unique_index)] pub name: String, /// A description for the variable. #[serde(default, skip_serializing_if = "String::is_empty")] pub description: String, /// The value associated with the variable. #[serde(default)] pub value: String, /// If marked as secret, the variable value will be hidden in updates / logs. /// Additionally the value will not be served in read requests by non admin users. /// /// Note that the value is NOT encrypted in the database, and will likely show up in database logs. /// The security of these variables comes down to the security /// of the database (system level encryption, network isolation, etc.) #[serde(default)] pub is_secret: bool, } ================================================ FILE: client/core/rs/src/lib.rs ================================================ //! # Komodo //! *A system to build and deploy software across many servers*. [**https://komo.do**](https://komo.do) //! //! This is a client library for the Komodo Core API. //! It contains: //! - Definitions for the application [api] and [entities]. //! - A [client][KomodoClient] to interact with the Komodo Core API. //! - Information on configuring Komodo [Core][entities::config::core] and [Periphery][entities::config::periphery]. //! //! ## Client Configuration //! //! The client includes a convenenience method to parse the Komodo API url and credentials from the environment: //! - `KOMODO_ADDRESS` //! - `KOMODO_API_KEY` //! - `KOMODO_API_SECRET` //! //! ## Client Example //! ```text //! dotenvy::dotenv().ok(); //! //! let client = KomodoClient::new_from_env()?; //! //! // Get all the deployments //! let deployments = client.read(ListDeployments::default()).await?; //! //! println!("{deployments:#?}"); //! //! let update = client.execute(RunBuild { build: "test-build".to_string() }).await?: //! ``` use std::{sync::OnceLock, time::Duration}; use anyhow::Context; use api::read::GetVersion; use serde::Deserialize; pub mod api; pub mod busy; pub mod deserializers; pub mod entities; pub mod parsers; pub mod terminal; pub mod ws; mod request; /// &'static KomodoClient initialized from environment. pub fn komodo_client() -> &'static KomodoClient { static KOMODO_CLIENT: OnceLock = OnceLock::new(); KOMODO_CLIENT.get_or_init(|| { KomodoClient::new_from_env() .context("Missing KOMODO_ADDRESS, KOMODO_API_KEY, KOMODO_API_SECRET from env") .unwrap() }) } /// Default environment variables for the [KomodoClient]. #[derive(Deserialize)] pub struct KomodoEnv { /// KOMODO_ADDRESS pub komodo_address: String, /// KOMODO_API_KEY pub komodo_api_key: String, /// KOMODO_API_SECRET pub komodo_api_secret: String, } /// Client to interface with [Komodo](https://komo.do/docs/api#rust-client) #[derive(Clone)] pub struct KomodoClient { #[cfg(not(feature = "blocking"))] reqwest: reqwest::Client, #[cfg(feature = "blocking")] reqwest: reqwest::blocking::Client, address: String, key: String, secret: String, } impl KomodoClient { /// Initializes KomodoClient, including a health check. pub fn new( address: impl Into, key: impl Into, secret: impl Into, ) -> KomodoClient { KomodoClient { reqwest: Default::default(), address: address.into(), key: key.into(), secret: secret.into(), } } /// Initializes KomodoClient from environment: [KomodoEnv] pub fn new_from_env() -> anyhow::Result { let KomodoEnv { komodo_address, komodo_api_key, komodo_api_secret, } = envy::from_env() .context("failed to parse environment for komodo client")?; Ok(KomodoClient::new( komodo_address, komodo_api_key, komodo_api_secret, )) } /// Add a healthcheck in the initialization pipeline: /// /// ```text /// let komodo = KomodoClient::new_from_env()? /// .with_healthcheck().await?; /// ``` #[cfg(not(feature = "blocking"))] pub async fn with_healthcheck(self) -> anyhow::Result { self.health_check().await?; Ok(self) } /// Add a healthcheck in the initialization pipeline: /// /// ```text /// let komodo = KomodoClient::new_from_env()? /// .with_healthcheck().await?; /// ``` #[cfg(feature = "blocking")] pub fn with_healthcheck(self) -> anyhow::Result { self.health_check()?; Ok(self) } /// Get the Core version. #[cfg(not(feature = "blocking"))] pub async fn core_version(&self) -> anyhow::Result { self.read(GetVersion {}).await.map(|r| r.version) } /// Get the Core version. #[cfg(feature = "blocking")] pub fn core_version(&self) -> anyhow::Result { self.read(GetVersion {}).map(|r| r.version) } /// Send a health check. #[cfg(not(feature = "blocking"))] pub async fn health_check(&self) -> anyhow::Result<()> { self.read(GetVersion {}).await.map(|_| ()) } /// Send a health check. #[cfg(feature = "blocking")] pub fn health_check(&self) -> anyhow::Result<()> { self.read(GetVersion {}).map(|_| ()) } /// Use a custom reqwest client. #[cfg(not(feature = "blocking"))] pub fn set_reqwest(mut self, reqwest: reqwest::Client) -> Self { self.reqwest = reqwest; self } /// Use a custom reqwest client. #[cfg(feature = "blocking")] pub fn set_reqwest( mut self, reqwest: reqwest::blocking::Client, ) -> Self { self.reqwest = reqwest; self } /// Poll an [Update][entities::update::Update] (returned by the `execute` calls) until the /// [UpdateStatus][entities::update::UpdateStatus] is `Complete`, and then return it. #[cfg(not(feature = "blocking"))] pub async fn poll_update_until_complete( &self, update_id: impl Into, ) -> anyhow::Result { let update_id = update_id.into(); loop { let update = self .read(api::read::GetUpdate { id: update_id.clone(), }) .await?; if update.status == entities::update::UpdateStatus::Complete { return Ok(update); } tokio::time::sleep(Duration::from_millis(500)).await; } } /// Poll an [Update][entities::update::Update] (returned by the `execute` calls) until the /// [UpdateStatus][entities::update::UpdateStatus] is `Complete`, and then return it. #[cfg(feature = "blocking")] pub fn poll_update_until_complete( &self, update_id: impl Into, ) -> anyhow::Result { let update_id = update_id.into(); loop { let update = self.read(api::read::GetUpdate { id: update_id.clone(), })?; if update.status == entities::update::UpdateStatus::Complete { return Ok(update); } } } } ================================================ FILE: client/core/rs/src/parsers.rs ================================================ use anyhow::Context; pub const QUOTE_PATTERN: &[char] = &['"', '\'']; /// Parses a list of key value pairs from a multiline string /// /// Example source: /// ```text /// # Supports comments /// KEY_1 = value_1 # end of line comments /// /// # Supports string wrapped values /// KEY_2="value_2" /// 'KEY_3 = value_3' /// /// # Also supports yaml list formats /// - KEY_4: 'value_4' /// - "KEY_5=value_5" /// /// # Wrapping outer quotes are removed while inner quotes are preserved /// "KEY_6 = 'value_6'" /// ``` /// /// Note this preserves the wrapping string around value. /// Writing environment file should format the value exactly as it comes in, /// including the given wrapping quotes. /// /// Returns: /// ```text /// [ /// ("KEY_1", "value_1"), /// ("KEY_2", "\"value_2\""), /// ("KEY_3", "value_3"), /// ("KEY_4", "'value_4'"), /// ("KEY_5", "value_5"), /// ("KEY_6", "'value_6'"), /// ] /// ``` pub fn parse_key_value_list( input: &str, ) -> anyhow::Result> { let trimmed = input.trim(); if trimmed.is_empty() { return Ok(Vec::new()); } trimmed .split('\n') .map(|line| line.trim()) .enumerate() .filter(|(_, line)| { !line.is_empty() && !line.starts_with('#') && !line.starts_with("//") }) .map(|(i, line)| { let line = line // Remove end of line comments .split_once(" #") .unwrap_or((line, "")) .0 .trim() // Remove preceding '-' (yaml list) .trim_start_matches('-') .trim(); let (key, value) = line .split_once(['=', ':']) .with_context(|| { format!( "line {i} missing assignment character ('=' or ':')" ) }) .map(|(key, value)| { let key = key.trim(); let value = value.trim(); // Remove wrapping quotes when around key AND value let (key, value) = if key.starts_with(QUOTE_PATTERN) && !key.ends_with(QUOTE_PATTERN) && value.ends_with(QUOTE_PATTERN) { ( key.strip_prefix(QUOTE_PATTERN).unwrap().trim(), value.strip_suffix(QUOTE_PATTERN).unwrap().trim(), ) } else { (key, value) }; (key.to_string(), value.to_string()) })?; anyhow::Ok((key, value)) }) .collect::>>() } /// Parses commands out of multiline string /// and chains them together with '&&' /// /// Supports full line and end of line comments, and escaped newlines. /// /// ## Example: /// ```sh /// # comments supported /// sh ./shell1.sh # end of line supported /// sh ./shell2.sh /// /// # escaped newlines supported /// curl --header "Content-Type: application/json" \ /// --request POST \ /// --data '{"key": "value"}' \ /// https://destination.com /// /// # print done /// echo done /// ``` /// becomes /// ```sh /// sh ./shell1.sh && sh ./shell2.sh && {long curl command} && echo done /// ``` pub fn parse_multiline_command(command: impl AsRef) -> String { command .as_ref() // Remove comments and join back .split('\n') .map(str::trim) .filter(|line| !line.is_empty() && !line.starts_with('#')) .filter_map(|line| line.split(" #").next()) .collect::>() .join("\n") // Remove escaped newlines .split(" \\") .map(str::trim) .fold(String::new(), |acc, el| acc + " " + el) // Then final split by newlines and join with && .split('\n') .map(str::trim) .filter(|line| !line.is_empty() && !line.starts_with('#')) .filter_map(|line| line.split(" #").next()) .map(str::trim) .collect::>() .join(" && ") } /// Parses a list of strings from a comment seperated and multiline string /// /// Example source: /// ```text /// # supports comments /// path/to/file1 # comment1 /// path/to/file2 /// /// # also supports comma seperated values /// path/to/file3,path/to/file4 /// ``` /// /// Returns: /// ```text /// ["path/to/file1", "path/to/file2", "path/to/file3", "path/to/file4"] /// ``` pub fn parse_string_list(source: impl AsRef) -> Vec { source .as_ref() .split('\n') .map(str::trim) .filter(|line| !line.is_empty() && !line.starts_with('#')) .filter_map(|line| line.split(" #").next()) .flat_map(|line| line.split(',')) .map(str::trim) .filter(|entry| !entry.is_empty()) .map(str::to_string) .collect() } ================================================ FILE: client/core/rs/src/request.rs ================================================ use anyhow::{Context, anyhow}; use serde::{Serialize, de::DeserializeOwned}; use serde_json::json; use serror::deserialize_error; use crate::{ KomodoClient, api::{ auth::KomodoAuthRequest, execute::KomodoExecuteRequest, read::KomodoReadRequest, user::KomodoUserRequest, write::KomodoWriteRequest, }, }; impl KomodoClient { #[cfg(not(feature = "blocking"))] pub async fn auth( &self, request: T, ) -> anyhow::Result where T: Serialize + KomodoAuthRequest, T::Response: DeserializeOwned, { self .post( "/auth", json!({ "type": T::req_type(), "params": request }), ) .await } #[cfg(feature = "blocking")] pub fn auth(&self, request: T) -> anyhow::Result where T: Serialize + KomodoAuthRequest, T::Response: DeserializeOwned, { self.post( "/auth", json!({ "type": T::req_type(), "params": request }), ) } #[cfg(not(feature = "blocking"))] pub async fn user( &self, request: T, ) -> anyhow::Result where T: Serialize + KomodoUserRequest, T::Response: DeserializeOwned, { self .post( "/auth", json!({ "type": T::req_type(), "params": request }), ) .await } #[cfg(feature = "blocking")] pub fn user(&self, request: T) -> anyhow::Result where T: Serialize + KomodoUserRequest, T::Response: DeserializeOwned, { self.post( "/auth", json!({ "type": T::req_type(), "params": request }), ) } #[cfg(not(feature = "blocking"))] pub async fn read( &self, request: T, ) -> anyhow::Result where T: Serialize + KomodoReadRequest, T::Response: DeserializeOwned, { self .post( "/read", json!({ "type": T::req_type(), "params": request }), ) .await } #[cfg(feature = "blocking")] pub fn read(&self, request: T) -> anyhow::Result where T: Serialize + KomodoReadRequest, T::Response: DeserializeOwned, { self.post( "/read", json!({ "type": T::req_type(), "params": request }), ) } #[cfg(not(feature = "blocking"))] pub async fn write( &self, request: T, ) -> anyhow::Result where T: Serialize + KomodoWriteRequest, T::Response: DeserializeOwned, { self .post( "/write", json!({ "type": T::req_type(), "params": request }), ) .await } #[cfg(feature = "blocking")] pub fn write(&self, request: T) -> anyhow::Result where T: Serialize + KomodoWriteRequest, T::Response: DeserializeOwned, { self.post( "/write", json!({ "type": T::req_type(), "params": request }), ) } #[cfg(not(feature = "blocking"))] pub async fn execute( &self, request: T, ) -> anyhow::Result where T: Serialize + KomodoExecuteRequest, T::Response: DeserializeOwned, { self .post( "/execute", json!({ "type": T::req_type(), "params": request }), ) .await } #[cfg(feature = "blocking")] pub fn execute(&self, request: T) -> anyhow::Result where T: Serialize + KomodoExecuteRequest, T::Response: DeserializeOwned, { self.post( "/execute", json!({ "type": T::req_type(), "params": request }), ) } #[cfg(not(feature = "blocking"))] async fn post< B: Serialize + std::fmt::Debug, R: DeserializeOwned, >( &self, endpoint: &str, body: B, ) -> anyhow::Result { let req = self .reqwest .post(format!("{}{endpoint}", self.address)) .header("x-api-key", &self.key) .header("x-api-secret", &self.secret) .header("content-type", "application/json") .json(&body); let res = req.send().await.context("failed to reach Komodo API")?; let status = res.status(); if status.is_success() { match res.json().await { Ok(res) => Ok(res), Err(e) => Err(anyhow!("{e:#?}").context(status)), } } else { match res.text().await { Ok(res) => Err(deserialize_error(res).context(status)), Err(e) => Err(anyhow!("{e:?}").context(status)), } } } #[cfg(feature = "blocking")] fn post( &self, endpoint: &str, body: B, ) -> anyhow::Result { let req = self .reqwest .post(format!("{}{endpoint}", self.address)) .header("x-api-key", &self.key) .header("x-api-secret", &self.secret) .header("content-type", "application/json") .json(&body); let res = req.send().context("failed to reach Komodo API")?; let status = res.status(); if status.is_success() { match res.json() { Ok(res) => Ok(res), Err(e) => Err(anyhow!("{e:#?}").context(status)), } } else { match res.text() { Ok(res) => Err(deserialize_error(res).context(status)), Err(e) => Err(anyhow!("{e:?}").context(status)), } } } } ================================================ FILE: client/core/rs/src/terminal.rs ================================================ use futures::{Stream, StreamExt, TryStreamExt}; pub struct TerminalStreamResponse(pub reqwest::Response); impl TerminalStreamResponse { pub fn into_line_stream( self, ) -> impl Stream> { tokio_util::codec::FramedRead::new( tokio_util::io::StreamReader::new( self.0.bytes_stream().map_err(std::io::Error::other), ), tokio_util::codec::LinesCodec::new(), ) .map(|line| line.map(|line| line + "\n")) } } ================================================ FILE: client/core/rs/src/ws.rs ================================================ use std::time::Duration; use anyhow::Context; use futures::{SinkExt, TryStreamExt}; use serde::{Deserialize, Serialize}; use serror::serialize_error; use thiserror::Error; use tokio::sync::broadcast; use tokio_tungstenite::{connect_async, tungstenite::Message}; use tokio_util::sync::CancellationToken; use tracing::{Instrument, debug, info, info_span, warn}; use typeshare::typeshare; use uuid::Uuid; use crate::{KomodoClient, entities::update::UpdateListItem}; #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", content = "params")] pub enum WsLoginMessage { Jwt { jwt: String }, ApiKeys { key: String, secret: String }, } impl WsLoginMessage { pub fn from_json_str(json: &str) -> anyhow::Result { serde_json::from_str(json) .context("failed to parse json as WsLoginMessage") } pub fn to_json_string(&self) -> anyhow::Result { serde_json::to_string(self) .context("failed to serialize WsLoginMessage to json string") } } #[derive(Debug, Clone)] pub enum UpdateWsMessage { Update(UpdateListItem), Error(UpdateWsError), Disconnected, Reconnected, } #[derive(Error, Debug, Clone)] pub enum UpdateWsError { #[error("failed to connect | {0}")] ConnectionError(String), #[error("failed to login | {0}")] LoginError(String), #[error("failed to recieve message | {0}")] MessageError(String), #[error("did not recognize message | {0}")] MessageUnrecognized(String), } const MAX_SHORT_RETRY_COUNT: usize = 5; impl KomodoClient { /// Subscribes to the Komodo Core update websocket, /// and forwards the updates over a channel. /// Handles reconnection internally. /// /// ```text /// let (mut rx, _) = komodo.subscribe_to_updates()?; /// loop { /// let update = match rx.recv().await { /// Ok(msg) => msg, /// Err(e) => { /// error!("🚨 recv error | {e:?}"); /// break; /// } /// }; /// // Handle the update /// info!("Got update: {update:?}"); /// } /// ``` pub fn subscribe_to_updates( &self, // retry_cooldown_secs: u64, ) -> anyhow::Result<( broadcast::Receiver, CancellationToken, )> { let (tx, rx) = broadcast::channel(128); let cancel = CancellationToken::new(); let cancel_clone = cancel.clone(); let address = format!("{}/ws/update", self.address.replacen("http", "ws", 1)); let login_msg = WsLoginMessage::ApiKeys { key: self.key.clone(), secret: self.secret.clone(), } .to_json_string()?; tokio::spawn(async move { let master_uuid = Uuid::new_v4(); loop { // OUTER LOOP (LONG RECONNECT) if cancel.is_cancelled() { break; } let outer_uuid = Uuid::new_v4(); let span = info_span!( "Outer Loop", master_uuid = format!("{master_uuid}"), outer_uuid = format!("{outer_uuid}") ); async { debug!("Entering inner (connection) loop | outer uuid {outer_uuid} | master uuid {master_uuid}"); let mut retry = 0; loop { // INNER LOOP (SHORT RECONNECT) if cancel.is_cancelled() { break; } if retry >= MAX_SHORT_RETRY_COUNT { break; } let inner_uuid = Uuid::new_v4(); let span = info_span!( "Inner Loop", master_uuid = format!("{master_uuid}"), outer_uuid = format!("{outer_uuid}"), inner_uuid = format!("{inner_uuid}") ); async { debug!("Connecting to websocket | inner uuid {inner_uuid} | outer uuid {outer_uuid} | master uuid {master_uuid}"); let mut ws = match connect_async(&address).await.with_context(|| { format!( "failed to connect to Komodo update websocket at {address}" ) }) { Ok((ws, _)) => ws, Err(e) => { let _ = tx.send(UpdateWsMessage::Error( UpdateWsError::ConnectionError(serialize_error(&e)), )); warn!("{e:#}"); retry += 1; return; } }; debug!("Connected to websocket | inner uuid {inner_uuid} | outer uuid {outer_uuid} | master uuid {master_uuid}"); // ================== // SEND LOGIN MSG // ================== let login_send_res = ws .send(Message::text(&login_msg)) .await .context("failed to send login message"); if let Err(e) = login_send_res { let _ = tx.send(UpdateWsMessage::Error( UpdateWsError::LoginError(serialize_error(&e)), )); warn!("breaking inner loop | {e:#} | inner uuid {inner_uuid} | outer uuid {outer_uuid} | master uuid {master_uuid}"); retry += 1; return; } // ================== // HANDLE LOGIN RES // ================== match ws.try_next().await { Ok(Some(Message::Text(msg))) => { if msg != "LOGGED_IN" { let _ = tx.send(UpdateWsMessage::Error( UpdateWsError::LoginError(msg.to_string()), )); let _ = ws.close(None).await; warn!("breaking inner loop | got msg {msg} instead of 'LOGGED_IN' | inner uuid {inner_uuid} | outer uuid {outer_uuid} | master uuid {master_uuid}"); retry += 1; return; } } Ok(Some(msg)) => { let _ = tx.send(UpdateWsMessage::Error( UpdateWsError::LoginError(format!("{msg:#?}")), )); let _ = ws.close(None).await; warn!("breaking inner loop | got msg {msg} instead of Message::Text 'LOGGED_IN' | inner uuid {inner_uuid} | outer uuid {outer_uuid} | master uuid {master_uuid}"); retry += 1; return; } Ok(None) => { let _ = tx.send(UpdateWsMessage::Error( UpdateWsError::LoginError(String::from( "got None message after login message", )), )); let _ = ws.close(None).await; warn!("breaking inner loop | got None instead of 'LOGGED_IN' | inner uuid {inner_uuid} | outer uuid {outer_uuid} | master uuid {master_uuid}"); retry += 1; return; } Err(e) => { let _ = tx.send(UpdateWsMessage::Error( UpdateWsError::LoginError(format!( "failed to recieve message | {e:#?}" )), )); let _ = ws.close(None).await; warn!("breaking inner loop | got error msg | {e:?} | inner uuid {inner_uuid} | outer uuid {outer_uuid} | master uuid {master_uuid}"); retry += 1; return; } } let _ = tx.send(UpdateWsMessage::Reconnected); info!("Logged into websocket | inner uuid {inner_uuid} | outer uuid {outer_uuid} | master uuid {master_uuid}"); // If we get to this point (connected / logged in) reset the short retry counter retry = 0; // ================== // HANLDE MSGS // ================== loop { match ws .try_next() .await .context("failed to recieve message") { Ok(Some(Message::Text(msg))) => { match serde_json::from_str::(&msg) { Ok(msg) => { debug!( "got recognized message: {msg:?} | inner uuid {inner_uuid} | outer uuid {outer_uuid} | master uuid {master_uuid}" ); let _ = tx.send(UpdateWsMessage::Update(msg)); } Err(_) => { warn!( "got unrecognized message: {msg:?} | inner uuid {inner_uuid} | outer uuid {outer_uuid} | master uuid {master_uuid}" ); let _ = tx.send(UpdateWsMessage::Error( UpdateWsError::MessageUnrecognized(msg.to_string()), )); } } } Ok(Some(Message::Close(_))) => { let _ = tx.send(UpdateWsMessage::Disconnected); let _ = ws.close(None).await; warn!( "breaking inner loop | got close message | inner uuid {inner_uuid} | outer uuid {outer_uuid} | master uuid {master_uuid}" ); break; } Err(e) => { let _ = tx.send(UpdateWsMessage::Error( UpdateWsError::MessageError(serialize_error(&e)), )); let _ = tx.send(UpdateWsMessage::Disconnected); let _ = ws.close(None).await; warn!( "breaking inner loop | got error message | {e:#} | inner uuid {inner_uuid} | outer uuid {outer_uuid} | master uuid {master_uuid}" ); break; } Ok(_) => { // ignore } } } } .instrument(span) .await; } }.instrument(span).await; tokio::time::sleep(Duration::from_secs(3)).await; } }); Ok((rx, cancel_clone)) } } ================================================ FILE: client/core/ts/README.md ================================================ # Komodo _A system to build and deploy software across many servers_. [https://komo.do](https://komo.do) ```sh npm install komodo_client ``` or ```sh yarn add komodo_client ``` ```ts import { KomodoClient, Types } from "komodo_client"; const komodo = KomodoClient("https://demo.komo.do", { type: "api-key", params: { key: "your_key", secret: "your secret", }, }); // Inferred as Types.StackListItem[] const stacks = await komodo.read("ListStacks", {}); // Inferred as Types.Stack const stack = await komodo.read("GetStack", { stack: stacks[0].name, }); ``` ================================================ FILE: client/core/ts/generate_types.mjs ================================================ import { exec } from "child_process"; import { readFileSync, writeFileSync } from "fs"; import path from "path"; import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); console.log("generating typescript types..."); const gen_command = "RUST_BACKTRACE=1 typeshare . --lang=typescript --output-file=./client/core/ts/src/types.ts"; exec(gen_command, (error, _stdout, _stderr) => { if (error) { console.error(error); return; } console.log("generated types using typeshare"); fix_types(); console.log("finished."); }); function fix_types() { const types_path = __dirname + "/src/types.ts"; const contents = readFileSync(types_path); const fixed = contents .toString() // Replace Variants .replaceAll("ResourceTargetVariant", 'ResourceTarget["type"]') .replaceAll("AlerterEndpointVariant", 'AlerterEndpoint["type"]') .replaceAll("AlertDataVariant", 'AlertData["type"]') .replaceAll("ServerTemplateConfigVariant", 'ServerTemplateConfig["type"]') // Add '| string' to env vars .replaceAll("EnvironmentVar[]", "EnvironmentVar[] | string") .replaceAll("IndexSet", "Array") .replaceAll( ": PermissionLevelAndSpecifics", ": PermissionLevelAndSpecifics | PermissionLevel" ) .replaceAll( ", PermissionLevelAndSpecifics", ", PermissionLevelAndSpecifics | PermissionLevel" ) .replaceAll("IndexMap", "Record"); writeFileSync(types_path, fixed); } ================================================ FILE: client/core/ts/package.json ================================================ { "name": "komodo_client", "version": "1.19.5", "description": "Komodo client package", "homepage": "https://komo.do", "main": "dist/lib.js", "type": "module", "license": "GPL-3.0", "publishConfig": { "access": "public" }, "scripts": { "build": "tsc" }, "dependencies": {}, "devDependencies": { "typescript": "^5.6.3" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } ================================================ FILE: client/core/ts/runfile.toml ================================================ [publish-ts-client] description = "publish the typescript client to npm" cmd = "npm publish" ================================================ FILE: client/core/ts/src/lib.ts ================================================ import { AuthResponses, ExecuteResponses, ReadResponses, UserResponses, WriteResponses, } from "./responses.js"; import { terminal_methods, ConnectExecQuery, ExecuteExecBody, TerminalCallbacks, } from "./terminal.js"; import { AuthRequest, BatchExecutionResponse, ConnectTerminalQuery, ExecuteRequest, ExecuteTerminalBody, ReadRequest, Update, UpdateListItem, UpdateStatus, UserRequest, WriteRequest, WsLoginMessage, } from "./types.js"; export * as Types from "./types.js"; export type { ConnectExecQuery, ExecuteExecBody, TerminalCallbacks }; export type InitOptions = | { type: "jwt"; params: { jwt: string } } | { type: "api-key"; params: { key: string; secret: string } }; export class CancelToken { cancelled: boolean; constructor() { this.cancelled = false; } cancel() { this.cancelled = true; } } export type ClientState = { jwt: string | undefined; key: string | undefined; secret: string | undefined; }; /** Initialize a new client for Komodo */ export function KomodoClient(url: string, options: InitOptions) { const state: ClientState = { jwt: options.type === "jwt" ? options.params.jwt : undefined, key: options.type === "api-key" ? options.params.key : undefined, secret: options.type === "api-key" ? options.params.secret : undefined, }; const request = ( path: "/auth" | "/user" | "/read" | "/execute" | "/write", type: string, params: Params ): Promise => new Promise(async (res, rej) => { try { let response = await fetch(`${url}${path}/${type}`, { method: "POST", body: JSON.stringify(params), headers: { ...(state.jwt ? { authorization: state.jwt, } : state.key && state.secret ? { "x-api-key": state.key, "x-api-secret": state.secret, } : {}), "content-type": "application/json", }, }); if (response.status === 200) { const body: Res = await response.json(); res(body); } else { try { const result = await response.json(); rej({ status: response.status, result }); } catch (error) { rej({ status: response.status, result: { error: "Failed to get response body", trace: [JSON.stringify(error)], }, error, }); } } } catch (error) { rej({ status: 1, result: { error: "Request failed with error", trace: [JSON.stringify(error)], }, error, }); } }); const auth = async < T extends AuthRequest["type"], Req extends Extract >( type: T, params: Req["params"] ) => await request( "/auth", type, params ); const user = async < T extends UserRequest["type"], Req extends Extract >( type: T, params: Req["params"] ) => await request( "/user", type, params ); const read = async < T extends ReadRequest["type"], Req extends Extract >( type: T, params: Req["params"] ) => await request( "/read", type, params ); const write = async < T extends WriteRequest["type"], Req extends Extract >( type: T, params: Req["params"] ) => await request( "/write", type, params ); const execute = async < T extends ExecuteRequest["type"], Req extends Extract >( type: T, params: Req["params"] ) => await request( "/execute", type, params ); const execute_and_poll = async < T extends ExecuteRequest["type"], Req extends Extract >( type: T, params: Req["params"] ) => { const res = await execute(type, params); // Check if its a batch of updates or a single update; if (Array.isArray(res)) { const batch = res as any as BatchExecutionResponse; return await Promise.all( batch.map(async (item) => { if (item.status === "Err") { return item; } return await poll_update_until_complete(item.data._id?.$oid!); }) ); } else { // it is a single update const update = res as any as Update; if (update.status === UpdateStatus.Complete || !update._id?.$oid) { return update; } return await poll_update_until_complete(update._id?.$oid!); } }; const poll_update_until_complete = async (update_id: string) => { while (true) { await new Promise((resolve) => setTimeout(resolve, 1000)); const update = await read("GetUpdate", { id: update_id }); if (update.status === UpdateStatus.Complete) { return update; } } }; const core_version = () => read("GetVersion", {}).then((res) => res.version); const get_update_websocket = ({ on_update, on_login, on_open, on_close, }: { on_update: (update: UpdateListItem) => void; on_login?: () => void; on_open?: () => void; on_close?: () => void; }) => { const ws = new WebSocket(url.replace("http", "ws") + "/ws/update"); // Handle login on websocket open ws.addEventListener("open", () => { on_open?.(); const login_msg: WsLoginMessage = options.type === "jwt" ? { type: "Jwt", params: { jwt: options.params.jwt, }, } : { type: "ApiKeys", params: { key: options.params.key, secret: options.params.secret, }, }; ws.send(JSON.stringify(login_msg)); }); ws.addEventListener("message", ({ data }: MessageEvent) => { if (data == "LOGGED_IN") return on_login?.(); on_update(JSON.parse(data)); }); if (on_close) { ws.addEventListener("close", on_close); } return ws; }; const subscribe_to_update_websocket = async ({ on_update, on_open, on_login, on_close, retry = true, retry_timeout_ms = 5_000, cancel = new CancelToken(), on_cancel, }: { on_update: (update: UpdateListItem) => void; on_login?: () => void; on_open?: () => void; on_close?: () => void; retry?: boolean; retry_timeout_ms?: number; cancel?: CancelToken; on_cancel?: () => void; }) => { while (true) { if (cancel.cancelled) { on_cancel?.(); return; } try { const ws = get_update_websocket({ on_open, on_login, on_update, on_close, }); // This while loop will end when the socket is closed while ( ws.readyState !== WebSocket.CLOSING && ws.readyState !== WebSocket.CLOSED ) { if (cancel.cancelled) ws.close(); // Sleep for a bit before checking for websocket closed await new Promise((resolve) => setTimeout(resolve, 500)); } if (retry) { // Sleep for a bit before retrying connection to avoid spam. await new Promise((resolve) => setTimeout(resolve, retry_timeout_ms)); } else { return; } } catch (error) { console.error(error); if (retry) { // Sleep for a bit before retrying, maybe Komodo Core is down temporarily. await new Promise((resolve) => setTimeout(resolve, retry_timeout_ms)); } else { return; } } } }; const { connect_terminal, execute_terminal, execute_terminal_stream, connect_exec, connect_container_exec, execute_container_exec, execute_container_exec_stream, connect_deployment_exec, execute_deployment_exec, execute_deployment_exec_stream, connect_stack_exec, execute_stack_exec, execute_stack_exec_stream, } = terminal_methods(url, state); return { /** * Call the `/auth` api. * * ``` * const login_options = await komodo.auth("GetLoginOptions", {}); * ``` * * https://docs.rs/komodo_client/latest/komodo_client/api/auth/index.html */ auth, /** * Call the `/user` api. * * ``` * const { key, secret } = await komodo.user("CreateApiKey", { * name: "my-api-key" * }); * ``` * * https://docs.rs/komodo_client/latest/komodo_client/api/user/index.html */ user, /** * Call the `/read` api. * * ``` * const stack = await komodo.read("GetStack", { * stack: "my-stack" * }); * ``` * * https://docs.rs/komodo_client/latest/komodo_client/api/read/index.html */ read, /** * Call the `/write` api. * * ``` * const build = await komodo.write("UpdateBuild", { * id: "my-build", * config: { * version: "1.0.4" * } * }); * ``` * * https://docs.rs/komodo_client/latest/komodo_client/api/write/index.html */ write, /** * Call the `/execute` api. * * ``` * const update = await komodo.execute("DeployStack", { * stack: "my-stack" * }); * ``` * * NOTE. These calls return immediately when the update is created, NOT when the execution task finishes. * To have the call only return when the task finishes, use [execute_and_poll_until_complete]. * * https://docs.rs/komodo_client/latest/komodo_client/api/execute/index.html */ execute, /** * Call the `/execute` api, and poll the update until the task has completed. * * ``` * const update = await komodo.execute_and_poll("DeployStack", { * stack: "my-stack" * }); * ``` * * https://docs.rs/komodo_client/latest/komodo_client/api/execute/index.html */ execute_and_poll, /** * Poll an Update (returned by the `execute` calls) until the `status` is `Complete`. * https://docs.rs/komodo_client/latest/komodo_client/entities/update/struct.Update.html#structfield.status. */ poll_update_until_complete, /** Returns the version of Komodo Core the client is calling to. */ core_version, /** * Connects to update websocket, performs login and attaches handlers, * and returns the WebSocket handle. */ get_update_websocket, /** * Subscribes to the update websocket with automatic reconnect loop. * * Note. Awaiting this method will never finish. */ subscribe_to_update_websocket, /** * Subscribes to terminal io over websocket message, * for use with xtermjs. */ connect_terminal, /** * Executes a command on a given Server / terminal, * and gives a callback to handle the output as it comes in. * * ```ts * await komodo.execute_terminal( * { * server: "my-server", * terminal: "name", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }, * { * onLine: (line) => console.log(line), * onFinish: (code) => console.log("Finished:", code), * } * ); * ``` */ execute_terminal, /** * Executes a command on a given Server / terminal, * and returns a stream to process the output as it comes in. * * Note. The final line of the stream will usually be * `__KOMODO_EXIT_CODE__:0`. The number * is the exit code of the command. * * If this line is NOT present, it means the stream * was terminated early, ie like running `exit`. * * ```ts * const stream = await komodo.execute_terminal_stream({ * server: "my-server", * terminal: "name", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }); * * for await (const line of stream) { * console.log(line); * } * ``` */ execute_terminal_stream, /** * Subscribes to container exec io over websocket message, * for use with xtermjs. Can connect to container on a Server, * or associated with a Deployment or Stack. * Terminal permission on connecting resource required. */ connect_exec, /** * Subscribes to container exec io over websocket message, * for use with xtermjs. Can connect to Container on a Server. * Server Terminal permission required. */ connect_container_exec, /** * Executes a command on a given container, * and gives a callback to handle the output as it comes in. * * ```ts * await komodo.execute_container_exec( * { * server: "my-server", * container: "name", * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }, * { * onLine: (line) => console.log(line), * onFinish: (code) => console.log("Finished:", code), * } * ); * ``` */ execute_container_exec, /** * Executes a command on a given container, * and returns a stream to process the output as it comes in. * * Note. The final line of the stream will usually be * `__KOMODO_EXIT_CODE__:0`. The number * is the exit code of the command. * * If this line is NOT present, it means the stream * was terminated early, ie like running `exit`. * * ```ts * const stream = await komodo.execute_container_exec_stream({ * server: "my-server", * container: "name", * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }); * * for await (const line of stream) { * console.log(line); * } * ``` */ execute_container_exec_stream, /** * Subscribes to deployment container exec io over websocket message, * for use with xtermjs. Can connect to Deployment container. * Deployment Terminal permission required. */ connect_deployment_exec, /** * Executes a command on a given deployment container, * and gives a callback to handle the output as it comes in. * * ```ts * await komodo.execute_deployment_exec( * { * deployment: "my-deployment", * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }, * { * onLine: (line) => console.log(line), * onFinish: (code) => console.log("Finished:", code), * } * ); * ``` */ execute_deployment_exec, /** * Executes a command on a given deployment container, * and returns a stream to process the output as it comes in. * * Note. The final line of the stream will usually be * `__KOMODO_EXIT_CODE__:0`. The number * is the exit code of the command. * * If this line is NOT present, it means the stream * was terminated early, ie like running `exit`. * * ```ts * const stream = await komodo.execute_deployment_exec_stream({ * deployment: "my-deployment", * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }); * * for await (const line of stream) { * console.log(line); * } * ``` */ execute_deployment_exec_stream, /** * Subscribes to container exec io over websocket message, * for use with xtermjs. Can connect to Stack service container. * Stack Terminal permission required. */ connect_stack_exec, /** * Executes a command on a given stack service container, * and gives a callback to handle the output as it comes in. * * ```ts * await komodo.execute_stack_exec( * { * stack: "my-stack", * service: "database" * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }, * { * onLine: (line) => console.log(line), * onFinish: (code) => console.log("Finished:", code), * } * ); * ``` */ execute_stack_exec, /** * Executes a command on a given stack service container, * and returns a stream to process the output as it comes in. * * Note. The final line of the stream will usually be * `__KOMODO_EXIT_CODE__:0`. The number * is the exit code of the command. * * If this line is NOT present, it means the stream * was terminated early, ie like running `exit`. * * ```ts * const stream = await komodo.execute_stack_exec_stream({ * stack: "my-stack", * service: "service1", * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }); * * for await (const line of stream) { * console.log(line); * } * ``` */ execute_stack_exec_stream, }; } ================================================ FILE: client/core/ts/src/responses.ts ================================================ import * as Types from "./types.js"; export type AuthResponses = { GetLoginOptions: Types.GetLoginOptionsResponse; SignUpLocalUser: Types.SignUpLocalUserResponse; LoginLocalUser: Types.LoginLocalUserResponse; ExchangeForJwt: Types.ExchangeForJwtResponse; GetUser: Types.GetUserResponse; }; export type UserResponses = { PushRecentlyViewed: Types.PushRecentlyViewedResponse; SetLastSeenUpdate: Types.SetLastSeenUpdateResponse; CreateApiKey: Types.CreateApiKeyResponse; DeleteApiKey: Types.DeleteApiKeyResponse; }; export type ReadResponses = { GetVersion: Types.GetVersionResponse; GetCoreInfo: Types.GetCoreInfoResponse; ListSecrets: Types.ListSecretsResponse; ListGitProvidersFromConfig: Types.ListGitProvidersFromConfigResponse; ListDockerRegistriesFromConfig: Types.ListDockerRegistriesFromConfigResponse; // ==== USER ==== GetUsername: Types.GetUsernameResponse; GetPermission: Types.GetPermissionResponse; FindUser: Types.FindUserResponse; ListUsers: Types.ListUsersResponse; ListApiKeys: Types.ListApiKeysResponse; ListApiKeysForServiceUser: Types.ListApiKeysForServiceUserResponse; ListPermissions: Types.ListPermissionsResponse; ListUserTargetPermissions: Types.ListUserTargetPermissionsResponse; // ==== USER GROUP ==== GetUserGroup: Types.GetUserGroupResponse; ListUserGroups: Types.ListUserGroupsResponse; // ==== PROCEDURE ==== GetProceduresSummary: Types.GetProceduresSummaryResponse; GetProcedure: Types.GetProcedureResponse; GetProcedureActionState: Types.GetProcedureActionStateResponse; ListProcedures: Types.ListProceduresResponse; ListFullProcedures: Types.ListFullProceduresResponse; // ==== ACTION ==== GetActionsSummary: Types.GetActionsSummaryResponse; GetAction: Types.GetActionResponse; GetActionActionState: Types.GetActionActionStateResponse; ListActions: Types.ListActionsResponse; ListFullActions: Types.ListFullActionsResponse; // ==== SCHEDULE ==== ListSchedules: Types.ListSchedulesResponse; // ==== SERVER ==== GetServersSummary: Types.GetServersSummaryResponse; GetServer: Types.GetServerResponse; GetServerState: Types.GetServerStateResponse; GetPeripheryVersion: Types.GetPeripheryVersionResponse; GetDockerContainersSummary: Types.GetDockerContainersSummaryResponse; ListDockerContainers: Types.ListDockerContainersResponse; ListAllDockerContainers: Types.ListAllDockerContainersResponse; InspectDockerContainer: Types.InspectDockerContainerResponse; GetResourceMatchingContainer: Types.GetResourceMatchingContainerResponse; GetContainerLog: Types.GetContainerLogResponse; SearchContainerLog: Types.SearchContainerLogResponse; ListDockerNetworks: Types.ListDockerNetworksResponse; InspectDockerNetwork: Types.InspectDockerNetworkResponse; ListDockerImages: Types.ListDockerImagesResponse; InspectDockerImage: Types.InspectDockerImageResponse; ListDockerImageHistory: Types.ListDockerImageHistoryResponse; ListDockerVolumes: Types.ListDockerVolumesResponse; InspectDockerVolume: Types.InspectDockerVolumeResponse; ListComposeProjects: Types.ListComposeProjectsResponse; GetServerActionState: Types.GetServerActionStateResponse; GetHistoricalServerStats: Types.GetHistoricalServerStatsResponse; ListServers: Types.ListServersResponse; ListFullServers: Types.ListFullServersResponse; ListTerminals: Types.ListTerminalsResponse; // ==== STACK ==== GetStacksSummary: Types.GetStacksSummaryResponse; GetStack: Types.GetStackResponse; GetStackActionState: Types.GetStackActionStateResponse; GetStackWebhooksEnabled: Types.GetStackWebhooksEnabledResponse; GetStackLog: Types.GetStackLogResponse; SearchStackLog: Types.SearchStackLogResponse; InspectStackContainer: Types.InspectStackContainerResponse; ListStacks: Types.ListStacksResponse; ListFullStacks: Types.ListFullStacksResponse; ListStackServices: Types.ListStackServicesResponse; ListCommonStackExtraArgs: Types.ListCommonStackExtraArgsResponse; ListCommonStackBuildExtraArgs: Types.ListCommonStackBuildExtraArgsResponse; // ==== DEPLOYMENT ==== GetDeploymentsSummary: Types.GetDeploymentsSummaryResponse; GetDeployment: Types.GetDeploymentResponse; GetDeploymentContainer: Types.GetDeploymentContainerResponse; GetDeploymentActionState: Types.GetDeploymentActionStateResponse; GetDeploymentStats: Types.GetDeploymentStatsResponse; GetDeploymentLog: Types.GetDeploymentLogResponse; SearchDeploymentLog: Types.SearchDeploymentLogResponse; InspectDeploymentContainer: Types.InspectDeploymentContainerResponse; ListDeployments: Types.ListDeploymentsResponse; ListFullDeployments: Types.ListFullDeploymentsResponse; ListCommonDeploymentExtraArgs: Types.ListCommonDeploymentExtraArgsResponse; // ==== BUILD ==== GetBuildsSummary: Types.GetBuildsSummaryResponse; GetBuild: Types.GetBuildResponse; GetBuildActionState: Types.GetBuildActionStateResponse; GetBuildMonthlyStats: Types.GetBuildMonthlyStatsResponse; GetBuildWebhookEnabled: Types.GetBuildWebhookEnabledResponse; ListBuilds: Types.ListBuildsResponse; ListFullBuilds: Types.ListFullBuildsResponse; ListBuildVersions: Types.ListBuildVersionsResponse; ListCommonBuildExtraArgs: Types.ListCommonBuildExtraArgsResponse; // ==== REPO ==== GetReposSummary: Types.GetReposSummaryResponse; GetRepo: Types.GetRepoResponse; GetRepoActionState: Types.GetRepoActionStateResponse; GetRepoWebhooksEnabled: Types.GetRepoWebhooksEnabledResponse; ListRepos: Types.ListReposResponse; ListFullRepos: Types.ListFullReposResponse; // ==== SYNC ==== GetResourceSyncsSummary: Types.GetResourceSyncsSummaryResponse; GetResourceSync: Types.GetResourceSyncResponse; GetResourceSyncActionState: Types.GetResourceSyncActionStateResponse; GetSyncWebhooksEnabled: Types.GetSyncWebhooksEnabledResponse; ListResourceSyncs: Types.ListResourceSyncsResponse; ListFullResourceSyncs: Types.ListFullResourceSyncsResponse; // ==== BUILDER ==== GetBuildersSummary: Types.GetBuildersSummaryResponse; GetBuilder: Types.GetBuilderResponse; ListBuilders: Types.ListBuildersResponse; ListFullBuilders: Types.ListFullBuildersResponse; // ==== ALERTER ==== GetAlertersSummary: Types.GetAlertersSummaryResponse; GetAlerter: Types.GetAlerterResponse; ListAlerters: Types.ListAlertersResponse; ListFullAlerters: Types.ListFullAlertersResponse; // ==== TOML ==== ExportAllResourcesToToml: Types.ExportAllResourcesToTomlResponse; ExportResourcesToToml: Types.ExportResourcesToTomlResponse; // ==== TAG ==== GetTag: Types.GetTagResponse; ListTags: Types.ListTagsResponse; // ==== UPDATE ==== GetUpdate: Types.GetUpdateResponse; ListUpdates: Types.ListUpdatesResponse; // ==== ALERT ==== ListAlerts: Types.ListAlertsResponse; GetAlert: Types.GetAlertResponse; // ==== SERVER STATS ==== GetSystemInformation: Types.GetSystemInformationResponse; GetSystemStats: Types.GetSystemStatsResponse; ListSystemProcesses: Types.ListSystemProcessesResponse; // ==== VARIABLE ==== GetVariable: Types.GetVariableResponse; ListVariables: Types.ListVariablesResponse; // ==== PROVIDER ==== GetGitProviderAccount: Types.GetGitProviderAccountResponse; ListGitProviderAccounts: Types.ListGitProviderAccountsResponse; GetDockerRegistryAccount: Types.GetDockerRegistryAccountResponse; ListDockerRegistryAccounts: Types.ListDockerRegistryAccountsResponse; }; export type WriteResponses = { // ==== USER ==== CreateLocalUser: Types.CreateLocalUserResponse; UpdateUserUsername: Types.UpdateUserUsernameResponse; UpdateUserPassword: Types.UpdateUserPasswordResponse; DeleteUser: Types.DeleteUserResponse; // ==== SERVICE USER ==== CreateServiceUser: Types.CreateServiceUserResponse; UpdateServiceUserDescription: Types.UpdateServiceUserDescriptionResponse; CreateApiKeyForServiceUser: Types.CreateApiKeyForServiceUserResponse; DeleteApiKeyForServiceUser: Types.DeleteApiKeyForServiceUserResponse; // ==== USER GROUP ==== CreateUserGroup: Types.UserGroup; RenameUserGroup: Types.UserGroup; DeleteUserGroup: Types.UserGroup; AddUserToUserGroup: Types.UserGroup; RemoveUserFromUserGroup: Types.UserGroup; SetUsersInUserGroup: Types.UserGroup; SetEveryoneUserGroup: Types.UserGroup; // ==== PERMISSIONS ==== UpdateUserAdmin: Types.UpdateUserAdminResponse; UpdateUserBasePermissions: Types.UpdateUserBasePermissionsResponse; UpdatePermissionOnResourceType: Types.UpdatePermissionOnResourceTypeResponse; UpdatePermissionOnTarget: Types.UpdatePermissionOnTargetResponse; // ==== RESOURCE ==== UpdateResourceMeta: Types.UpdateResourceMetaResponse; // ==== SERVER ==== CreateServer: Types.Server; CopyServer: Types.Server; DeleteServer: Types.Server; UpdateServer: Types.Server; RenameServer: Types.Update; CreateNetwork: Types.Update; CreateTerminal: Types.NoData; DeleteTerminal: Types.NoData; DeleteAllTerminals: Types.NoData; // ==== STACK ==== CreateStack: Types.Stack; CopyStack: Types.Stack; DeleteStack: Types.Stack; UpdateStack: Types.Stack; RenameStack: Types.Update; WriteStackFileContents: Types.Update; RefreshStackCache: Types.NoData; CreateStackWebhook: Types.CreateStackWebhookResponse; DeleteStackWebhook: Types.DeleteStackWebhookResponse; // ==== DEPLOYMENT ==== CreateDeployment: Types.Deployment; CopyDeployment: Types.Deployment; CreateDeploymentFromContainer: Types.Deployment; DeleteDeployment: Types.Deployment; UpdateDeployment: Types.Deployment; RenameDeployment: Types.Update; // ==== BUILD ==== CreateBuild: Types.Build; CopyBuild: Types.Build; DeleteBuild: Types.Build; UpdateBuild: Types.Build; RenameBuild: Types.Update; WriteBuildFileContents: Types.Update; RefreshBuildCache: Types.NoData; CreateBuildWebhook: Types.CreateBuildWebhookResponse; DeleteBuildWebhook: Types.DeleteBuildWebhookResponse; // ==== BUILDER ==== CreateBuilder: Types.Builder; CopyBuilder: Types.Builder; DeleteBuilder: Types.Builder; UpdateBuilder: Types.Builder; RenameBuilder: Types.Update; // ==== REPO ==== CreateRepo: Types.Repo; CopyRepo: Types.Repo; DeleteRepo: Types.Repo; UpdateRepo: Types.Repo; RenameRepo: Types.Update; RefreshRepoCache: Types.NoData; CreateRepoWebhook: Types.CreateRepoWebhookResponse; DeleteRepoWebhook: Types.DeleteRepoWebhookResponse; // ==== ALERTER ==== CreateAlerter: Types.Alerter; CopyAlerter: Types.Alerter; DeleteAlerter: Types.Alerter; UpdateAlerter: Types.Alerter; RenameAlerter: Types.Update; // ==== PROCEDURE ==== CreateProcedure: Types.Procedure; CopyProcedure: Types.Procedure; DeleteProcedure: Types.Procedure; UpdateProcedure: Types.Procedure; RenameProcedure: Types.Update; // ==== ACTION ==== CreateAction: Types.Action; CopyAction: Types.Action; DeleteAction: Types.Action; UpdateAction: Types.Action; RenameAction: Types.Update; // ==== SYNC ==== CreateResourceSync: Types.ResourceSync; CopyResourceSync: Types.ResourceSync; DeleteResourceSync: Types.ResourceSync; UpdateResourceSync: Types.ResourceSync; RenameResourceSync: Types.Update; CommitSync: Types.Update; WriteSyncFileContents: Types.Update; RefreshResourceSyncPending: Types.ResourceSync; CreateSyncWebhook: Types.CreateSyncWebhookResponse; DeleteSyncWebhook: Types.DeleteSyncWebhookResponse; // ==== TAG ==== CreateTag: Types.Tag; DeleteTag: Types.Tag; RenameTag: Types.Tag; UpdateTagColor: Types.Tag; // ==== VARIABLE ==== CreateVariable: Types.CreateVariableResponse; UpdateVariableValue: Types.UpdateVariableValueResponse; UpdateVariableDescription: Types.UpdateVariableDescriptionResponse; UpdateVariableIsSecret: Types.UpdateVariableIsSecretResponse; DeleteVariable: Types.DeleteVariableResponse; // ==== PROVIDERS ==== CreateGitProviderAccount: Types.CreateGitProviderAccountResponse; UpdateGitProviderAccount: Types.UpdateGitProviderAccountResponse; DeleteGitProviderAccount: Types.DeleteGitProviderAccountResponse; CreateDockerRegistryAccount: Types.CreateDockerRegistryAccountResponse; UpdateDockerRegistryAccount: Types.UpdateDockerRegistryAccountResponse; DeleteDockerRegistryAccount: Types.DeleteDockerRegistryAccountResponse; }; export type ExecuteResponses = { // ==== SERVER ==== StartContainer: Types.Update; RestartContainer: Types.Update; PauseContainer: Types.Update; UnpauseContainer: Types.Update; StopContainer: Types.Update; DestroyContainer: Types.Update; StartAllContainers: Types.Update; RestartAllContainers: Types.Update; PauseAllContainers: Types.Update; UnpauseAllContainers: Types.Update; StopAllContainers: Types.Update; PruneContainers: Types.Update; DeleteNetwork: Types.Update; PruneNetworks: Types.Update; DeleteImage: Types.Update; PruneImages: Types.Update; DeleteVolume: Types.Update; PruneVolumes: Types.Update; PruneDockerBuilders: Types.Update; PruneBuildx: Types.Update; PruneSystem: Types.Update; // ==== STACK ==== DeployStack: Types.Update; BatchDeployStack: Types.BatchExecutionResponse; DeployStackIfChanged: Types.Update; BatchDeployStackIfChanged: Types.BatchExecutionResponse; PullStack: Types.Update; BatchPullStack: Types.BatchExecutionResponse; StartStack: Types.Update; RestartStack: Types.Update; StopStack: Types.Update; PauseStack: Types.Update; UnpauseStack: Types.Update; DestroyStack: Types.Update; BatchDestroyStack: Types.BatchExecutionResponse; // ==== DEPLOYMENT ==== Deploy: Types.Update; BatchDeploy: Types.BatchExecutionResponse; PullDeployment: Types.Update; StartDeployment: Types.Update; RestartDeployment: Types.Update; PauseDeployment: Types.Update; UnpauseDeployment: Types.Update; StopDeployment: Types.Update; DestroyDeployment: Types.Update; BatchDestroyDeployment: Types.BatchExecutionResponse; // ==== BUILD ==== RunBuild: Types.Update; BatchRunBuild: Types.BatchExecutionResponse; CancelBuild: Types.Update; // ==== REPO ==== CloneRepo: Types.Update; BatchCloneRepo: Types.BatchExecutionResponse; PullRepo: Types.Update; BatchPullRepo: Types.BatchExecutionResponse; BuildRepo: Types.Update; BatchBuildRepo: Types.BatchExecutionResponse; CancelRepoBuild: Types.Update; // ==== PROCEDURE ==== RunProcedure: Types.Update; BatchRunProcedure: Types.BatchExecutionResponse; // ==== ACTION ==== RunAction: Types.Update; BatchRunAction: Types.BatchExecutionResponse; // ==== SYNC ==== RunSync: Types.Update; // ==== STACK Service ==== DeployStackService: Types.Update; StartStackService: Types.Update; RestartStackService: Types.Update; StopStackService: Types.Update; PauseStackService: Types.Update; UnpauseStackService: Types.Update; DestroyStackService: Types.Update; RunStackService: Types.Update; // ==== ALERTER ==== TestAlerter: Types.Update; SendAlert: Types.Update; // ==== MAINTENANCE ==== ClearRepoCache: Types.Update; BackupCoreDatabase: Types.Update; GlobalAutoUpdate: Types.Update; }; ================================================ FILE: client/core/ts/src/terminal.ts ================================================ import { ClientState, InitOptions } from "./lib"; import { ConnectContainerExecQuery, ConnectDeploymentExecQuery, ConnectStackExecQuery, ConnectTerminalQuery, ExecuteContainerExecBody, ExecuteDeploymentExecBody, ExecuteStackExecBody, ExecuteTerminalBody, WsLoginMessage, } from "./types"; export type TerminalCallbacks = { on_message?: (e: MessageEvent) => void; on_login?: () => void; on_open?: () => void; on_close?: () => void; }; export type ConnectExecQuery = | { type: "container"; query: ConnectContainerExecQuery; } | { type: "deployment"; query: ConnectDeploymentExecQuery; } | { type: "stack"; query: ConnectStackExecQuery; }; export type ExecuteExecBody = | { type: "container"; body: ExecuteContainerExecBody; } | { type: "deployment"; body: ExecuteDeploymentExecBody; } | { type: "stack"; body: ExecuteStackExecBody; }; export type ExecuteCallbacks = { onLine?: (line: string) => void | Promise; onFinish?: (code: string) => void | Promise; }; export const terminal_methods = (url: string, state: ClientState) => { const connect_terminal = ({ query, on_message, on_login, on_open, on_close, }: { query: ConnectTerminalQuery; } & TerminalCallbacks) => { const url_query = new URLSearchParams( query as any as Record ).toString(); const ws = new WebSocket( url.replace("http", "ws") + "/ws/terminal?" + url_query ); // Handle login on websocket open ws.onopen = () => { const login_msg: WsLoginMessage = state.jwt ? { type: "Jwt", params: { jwt: state.jwt, }, } : { type: "ApiKeys", params: { key: state.key!, secret: state.secret!, }, }; ws.send(JSON.stringify(login_msg)); on_open?.(); }; ws.onmessage = (e) => { if (e.data == "LOGGED_IN") { ws.binaryType = "arraybuffer"; ws.onmessage = (e) => on_message?.(e); on_login?.(); return; } else { on_message?.(e); } }; ws.onclose = () => on_close?.(); return ws; }; const execute_terminal = async ( request: ExecuteTerminalBody, callbacks?: ExecuteCallbacks ) => { const stream = await execute_terminal_stream(request); for await (const line of stream) { if (line.startsWith("__KOMODO_EXIT_CODE")) { await callbacks?.onFinish?.(line.split(":")[1]); return; } else { await callbacks?.onLine?.(line); } } // This is hit if no __KOMODO_EXIT_CODE is sent, ie early exit await callbacks?.onFinish?.("Early exit without code"); }; const execute_terminal_stream = (request: ExecuteTerminalBody) => execute_stream("/terminal/execute", request); const connect_container_exec = ({ query, ...callbacks }: { query: ConnectContainerExecQuery; } & TerminalCallbacks) => connect_exec({ query: { type: "container", query }, ...callbacks }); const connect_deployment_exec = ({ query, ...callbacks }: { query: ConnectDeploymentExecQuery; } & TerminalCallbacks) => connect_exec({ query: { type: "deployment", query }, ...callbacks }); const connect_stack_exec = ({ query, ...callbacks }: { query: ConnectStackExecQuery; } & TerminalCallbacks) => connect_exec({ query: { type: "stack", query }, ...callbacks }); const connect_exec = ({ query: { type, query }, on_message, on_login, on_open, on_close, }: { query: ConnectExecQuery; } & TerminalCallbacks) => { const url_query = new URLSearchParams( query as any as Record ).toString(); const ws = new WebSocket( url.replace("http", "ws") + `/ws/${type}/terminal?` + url_query ); // Handle login on websocket open ws.onopen = () => { const login_msg: WsLoginMessage = state.jwt ? { type: "Jwt", params: { jwt: state.jwt, }, } : { type: "ApiKeys", params: { key: state.key!, secret: state.secret!, }, }; ws.send(JSON.stringify(login_msg)); on_open?.(); }; ws.onmessage = (e) => { if (e.data == "LOGGED_IN") { ws.binaryType = "arraybuffer"; ws.onmessage = (e) => on_message?.(e); on_login?.(); return; } else { on_message?.(e); } }; ws.onclose = () => on_close?.(); return ws; }; const execute_container_exec = ( body: ExecuteContainerExecBody, callbacks?: ExecuteCallbacks ) => execute_exec({ type: "container", body }, callbacks); const execute_deployment_exec = ( body: ExecuteDeploymentExecBody, callbacks?: ExecuteCallbacks ) => execute_exec({ type: "deployment", body }, callbacks); const execute_stack_exec = ( body: ExecuteStackExecBody, callbacks?: ExecuteCallbacks ) => execute_exec({ type: "stack", body }, callbacks); const execute_exec = async ( request: ExecuteExecBody, callbacks?: ExecuteCallbacks ) => { const stream = await execute_exec_stream(request); for await (const line of stream) { if (line.startsWith("__KOMODO_EXIT_CODE")) { await callbacks?.onFinish?.(line.split(":")[1]); return; } else { await callbacks?.onLine?.(line); } } // This is hit if no __KOMODO_EXIT_CODE is sent, ie early exit await callbacks?.onFinish?.("Early exit without code"); }; const execute_container_exec_stream = (body: ExecuteContainerExecBody) => execute_exec_stream({ type: "container", body }); const execute_deployment_exec_stream = (body: ExecuteDeploymentExecBody) => execute_exec_stream({ type: "deployment", body }); const execute_stack_exec_stream = (body: ExecuteStackExecBody) => execute_exec_stream({ type: "stack", body }); const execute_exec_stream = (request: ExecuteExecBody) => execute_stream(`/terminal/execute/${request.type}`, request.body); const execute_stream = (path: string, request: any) => new Promise>(async (res, rej) => { try { let response = await fetch(url + path, { method: "POST", body: JSON.stringify(request), headers: { ...(state.jwt ? { authorization: state.jwt, } : state.key && state.secret ? { "x-api-key": state.key, "x-api-secret": state.secret, } : {}), "content-type": "application/json", }, }); if (response.status === 200) { if (response.body) { const stream = response.body .pipeThrough(new TextDecoderStream("utf-8")) .pipeThrough( new TransformStream({ start(_controller) { this.tail = ""; }, transform(chunk, controller) { const data = this.tail + chunk; // prepend any carry‑over const parts = data.split(/\r?\n/); // split on CRLF or LF this.tail = parts.pop()!; // last item may be incomplete for (const line of parts) controller.enqueue(line); }, flush(controller) { if (this.tail) controller.enqueue(this.tail); // final unterminated line }, } as Transformer & { tail: string }) ); res(stream); } else { rej({ status: response.status, result: { error: "No response body", trace: [] }, }); } } else { try { const result = await response.json(); rej({ status: response.status, result }); } catch (error) { rej({ status: response.status, result: { error: "Failed to get response body", trace: [JSON.stringify(error)], }, error, }); } } } catch (error) { rej({ status: 1, result: { error: "Request failed with error", trace: [JSON.stringify(error)], }, error, }); } }); return { connect_terminal, execute_terminal, execute_terminal_stream, connect_exec, connect_container_exec, execute_container_exec, execute_container_exec_stream, connect_deployment_exec, execute_deployment_exec, execute_deployment_exec_stream, connect_stack_exec, execute_stack_exec, execute_stack_exec_stream, }; }; ================================================ FILE: client/core/ts/src/types.ts ================================================ /* Generated by typeshare 1.13.3 */ export interface MongoIdObj { $oid: string; } export type MongoId = MongoIdObj; /** The levels of permission that a User or UserGroup can have on a resource. */ export enum PermissionLevel { /** No permissions. */ None = "None", /** Can read resource information and config */ Read = "Read", /** Can execute actions on the resource */ Execute = "Execute", /** Can update the resource configuration */ Write = "Write", } export interface PermissionLevelAndSpecifics { level: PermissionLevel; specific: Array; } export type I64 = number; export interface Resource { /** * The Mongo ID of the resource. * This field is de/serialized from/to JSON as * `{ "_id": { "$oid": "..." }, ...(rest of serialized Resource) }` */ _id?: MongoId; /** * The resource name. * This is guaranteed unique among others of the same resource type. */ name: string; /** A description for the resource */ description?: string; /** Mark resource as a template */ template?: boolean; /** Tag Ids */ tags?: string[]; /** Resource-specific information (not user configurable). */ info?: Info; /** Resource-specific configuration. */ config?: Config; /** * Set a base permission level that all users will have on the * resource. */ base_permission?: PermissionLevelAndSpecifics | PermissionLevel; /** When description last updated */ updated_at?: I64; } export enum ScheduleFormat { English = "English", Cron = "Cron", } export enum FileFormat { KeyValue = "key_value", Toml = "toml", Yaml = "yaml", Json = "json", } export interface ActionConfig { /** Whether this action should run at startup. */ run_at_startup: boolean; /** Choose whether to specify schedule as regular CRON, or using the english to CRON parser. */ schedule_format?: ScheduleFormat; /** * Optionally provide a schedule for the procedure to run on. * * There are 2 ways to specify a schedule: * * 1. Regular CRON expression: * * (second, minute, hour, day, month, day-of-week) * ```text * 0 0 0 1,15 * ? * ``` * * 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron): * * ```text * at midnight on the 1st and 15th of the month * ``` */ schedule?: string; /** * Whether schedule is enabled if one is provided. * Can be used to temporarily disable the schedule. */ schedule_enabled: boolean; /** * Optional. A TZ Identifier. If not provided, will use Core local timezone. * https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. */ schedule_timezone?: string; /** Whether to send alerts when the schedule was run. */ schedule_alert: boolean; /** Whether to send alerts when this action fails. */ failure_alert: boolean; /** Whether incoming webhooks actually trigger action. */ webhook_enabled: boolean; /** * Optionally provide an alternate webhook secret for this procedure. * If its an empty string, use the default secret from the config. */ webhook_secret?: string; /** * Whether deno will be instructed to reload all dependencies, * this can usually be kept false outside of development. */ reload_deno_deps?: boolean; /** * Typescript file contents using pre-initialized `komodo` client. * Supports variable / secret interpolation. */ file_contents?: string; /** * Specify the format in which the arguments are defined. * Default: `key_value` (like environment) */ arguments_format?: FileFormat; /** Default arguments to give to the Action for use in the script at `ARGS`. */ arguments?: string; } /** Represents an empty json object: `{}` */ export interface NoData { } export type Action = Resource; export interface ResourceListItem { /** The resource id */ id: string; /** The resource type, ie `Server` or `Deployment` */ type: ResourceTarget["type"]; /** The resource name */ name: string; /** Whether resource is a template */ template: boolean; /** Tag Ids */ tags: string[]; /** Resource specific info */ info: Info; } export enum ActionState { /** Unknown case */ Unknown = "Unknown", /** Last clone / pull successful (or never cloned) */ Ok = "Ok", /** Last clone / pull failed */ Failed = "Failed", /** Currently running */ Running = "Running", } export interface ActionListItemInfo { /** Whether last action run successful */ state: ActionState; /** Action last successful run timestamp in ms. */ last_run_at?: I64; /** * If the action has schedule enabled, this is the * next scheduled run time in unix ms. */ next_scheduled_run?: I64; /** * If there is an error parsing schedule expression, * it will be given here. */ schedule_error?: string; } export type ActionListItem = ResourceListItem; export enum TemplatesQueryBehavior { /** Include templates in results. Default. */ Include = "Include", /** Exclude templates from results. */ Exclude = "Exclude", /** Results *only* includes templates. */ Only = "Only", } export enum TagQueryBehavior { /** Returns resources which have strictly all the tags */ All = "All", /** Returns resources which have one or more of the tags */ Any = "Any", } /** Passing empty Vec is the same as not filtering by that field */ export interface ResourceQuery { names?: string[]; templates?: TemplatesQueryBehavior; /** Pass Vec of tag ids or tag names */ tags?: string[]; /** 'All' or 'Any' */ tag_behavior?: TagQueryBehavior; specific?: T; } export interface ActionQuerySpecifics { } export type ActionQuery = ResourceQuery; export type AlerterEndpoint = /** Send alert serialized to JSON to an http endpoint. */ | { type: "Custom", params: CustomAlerterEndpoint } /** Send alert to a Slack app */ | { type: "Slack", params: SlackAlerterEndpoint } /** Send alert to a Discord app */ | { type: "Discord", params: DiscordAlerterEndpoint } /** Send alert to Ntfy */ | { type: "Ntfy", params: NtfyAlerterEndpoint } /** Send alert to Pushover */ | { type: "Pushover", params: PushoverAlerterEndpoint }; /** Used to reference a specific resource across all resource types */ export type ResourceTarget = | { type: "System", id: string } | { type: "Server", id: string } | { type: "Stack", id: string } | { type: "Deployment", id: string } | { type: "Build", id: string } | { type: "Repo", id: string } | { type: "Procedure", id: string } | { type: "Action", id: string } | { type: "Builder", id: string } | { type: "Alerter", id: string } | { type: "ResourceSync", id: string }; /** Types of maintenance schedules */ export enum MaintenanceScheduleType { /** Daily at the specified time */ Daily = "Daily", /** Weekly on the specified day and time */ Weekly = "Weekly", /** One-time maintenance on a specific date and time */ OneTime = "OneTime", } /** Represents a scheduled maintenance window */ export interface MaintenanceWindow { /** Name for the maintenance window (required) */ name: string; /** Description of what maintenance is performed (optional) */ description?: string; /** * The type of maintenance schedule: * - Daily (default) * - Weekly * - OneTime */ schedule_type?: MaintenanceScheduleType; /** For Weekly schedules: Specify the day of the week (Monday, Tuesday, etc.) */ day_of_week?: string; /** For OneTime window: ISO 8601 date format (YYYY-MM-DD) */ date?: string; /** Start hour in 24-hour format (0-23) (optional, defaults to 0) */ hour?: number; /** Start minute (0-59) (optional, defaults to 0) */ minute?: number; /** Duration of the maintenance window in minutes (required) */ duration_minutes: number; /** * Timezone for maintenance window specificiation. * If empty, will use Core timezone. */ timezone?: string; /** Whether this maintenance window is currently enabled */ enabled: boolean; } export interface AlerterConfig { /** Whether the alerter is enabled */ enabled?: boolean; /** * Where to route the alert messages. * * Default: Custom endpoint `http://localhost:7000` */ endpoint?: AlerterEndpoint; /** * Only send specific alert types. * If empty, will send all alert types. */ alert_types?: AlertData["type"][]; /** * Only send alerts on specific resources. * If empty, will send alerts for all resources. */ resources?: ResourceTarget[]; /** DON'T send alerts on these resources. */ except_resources?: ResourceTarget[]; /** Scheduled maintenance windows during which alerts will be suppressed. */ maintenance_windows?: MaintenanceWindow[]; } export type Alerter = Resource; export interface AlerterListItemInfo { /** Whether alerter is enabled for sending alerts */ enabled: boolean; /** The type of the alerter, eg. `Slack`, `Custom` */ endpoint_type: AlerterEndpoint["type"]; } export type AlerterListItem = ResourceListItem; export interface AlerterQuerySpecifics { /** * Filter alerters by enabled. * - `None`: Don't filter by enabled * - `Some(true)`: Only include alerts with `enabled: true` * - `Some(false)`: Only include alerts with `enabled: false` */ enabled?: boolean; /** * Only include alerters with these endpoint types. * If empty, don't filter by enpoint type. */ types: AlerterEndpoint["type"][]; } export type AlerterQuery = ResourceQuery; export type BatchExecutionResponseItem = | { status: "Ok", data: Update } | { status: "Err", data: BatchExecutionResponseItemErr }; export type BatchExecutionResponse = BatchExecutionResponseItem[]; export enum Operation { None = "None", CreateServer = "CreateServer", UpdateServer = "UpdateServer", DeleteServer = "DeleteServer", RenameServer = "RenameServer", StartContainer = "StartContainer", RestartContainer = "RestartContainer", PauseContainer = "PauseContainer", UnpauseContainer = "UnpauseContainer", StopContainer = "StopContainer", DestroyContainer = "DestroyContainer", StartAllContainers = "StartAllContainers", RestartAllContainers = "RestartAllContainers", PauseAllContainers = "PauseAllContainers", UnpauseAllContainers = "UnpauseAllContainers", StopAllContainers = "StopAllContainers", PruneContainers = "PruneContainers", CreateNetwork = "CreateNetwork", DeleteNetwork = "DeleteNetwork", PruneNetworks = "PruneNetworks", DeleteImage = "DeleteImage", PruneImages = "PruneImages", DeleteVolume = "DeleteVolume", PruneVolumes = "PruneVolumes", PruneDockerBuilders = "PruneDockerBuilders", PruneBuildx = "PruneBuildx", PruneSystem = "PruneSystem", CreateStack = "CreateStack", UpdateStack = "UpdateStack", RenameStack = "RenameStack", DeleteStack = "DeleteStack", WriteStackContents = "WriteStackContents", RefreshStackCache = "RefreshStackCache", PullStack = "PullStack", DeployStack = "DeployStack", StartStack = "StartStack", RestartStack = "RestartStack", PauseStack = "PauseStack", UnpauseStack = "UnpauseStack", StopStack = "StopStack", DestroyStack = "DestroyStack", RunStackService = "RunStackService", DeployStackService = "DeployStackService", PullStackService = "PullStackService", StartStackService = "StartStackService", RestartStackService = "RestartStackService", PauseStackService = "PauseStackService", UnpauseStackService = "UnpauseStackService", StopStackService = "StopStackService", DestroyStackService = "DestroyStackService", CreateDeployment = "CreateDeployment", UpdateDeployment = "UpdateDeployment", RenameDeployment = "RenameDeployment", DeleteDeployment = "DeleteDeployment", Deploy = "Deploy", PullDeployment = "PullDeployment", StartDeployment = "StartDeployment", RestartDeployment = "RestartDeployment", PauseDeployment = "PauseDeployment", UnpauseDeployment = "UnpauseDeployment", StopDeployment = "StopDeployment", DestroyDeployment = "DestroyDeployment", CreateBuild = "CreateBuild", UpdateBuild = "UpdateBuild", RenameBuild = "RenameBuild", DeleteBuild = "DeleteBuild", RunBuild = "RunBuild", CancelBuild = "CancelBuild", WriteDockerfile = "WriteDockerfile", CreateRepo = "CreateRepo", UpdateRepo = "UpdateRepo", RenameRepo = "RenameRepo", DeleteRepo = "DeleteRepo", CloneRepo = "CloneRepo", PullRepo = "PullRepo", BuildRepo = "BuildRepo", CancelRepoBuild = "CancelRepoBuild", CreateProcedure = "CreateProcedure", UpdateProcedure = "UpdateProcedure", RenameProcedure = "RenameProcedure", DeleteProcedure = "DeleteProcedure", RunProcedure = "RunProcedure", CreateAction = "CreateAction", UpdateAction = "UpdateAction", RenameAction = "RenameAction", DeleteAction = "DeleteAction", RunAction = "RunAction", CreateBuilder = "CreateBuilder", UpdateBuilder = "UpdateBuilder", RenameBuilder = "RenameBuilder", DeleteBuilder = "DeleteBuilder", CreateAlerter = "CreateAlerter", UpdateAlerter = "UpdateAlerter", RenameAlerter = "RenameAlerter", DeleteAlerter = "DeleteAlerter", TestAlerter = "TestAlerter", SendAlert = "SendAlert", CreateResourceSync = "CreateResourceSync", UpdateResourceSync = "UpdateResourceSync", RenameResourceSync = "RenameResourceSync", DeleteResourceSync = "DeleteResourceSync", WriteSyncContents = "WriteSyncContents", CommitSync = "CommitSync", RunSync = "RunSync", ClearRepoCache = "ClearRepoCache", BackupCoreDatabase = "BackupCoreDatabase", GlobalAutoUpdate = "GlobalAutoUpdate", CreateVariable = "CreateVariable", UpdateVariableValue = "UpdateVariableValue", DeleteVariable = "DeleteVariable", CreateGitProviderAccount = "CreateGitProviderAccount", UpdateGitProviderAccount = "UpdateGitProviderAccount", DeleteGitProviderAccount = "DeleteGitProviderAccount", CreateDockerRegistryAccount = "CreateDockerRegistryAccount", UpdateDockerRegistryAccount = "UpdateDockerRegistryAccount", DeleteDockerRegistryAccount = "DeleteDockerRegistryAccount", } /** Represents the output of some command being run */ export interface Log { /** A label for the log */ stage: string; /** The command which was executed */ command: string; /** The output of the command in the standard channel */ stdout: string; /** The output of the command in the error channel */ stderr: string; /** Whether the command run was successful */ success: boolean; /** The start time of the command execution */ start_ts: I64; /** The end time of the command execution */ end_ts: I64; } /** An update's status */ export enum UpdateStatus { /** The run is in the system but hasn't started yet */ Queued = "Queued", /** The run is currently running */ InProgress = "InProgress", /** The run is complete */ Complete = "Complete", } export interface Version { major: number; minor: number; patch: number; } /** Represents an action performed by Komodo. */ export interface Update { /** * The Mongo ID of the update. * This field is de/serialized from/to JSON as * `{ "_id": { "$oid": "..." }, ...(rest of serialized Update) }` */ _id?: MongoId; /** The operation performed */ operation: Operation; /** The time the operation started */ start_ts: I64; /** Whether the operation was successful */ success: boolean; /** * The user id that triggered the update. * * Also can take these values for operations triggered automatically: * - `Procedure`: The operation was triggered as part of a procedure run * - `Github`: The operation was triggered by a github webhook * - `Auto Redeploy`: The operation (always `Deploy`) was triggered by an attached build finishing. */ operator: string; /** The target resource to which this update refers */ target: ResourceTarget; /** Logs produced as the operation is performed */ logs: Log[]; /** The time the operation completed. */ end_ts?: I64; /** * The status of the update * - `Queued` * - `InProgress` * - `Complete` */ status: UpdateStatus; /** An optional version on the update, ie build version or deployed version. */ version?: Version; /** An optional commit hash associated with the update, ie cloned hash or deployed hash. */ commit_hash?: string; /** Some unstructured, operation specific data. Not for general usage. */ other_data?: string; /** If the update is for resource config update, give the previous toml contents */ prev_toml?: string; /** If the update is for resource config update, give the current (at time of Update) toml contents */ current_toml?: string; } export type BoxUpdate = Update; /** Configuration for an image registry */ export interface ImageRegistryConfig { /** * Specify the registry provider domain, eg `docker.io`. * If not provided, will not push to any registry. */ domain?: string; /** Specify an account to use with the registry. */ account?: string; /** * Optional. Specify an organization to push the image under. * Empty string means no organization. */ organization?: string; } export interface SystemCommand { path?: string; command?: string; } /** The build configuration. */ export interface BuildConfig { /** Which builder is used to build the image. */ builder_id?: string; /** The current version of the build. */ version?: Version; /** * Whether to automatically increment the patch on every build. * Default is `true` */ auto_increment_version: boolean; /** * An alternate name for the image pushed to the repository. * If this is empty, it will use the build name. * * Can be used in conjunction with `image_tag` to direct multiple builds * with different configs to push to the same image registry, under different, * independantly versioned tags. */ image_name?: string; /** * An extra tag put after the build version, for the image pushed to the repository. * Eg. in image tag of `aarch64` would push to moghtech/komodo-core:1.13.2-aarch64. * If this is empty, the image tag will just be the build version. * * Can be used in conjunction with `image_name` to direct multiple builds * with different configs to push to the same image registry, under different, * independantly versioned tags. */ image_tag?: string; /** Push `:latest` / `:latest-image_tag` tags. */ include_latest_tag: boolean; /** Push build version semver `:1.19.5` + `1.19` / `:1.19.5-image_tag` tags. */ include_version_tags: boolean; /** Push commit hash `:a6v8h83` / `:a6v8h83-image_tag` tags. */ include_commit_tag: boolean; /** Configure quick links that are displayed in the resource header */ links?: string[]; /** Choose a Komodo Repo (Resource) to source the build files. */ linked_repo?: string; /** The git provider domain. Default: github.com */ git_provider: string; /** * Whether to use https to clone the repo (versus http). Default: true * * Note. Komodo does not currently support cloning repos via ssh. */ git_https: boolean; /** * The git account used to access private repos. * Passing empty string can only clone public repos. * * Note. A token for the account must be available in the core config or the builder server's periphery config * for the configured git provider. */ git_account?: string; /** The repo used as the source of the build. */ repo?: string; /** The branch of the repo. */ branch: string; /** Optionally set a specific commit hash. */ commit?: string; /** Whether incoming webhooks actually trigger action. */ webhook_enabled: boolean; /** * Optionally provide an alternate webhook secret for this build. * If its an empty string, use the default secret from the config. */ webhook_secret?: string; /** * If this is checked, the build will source the files on the host. * Use `build_path` and `dockerfile_path` to specify the path on the host. * This is useful for those who wish to setup their files on the host, * rather than defining the contents in UI or in a git repo. */ files_on_host?: boolean; /** * The path of the docker build context relative to the root of the repo. * Default: "." (the root of the repo). */ build_path: string; /** The path of the dockerfile relative to the build path. */ dockerfile_path: string; /** * Configuration for the registry/s to push the built image to. * The first registry in this list will be used with attached Deployments. */ image_registry?: ImageRegistryConfig[]; /** Whether to skip secret interpolation in the build_args. */ skip_secret_interp?: boolean; /** Whether to use buildx to build (eg `docker buildx build ...`) */ use_buildx?: boolean; /** Any extra docker cli arguments to be included in the build command */ extra_args?: string[]; /** The optional command run after repo clone and before docker build. */ pre_build?: SystemCommand; /** * UI defined dockerfile contents. * Supports variable / secret interpolation. */ dockerfile?: string; /** * Docker build arguments. * * These values are visible in the final image by running `docker inspect`. */ build_args?: string; /** * Secret arguments. * * These values remain hidden in the final image by using * docker secret mounts. See . * * The values can be used in RUN commands: * ```sh * RUN --mount=type=secret,id=SECRET_KEY \ * SECRET_KEY=$(cat /run/secrets/SECRET_KEY) ... * ``` */ secret_args?: string; /** Docker labels */ labels?: string; } export interface BuildInfo { /** The timestamp build was last built. */ last_built_at: I64; /** Latest built short commit hash, or null. */ built_hash?: string; /** Latest built commit message, or null. Only for repo based stacks */ built_message?: string; /** * The last built dockerfile contents. * This is updated whenever Komodo successfully runs the build. */ built_contents?: string; /** The absolute path to the file */ remote_path?: string; /** * The remote dockerfile contents, whether on host or in repo. * This is updated whenever Komodo refreshes the build cache. * It will be empty if the dockerfile is defined directly in the build config. */ remote_contents?: string; /** If there was an error in getting the remote contents, it will be here. */ remote_error?: string; /** Latest remote short commit hash, or null. */ latest_hash?: string; /** Latest remote commit message, or null */ latest_message?: string; } export type Build = Resource; export enum BuildState { /** Currently building */ Building = "Building", /** Last build successful (or never built) */ Ok = "Ok", /** Last build failed */ Failed = "Failed", /** Other case */ Unknown = "Unknown", } export interface BuildListItemInfo { /** State of the build. Reflects whether most recent build successful. */ state: BuildState; /** Unix timestamp in milliseconds of last build */ last_built_at: I64; /** The current version of the build */ version: Version; /** The builder attached to build. */ builder_id: string; /** Whether build is in files on host mode. */ files_on_host: boolean; /** Whether build has UI defined dockerfile contents */ dockerfile_contents: boolean; /** Linked repo, if one is attached. */ linked_repo: string; /** The git provider domain */ git_provider: string; /** The repo used as the source of the build */ repo: string; /** The branch of the repo */ branch: string; /** Full link to the repo. */ repo_link: string; /** Latest built short commit hash, or null. */ built_hash?: string; /** Latest short commit hash, or null. Only for repo based stacks */ latest_hash?: string; /** The first listed image registry domain */ image_registry_domain?: string; } export type BuildListItem = ResourceListItem; export interface BuildQuerySpecifics { builder_ids?: string[]; repos?: string[]; /** * query for builds last built more recently than this timestamp * defaults to 0 which is a no op */ built_since?: I64; } export type BuildQuery = ResourceQuery; export type BuilderConfig = /** Use a Periphery address as a Builder. */ | { type: "Url", params: UrlBuilderConfig } /** Use a connected server as a Builder. */ | { type: "Server", params: ServerBuilderConfig } /** Use EC2 instances spawned on demand as a Builder. */ | { type: "Aws", params: AwsBuilderConfig }; export type Builder = Resource; export interface BuilderListItemInfo { /** 'Url', 'Server', or 'Aws' */ builder_type: string; /** * If 'Url': null * If 'Server': the server id * If 'Aws': the instance type (eg. c5.xlarge) */ instance_type?: string; } export type BuilderListItem = ResourceListItem; export interface BuilderQuerySpecifics { } export type BuilderQuery = ResourceQuery; /** A wrapper for all Komodo exections. */ export type Execution = /** The "null" execution. Does nothing. */ | { type: "None", params: NoData } /** Run the target action. (alias: `action`, `ac`) */ | { type: "RunAction", params: RunAction } | { type: "BatchRunAction", params: BatchRunAction } /** Run the target procedure. (alias: `procedure`, `pr`) */ | { type: "RunProcedure", params: RunProcedure } | { type: "BatchRunProcedure", params: BatchRunProcedure } /** Run the target build. (alias: `build`, `bd`) */ | { type: "RunBuild", params: RunBuild } | { type: "BatchRunBuild", params: BatchRunBuild } | { type: "CancelBuild", params: CancelBuild } /** Deploy the target deployment. (alias: `dp`) */ | { type: "Deploy", params: Deploy } | { type: "BatchDeploy", params: BatchDeploy } | { type: "PullDeployment", params: PullDeployment } | { type: "StartDeployment", params: StartDeployment } | { type: "RestartDeployment", params: RestartDeployment } | { type: "PauseDeployment", params: PauseDeployment } | { type: "UnpauseDeployment", params: UnpauseDeployment } | { type: "StopDeployment", params: StopDeployment } | { type: "DestroyDeployment", params: DestroyDeployment } | { type: "BatchDestroyDeployment", params: BatchDestroyDeployment } /** Clone the target repo */ | { type: "CloneRepo", params: CloneRepo } | { type: "BatchCloneRepo", params: BatchCloneRepo } | { type: "PullRepo", params: PullRepo } | { type: "BatchPullRepo", params: BatchPullRepo } | { type: "BuildRepo", params: BuildRepo } | { type: "BatchBuildRepo", params: BatchBuildRepo } | { type: "CancelRepoBuild", params: CancelRepoBuild } | { type: "StartContainer", params: StartContainer } | { type: "RestartContainer", params: RestartContainer } | { type: "PauseContainer", params: PauseContainer } | { type: "UnpauseContainer", params: UnpauseContainer } | { type: "StopContainer", params: StopContainer } | { type: "DestroyContainer", params: DestroyContainer } | { type: "StartAllContainers", params: StartAllContainers } | { type: "RestartAllContainers", params: RestartAllContainers } | { type: "PauseAllContainers", params: PauseAllContainers } | { type: "UnpauseAllContainers", params: UnpauseAllContainers } | { type: "StopAllContainers", params: StopAllContainers } | { type: "PruneContainers", params: PruneContainers } | { type: "DeleteNetwork", params: DeleteNetwork } | { type: "PruneNetworks", params: PruneNetworks } | { type: "DeleteImage", params: DeleteImage } | { type: "PruneImages", params: PruneImages } | { type: "DeleteVolume", params: DeleteVolume } | { type: "PruneVolumes", params: PruneVolumes } | { type: "PruneDockerBuilders", params: PruneDockerBuilders } | { type: "PruneBuildx", params: PruneBuildx } | { type: "PruneSystem", params: PruneSystem } /** Execute a Resource Sync. (alias: `sync`) */ | { type: "RunSync", params: RunSync } /** Commit a Resource Sync. (alias: `commit`) */ | { type: "CommitSync", params: CommitSync } /** Deploy the target stack. (alias: `stack`, `st`) */ | { type: "DeployStack", params: DeployStack } | { type: "BatchDeployStack", params: BatchDeployStack } | { type: "DeployStackIfChanged", params: DeployStackIfChanged } | { type: "BatchDeployStackIfChanged", params: BatchDeployStackIfChanged } | { type: "PullStack", params: PullStack } | { type: "BatchPullStack", params: BatchPullStack } | { type: "StartStack", params: StartStack } | { type: "RestartStack", params: RestartStack } | { type: "PauseStack", params: PauseStack } | { type: "UnpauseStack", params: UnpauseStack } | { type: "StopStack", params: StopStack } | { type: "DestroyStack", params: DestroyStack } | { type: "BatchDestroyStack", params: BatchDestroyStack } | { type: "RunStackService", params: RunStackService } | { type: "TestAlerter", params: TestAlerter } | { type: "SendAlert", params: SendAlert } | { type: "ClearRepoCache", params: ClearRepoCache } | { type: "BackupCoreDatabase", params: BackupCoreDatabase } | { type: "GlobalAutoUpdate", params: GlobalAutoUpdate } | { type: "Sleep", params: Sleep }; /** Allows to enable / disabled procedures in the sequence / parallel vec on the fly */ export interface EnabledExecution { /** The execution request to run. */ execution: Execution; /** Whether the execution is enabled to run in the procedure. */ enabled: boolean; } /** A single stage of a procedure. Runs a list of executions in parallel. */ export interface ProcedureStage { /** A name for the procedure */ name: string; /** Whether the stage should be run as part of the procedure. */ enabled: boolean; /** The executions in the stage */ executions?: EnabledExecution[]; } /** Config for the [Procedure] */ export interface ProcedureConfig { /** The stages to be run by the procedure. */ stages?: ProcedureStage[]; /** Choose whether to specify schedule as regular CRON, or using the english to CRON parser. */ schedule_format?: ScheduleFormat; /** * Optionally provide a schedule for the procedure to run on. * * There are 2 ways to specify a schedule: * * 1. Regular CRON expression: * * (second, minute, hour, day, month, day-of-week) * ```text * 0 0 0 1,15 * ? * ``` * * 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron): * * ```text * at midnight on the 1st and 15th of the month * ``` */ schedule?: string; /** * Whether schedule is enabled if one is provided. * Can be used to temporarily disable the schedule. */ schedule_enabled: boolean; /** * Optional. A TZ Identifier. If not provided, will use Core local timezone. * https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. */ schedule_timezone?: string; /** Whether to send alerts when the schedule was run. */ schedule_alert: boolean; /** Whether to send alerts when this procedure fails. */ failure_alert: boolean; /** Whether incoming webhooks actually trigger action. */ webhook_enabled: boolean; /** * Optionally provide an alternate webhook secret for this procedure. * If its an empty string, use the default secret from the config. */ webhook_secret?: string; } /** * Procedures run a series of stages sequentially, where * each stage runs executions in parallel. */ export type Procedure = Resource; export type CopyProcedureResponse = Procedure; export type CreateActionWebhookResponse = NoData; /** Response for [CreateApiKey]. */ export interface CreateApiKeyResponse { /** X-API-KEY */ key: string; /** * X-API-SECRET * * Note. * There is no way to get the secret again after it is distributed in this message */ secret: string; } export type CreateApiKeyForServiceUserResponse = CreateApiKeyResponse; export type CreateBuildWebhookResponse = NoData; /** Configuration to access private image repositories on various registries. */ export interface DockerRegistryAccount { /** * The Mongo ID of the docker registry account. * This field is de/serialized from/to JSON as * `{ "_id": { "$oid": "..." }, ...(rest of DockerRegistryAccount) }` */ _id?: MongoId; /** * The domain of the provider. * * For docker registry, this can include 'http://...', * however this is not recommended and won't work unless "insecure registries" are enabled * on your hosts. See . */ domain: string; /** The account username */ username?: string; /** * The token in plain text on the db. * If the database / host can be accessed this is insecure. */ token?: string; } export type CreateDockerRegistryAccountResponse = DockerRegistryAccount; /** * Configuration to access private git repos from various git providers. * Note. Cannot create two accounts with the same domain and username. */ export interface GitProviderAccount { /** * The Mongo ID of the git provider account. * This field is de/serialized from/to JSON as * `{ "_id": { "$oid": "..." }, ...(rest of serialized User) }` */ _id?: MongoId; /** * The domain of the provider. * * For git, this cannot include the protocol eg 'http://', * which is controlled with 'https' field. */ domain: string; /** Whether git provider is accessed over http or https. */ https: boolean; /** The account username */ username?: string; /** * The token in plain text on the db. * If the database / host can be accessed this is insecure. */ token?: string; } export type CreateGitProviderAccountResponse = GitProviderAccount; export type UserConfig = /** User that logs in with username / password */ | { type: "Local", data: { password: string; }} /** User that logs in via Google Oauth */ | { type: "Google", data: { google_id: string; avatar: string; }} /** User that logs in via Github Oauth */ | { type: "Github", data: { github_id: string; avatar: string; }} /** User that logs in via Oidc provider */ | { type: "Oidc", data: { provider: string; user_id: string; }} /** Non-human managed user, can have it's own permissions / api keys */ | { type: "Service", data: { description: string; }}; export interface User { /** * The Mongo ID of the User. * This field is de/serialized from/to JSON as * `{ "_id": { "$oid": "..." }, ...(rest of User schema) }` */ _id?: MongoId; /** The globally unique username for the user. */ username: string; /** Whether user is enabled / able to access the api. */ enabled?: boolean; /** Can give / take other users admin priviledges. */ super_admin?: boolean; /** Whether the user has global admin permissions. */ admin?: boolean; /** Whether the user has permission to create servers. */ create_server_permissions?: boolean; /** Whether the user has permission to create builds */ create_build_permissions?: boolean; /** The user-type specific config. */ config: UserConfig; /** When the user last opened updates dropdown. */ last_update_view?: I64; /** Recently viewed ids */ recents?: Record; /** Give the user elevated permissions on all resources of a certain type */ all?: Record; updated_at?: I64; } export type CreateLocalUserResponse = User; export type CreateProcedureResponse = Procedure; export type CreateRepoWebhookResponse = NoData; export type CreateServiceUserResponse = User; export type CreateStackWebhookResponse = NoData; export type CreateSyncWebhookResponse = NoData; /** * A non-secret global variable which can be interpolated into deployment * environment variable values and build argument values. */ export interface Variable { /** * Unique name associated with the variable. * Instances of '[[variable.name]]' in value will be replaced with 'variable.value'. */ name: string; /** A description for the variable. */ description?: string; /** The value associated with the variable. */ value?: string; /** * If marked as secret, the variable value will be hidden in updates / logs. * Additionally the value will not be served in read requests by non admin users. * * Note that the value is NOT encrypted in the database, and will likely show up in database logs. * The security of these variables comes down to the security * of the database (system level encryption, network isolation, etc.) */ is_secret?: boolean; } export type CreateVariableResponse = Variable; export type DeleteActionWebhookResponse = NoData; export type DeleteApiKeyForServiceUserResponse = NoData; export type DeleteApiKeyResponse = NoData; export type DeleteBuildWebhookResponse = NoData; export type DeleteDockerRegistryAccountResponse = DockerRegistryAccount; export type DeleteGitProviderAccountResponse = GitProviderAccount; export type DeleteProcedureResponse = Procedure; export type DeleteRepoWebhookResponse = NoData; export type DeleteStackWebhookResponse = NoData; export type DeleteSyncWebhookResponse = NoData; export type DeleteUserResponse = User; export type DeleteVariableResponse = Variable; export type DeploymentImage = /** Deploy any external image. */ | { type: "Image", params: { /** The docker image, can be from any registry that works with docker and that the host server can reach. */ image?: string; }} /** Deploy a Komodo Build. */ | { type: "Build", params: { /** The id of the Build */ build_id?: string; /** * Use a custom / older version of the image produced by the build. * if version is 0.0.0, this means `latest` image. */ version?: Version; }}; export enum RestartMode { NoRestart = "no", OnFailure = "on-failure", Always = "always", UnlessStopped = "unless-stopped", } export enum TerminationSignal { SigHup = "SIGHUP", SigInt = "SIGINT", SigQuit = "SIGQUIT", SigTerm = "SIGTERM", } export interface DeploymentConfig { /** The id of server the deployment is deployed on. */ server_id?: string; /** * The image which the deployment deploys. * Can either be a user inputted image, or a Komodo Build. */ image?: DeploymentImage; /** * Configure the account used to pull the image from the registry. * Used with `docker login`. * * - If the field is empty string, will use the same account config as the build, or none at all if using image. * - If the field contains an account, a token for the account must be available. * - Will get the registry domain from the build / image */ image_registry_account?: string; /** Whether to skip secret interpolation into the deployment environment variables. */ skip_secret_interp?: boolean; /** Whether to redeploy the deployment whenever the attached build finishes. */ redeploy_on_build?: boolean; /** Whether to poll for any updates to the image. */ poll_for_updates?: boolean; /** * Whether to automatically redeploy when * newer a image is found. Will implicitly * enable `poll_for_updates`, you don't need to * enable both. */ auto_update?: boolean; /** Whether to send ContainerStateChange alerts for this deployment. */ send_alerts: boolean; /** Configure quick links that are displayed in the resource header */ links?: string[]; /** * The network attached to the container. * Default is `host`. */ network: string; /** The restart mode given to the container. */ restart?: RestartMode; /** * This is interpolated at the end of the `docker run` command, * which means they are either passed to the containers inner process, * or replaces the container command, depending on use of ENTRYPOINT or CMD in dockerfile. * Empty is no command. */ command?: string; /** The default termination signal to use to stop the deployment. Defaults to SigTerm (default docker signal). */ termination_signal?: TerminationSignal; /** The termination timeout. */ termination_timeout: number; /** * Extra args which are interpolated into the `docker run` command, * and affect the container configuration. */ extra_args?: string[]; /** * Labels attached to various termination signal options. * Used to specify different shutdown functionality depending on the termination signal. */ term_signal_labels?: string; /** * The container port mapping. * Irrelevant if container network is `host`. * Maps ports on host to ports on container. */ ports?: string; /** * The container volume mapping. * Maps files / folders on host to files / folders in container. */ volumes?: string; /** The environment variables passed to the container. */ environment?: string; /** The docker labels given to the container. */ labels?: string; } export type Deployment = Resource; /** * Variants de/serialized from/to snake_case. * * Eg. * - NotDeployed -> not_deployed * - Restarting -> restarting * - Running -> running. */ export enum DeploymentState { /** The deployment is currently re/deploying */ Deploying = "deploying", /** Container is running */ Running = "running", /** Container is created but not running */ Created = "created", /** Container is in restart loop */ Restarting = "restarting", /** Container is being removed */ Removing = "removing", /** Container is paused */ Paused = "paused", /** Container is exited */ Exited = "exited", /** Container is dead */ Dead = "dead", /** The deployment is not deployed (no matching container) */ NotDeployed = "not_deployed", /** Server not reachable for status */ Unknown = "unknown", } export interface DeploymentListItemInfo { /** The state of the deployment / underlying docker container. */ state: DeploymentState; /** The status of the docker container (eg. up 12 hours, exited 5 minutes ago.) */ status?: string; /** The image attached to the deployment. */ image: string; /** Whether there is a newer image available at the same tag. */ update_available: boolean; /** The server that deployment sits on. */ server_id: string; /** An attached Komodo Build, if it exists. */ build_id?: string; } export type DeploymentListItem = ResourceListItem; export interface DeploymentQuerySpecifics { /** * Query only for Deployments on these Servers. * If empty, does not filter by Server. * Only accepts Server id (not name). */ server_ids?: string[]; /** * Query only for Deployments with these Builds attached. * If empty, does not filter by Build. * Only accepts Build id (not name). */ build_ids?: string[]; /** Query only for Deployments with available image updates. */ update_available?: boolean; } export type DeploymentQuery = ResourceQuery; /** JSON containing an authentication token. */ export interface JwtResponse { /** User ID for signed in user. */ user_id: string; /** A token the user can use to authenticate their requests. */ jwt: string; } /** Response for [ExchangeForJwt]. */ export type ExchangeForJwtResponse = JwtResponse; /** Response containing pretty formatted toml contents. */ export interface TomlResponse { toml: string; } export type ExportAllResourcesToTomlResponse = TomlResponse; export type ExportResourcesToTomlResponse = TomlResponse; export type FindUserResponse = User; export interface ActionActionState { /** Number of instances of the Action currently running */ running: number; } export type GetActionActionStateResponse = ActionActionState; export type GetActionResponse = Action; /** Severity level of problem. */ export enum SeverityLevel { /** * No problem. * * Aliases: ok, low, l */ Ok = "OK", /** * Problem is imminent. * * Aliases: warning, w, medium, m */ Warning = "WARNING", /** * Problem fully realized. * * Aliases: critical, c, high, h */ Critical = "CRITICAL", } /** The variants of data related to the alert. */ export type AlertData = /** A null alert */ | { type: "None", data: { }} /** * The user triggered a test of the * Alerter configuration. */ | { type: "Test", data: { /** The id of the alerter */ id: string; /** The name of the alerter */ name: string; }} /** A server could not be reached. */ | { type: "ServerUnreachable", data: { /** The id of the server */ id: string; /** The name of the server */ name: string; /** The region of the server */ region?: string; /** The error data */ err?: _Serror; }} /** A server has high CPU usage. */ | { type: "ServerCpu", data: { /** The id of the server */ id: string; /** The name of the server */ name: string; /** The region of the server */ region?: string; /** The cpu usage percentage */ percentage: number; }} /** A server has high memory usage. */ | { type: "ServerMem", data: { /** The id of the server */ id: string; /** The name of the server */ name: string; /** The region of the server */ region?: string; /** The used memory */ used_gb: number; /** The total memory */ total_gb: number; }} /** A server has high disk usage. */ | { type: "ServerDisk", data: { /** The id of the server */ id: string; /** The name of the server */ name: string; /** The region of the server */ region?: string; /** The mount path of the disk */ path: string; /** The used portion of the disk in GB */ used_gb: number; /** The total size of the disk in GB */ total_gb: number; }} /** A server has a version mismatch with the core. */ | { type: "ServerVersionMismatch", data: { /** The id of the server */ id: string; /** The name of the server */ name: string; /** The region of the server */ region?: string; /** The actual server version */ server_version: string; /** The core version */ core_version: string; }} /** A container's state has changed unexpectedly. */ | { type: "ContainerStateChange", data: { /** The id of the deployment */ id: string; /** The name of the deployment */ name: string; /** The server id of server that the deployment is on */ server_id: string; /** The server name */ server_name: string; /** The previous container state */ from: DeploymentState; /** The current container state */ to: DeploymentState; }} /** A Deployment has an image update available */ | { type: "DeploymentImageUpdateAvailable", data: { /** The id of the deployment */ id: string; /** The name of the deployment */ name: string; /** The server id of server that the deployment is on */ server_id: string; /** The server name */ server_name: string; /** The image with update */ image: string; }} /** A Deployment has an image update available */ | { type: "DeploymentAutoUpdated", data: { /** The id of the deployment */ id: string; /** The name of the deployment */ name: string; /** The server id of server that the deployment is on */ server_id: string; /** The server name */ server_name: string; /** The updated image */ image: string; }} /** A stack's state has changed unexpectedly. */ | { type: "StackStateChange", data: { /** The id of the stack */ id: string; /** The name of the stack */ name: string; /** The server id of server that the stack is on */ server_id: string; /** The server name */ server_name: string; /** The previous stack state */ from: StackState; /** The current stack state */ to: StackState; }} /** A Stack has an image update available */ | { type: "StackImageUpdateAvailable", data: { /** The id of the stack */ id: string; /** The name of the stack */ name: string; /** The server id of server that the stack is on */ server_id: string; /** The server name */ server_name: string; /** The service name to update */ service: string; /** The image with update */ image: string; }} /** A Stack was auto updated */ | { type: "StackAutoUpdated", data: { /** The id of the stack */ id: string; /** The name of the stack */ name: string; /** The server id of server that the stack is on */ server_id: string; /** The server name */ server_name: string; /** One or more images that were updated */ images: string[]; }} /** An AWS builder failed to terminate. */ | { type: "AwsBuilderTerminationFailed", data: { /** The id of the aws instance which failed to terminate */ instance_id: string; /** A reason for the failure */ message: string; }} /** A resource sync has pending updates */ | { type: "ResourceSyncPendingUpdates", data: { /** The id of the resource sync */ id: string; /** The name of the resource sync */ name: string; }} /** A build has failed */ | { type: "BuildFailed", data: { /** The id of the build */ id: string; /** The name of the build */ name: string; /** The version that failed to build */ version: Version; }} /** A repo has failed */ | { type: "RepoBuildFailed", data: { /** The id of the repo */ id: string; /** The name of the repo */ name: string; }} /** A procedure has failed */ | { type: "ProcedureFailed", data: { /** The id of the procedure */ id: string; /** The name of the procedure */ name: string; }} /** An action has failed */ | { type: "ActionFailed", data: { /** The id of the action */ id: string; /** The name of the action */ name: string; }} /** A schedule was run */ | { type: "ScheduleRun", data: { /** Procedure or Action */ resource_type: ResourceTarget["type"]; /** The resource id */ id: string; /** The resource name */ name: string; }} /** * Custom header / body. * Produced using `/execute/SendAlert` */ | { type: "Custom", data: { /** The alert message. */ message: string; /** Message details. May be empty string. */ details?: string; }}; /** Representation of an alert in the system. */ export interface Alert { /** * The Mongo ID of the alert. * This field is de/serialized from/to JSON as * `{ "_id": { "$oid": "..." }, ...(rest of serialized Alert) }` */ _id?: MongoId; /** Unix timestamp in milliseconds the alert was opened */ ts: I64; /** Whether the alert is already resolved */ resolved: boolean; /** The severity of the alert */ level: SeverityLevel; /** The target of the alert */ target: ResourceTarget; /** The data attached to the alert */ data: AlertData; /** The timestamp of alert resolution */ resolved_ts?: I64; } export type GetAlertResponse = Alert; export type GetAlerterResponse = Alerter; export interface BuildActionState { building: boolean; } export type GetBuildActionStateResponse = BuildActionState; export type GetBuildResponse = Build; export type GetBuilderResponse = Builder; export type GetContainerLogResponse = Log; export interface DeploymentActionState { pulling: boolean; deploying: boolean; starting: boolean; restarting: boolean; pausing: boolean; unpausing: boolean; stopping: boolean; destroying: boolean; renaming: boolean; } export type GetDeploymentActionStateResponse = DeploymentActionState; export type GetDeploymentLogResponse = Log; export type GetDeploymentResponse = Deployment; export interface ContainerStats { name: string; cpu_perc: string; mem_perc: string; mem_usage: string; net_io: string; block_io: string; pids: string; } export type GetDeploymentStatsResponse = ContainerStats; export type GetDockerRegistryAccountResponse = DockerRegistryAccount; export type GetGitProviderAccountResponse = GitProviderAccount; export type GetPermissionResponse = PermissionLevelAndSpecifics; export interface ProcedureActionState { running: boolean; } export type GetProcedureActionStateResponse = ProcedureActionState; export type GetProcedureResponse = Procedure; export interface RepoActionState { /** Whether Repo currently cloning on the attached Server */ cloning: boolean; /** Whether Repo currently pulling on the attached Server */ pulling: boolean; /** Whether Repo currently building using the attached Builder. */ building: boolean; /** Whether Repo currently renaming. */ renaming: boolean; } export type GetRepoActionStateResponse = RepoActionState; export interface RepoConfig { /** The server to clone the repo on. */ server_id?: string; /** Attach a builder to 'build' the repo. */ builder_id?: string; /** The git provider domain. Default: github.com */ git_provider: string; /** * Whether to use https to clone the repo (versus http). Default: true * * Note. Komodo does not currently support cloning repos via ssh. */ git_https: boolean; /** * The git account used to access private repos. * Passing empty string can only clone public repos. * * Note. A token for the account must be available in the core config or the builder server's periphery config * for the configured git provider. */ git_account?: string; /** The github repo to clone. */ repo?: string; /** The repo branch. */ branch: string; /** Optionally set a specific commit hash. */ commit?: string; /** * Explicitly specify the folder to clone the repo in. * - If absolute (has leading '/') * - Used directly as the path * - If relative * - Taken relative to Periphery `repo_dir` (ie `${root_directory}/repos`) */ path?: string; /** Whether incoming webhooks actually trigger action. */ webhook_enabled: boolean; /** * Optionally provide an alternate webhook secret for this repo. * If its an empty string, use the default secret from the config. */ webhook_secret?: string; /** * Command to be run after the repo is cloned. * The path is relative to the root of the repo. */ on_clone?: SystemCommand; /** * Command to be run after the repo is pulled. * The path is relative to the root of the repo. */ on_pull?: SystemCommand; /** Configure quick links that are displayed in the resource header */ links?: string[]; /** * The environment variables passed to the compose file. * They will be written to path defined in env_file_path, * which is given relative to the run directory. * * If it is empty, no file will be written. */ environment?: string; /** * The name of the written environment file before `docker compose up`. * Relative to the repo root. * Default: .env */ env_file_path: string; /** Whether to skip secret interpolation into the repo environment variable file. */ skip_secret_interp?: boolean; } export interface RepoInfo { /** When repo was last pulled */ last_pulled_at?: I64; /** When repo was last built */ last_built_at?: I64; /** Latest built short commit hash, or null. */ built_hash?: string; /** Latest built commit message, or null. Only for repo based stacks */ built_message?: string; /** Latest remote short commit hash, or null. */ latest_hash?: string; /** Latest remote commit message, or null */ latest_message?: string; } export type Repo = Resource; export type GetRepoResponse = Repo; export interface ResourceSyncActionState { /** Whether sync currently syncing */ syncing: boolean; } export type GetResourceSyncActionStateResponse = ResourceSyncActionState; /** The sync configuration. */ export interface ResourceSyncConfig { /** Choose a Komodo Repo (Resource) to source the sync files. */ linked_repo?: string; /** The git provider domain. Default: github.com */ git_provider: string; /** * Whether to use https to clone the repo (versus http). Default: true * * Note. Komodo does not currently support cloning repos via ssh. */ git_https: boolean; /** The Github repo used as the source of the build. */ repo?: string; /** The branch of the repo. */ branch: string; /** Optionally set a specific commit hash. */ commit?: string; /** * The git account used to access private repos. * Passing empty string can only clone public repos. * * Note. A token for the account must be available in the core config or the builder server's periphery config * for the configured git provider. */ git_account?: string; /** Whether incoming webhooks actually trigger action. */ webhook_enabled: boolean; /** * Optionally provide an alternate webhook secret for this sync. * If its an empty string, use the default secret from the config. */ webhook_secret?: string; /** * Files are available on the Komodo Core host. * Specify the file / folder with [ResourceSyncConfig::resource_path]. */ files_on_host?: boolean; /** * The path of the resource file(s) to sync. * - If Files on Host, this is relative to the configured `sync_directory` in core config. * - If Git Repo based, this is relative to the root of the repo. * Can be a specific file, or a directory containing multiple files / folders. * See [https://komo.do/docs/sync-resources](https://komo.do/docs/sync-resources) for more information. */ resource_path?: string[]; /** * Enable "pushes" to the file, * which exports resources matching tags to single file. * - If using `files_on_host`, it is stored in the file_contents, which must point to a .toml file path (it will be created if it doesn't exist). * - If using `file_contents`, it is stored in the database. * When using this, "delete" mode is always enabled. */ managed?: boolean; /** * Whether sync should delete resources * not declared in the resource files */ delete?: boolean; /** * Whether sync should include resources. * Default: true */ include_resources: boolean; /** * When using `managed` resource sync, will only export resources * matching all of the given tags. If none, will match all resources. */ match_tags?: string[]; /** Whether sync should include variables. */ include_variables?: boolean; /** Whether sync should include user groups. */ include_user_groups?: boolean; /** * Whether sync should send alert when it enters Pending state. * Default: true */ pending_alert: boolean; /** Manage the file contents in the UI. */ file_contents?: string; } export type DiffData = /** Resource will be created */ | { type: "Create", data: { /** The name of resource to create */ name?: string; /** The proposed resource to create in TOML */ proposed: string; }} | { type: "Update", data: { /** The proposed TOML */ proposed: string; /** The current TOML */ current: string; }} | { type: "Delete", data: { /** The current TOML of the resource to delete */ current: string; }}; export interface ResourceDiff { /** * The resource target. * The target id will be empty if "Create" ResourceDiffType. */ target: ResourceTarget; /** The data associated with the diff. */ data: DiffData; } export interface SyncDeployUpdate { /** Resources to deploy */ to_deploy: number; /** A readable log of all the changes to be applied */ log: string; } export interface SyncFileContents { /** The base resource path. */ resource_path?: string; /** The path of the file / error path relative to the resource path. */ path: string; /** The contents of the file */ contents: string; } export interface ResourceSyncInfo { /** Unix timestamp of last applied sync */ last_sync_ts?: I64; /** Short commit hash of last applied sync */ last_sync_hash?: string; /** Commit message of last applied sync */ last_sync_message?: string; /** The list of pending updates to resources */ resource_updates?: ResourceDiff[]; /** The list of pending updates to variables */ variable_updates?: DiffData[]; /** The list of pending updates to user groups */ user_group_updates?: DiffData[]; /** The list of pending deploys to resources. */ pending_deploy?: SyncDeployUpdate; /** If there is an error, it will be stored here */ pending_error?: string; /** The commit hash which produced these pending updates. */ pending_hash?: string; /** The commit message which produced these pending updates. */ pending_message?: string; /** The current sync files */ remote_contents?: SyncFileContents[]; /** Any read errors in files by path */ remote_errors?: SyncFileContents[]; } export type ResourceSync = Resource; export type GetResourceSyncResponse = ResourceSync; /** Current pending actions on the server. */ export interface ServerActionState { /** Server currently pruning networks */ pruning_networks: boolean; /** Server currently pruning containers */ pruning_containers: boolean; /** Server currently pruning images */ pruning_images: boolean; /** Server currently pruning volumes */ pruning_volumes: boolean; /** Server currently pruning docker builders */ pruning_builders: boolean; /** Server currently pruning builx cache */ pruning_buildx: boolean; /** Server currently pruning system */ pruning_system: boolean; /** Server currently starting containers. */ starting_containers: boolean; /** Server currently restarting containers. */ restarting_containers: boolean; /** Server currently pausing containers. */ pausing_containers: boolean; /** Server currently unpausing containers. */ unpausing_containers: boolean; /** Server currently stopping containers. */ stopping_containers: boolean; } export type GetServerActionStateResponse = ServerActionState; /** Server configuration. */ export interface ServerConfig { /** * The http address of the periphery client. * Default: http://localhost:8120 */ address: string; /** * The address to use with links for containers on the server. * If empty, will use the 'address' for links. */ external_address?: string; /** An optional region label */ region?: string; /** * Whether a server is enabled. * If a server is disabled, * you won't be able to perform any actions on it or see deployment's status. * Default: false */ enabled: boolean; /** * The timeout used to reach the server in seconds. * default: 2 */ timeout_seconds: I64; /** * An optional override passkey to use * to authenticate with periphery agent. * If this is empty, will use passkey in core config. */ passkey?: string; /** * Sometimes the system stats reports a mount path that is not desired. * Use this field to filter it out from the report. */ ignore_mounts?: string[]; /** * Whether to monitor any server stats beyond passing health check. * default: true */ stats_monitoring: boolean; /** * Whether to trigger 'docker image prune -a -f' every 24 hours. * default: true */ auto_prune: boolean; /** Configure quick links that are displayed in the resource header */ links?: string[]; /** Whether to send alerts about the servers reachability */ send_unreachable_alerts: boolean; /** Whether to send alerts about the servers CPU status */ send_cpu_alerts: boolean; /** Whether to send alerts about the servers MEM status */ send_mem_alerts: boolean; /** Whether to send alerts about the servers DISK status */ send_disk_alerts: boolean; /** Whether to send alerts about the servers version mismatch with core */ send_version_mismatch_alerts: boolean; /** The percentage threshhold which triggers WARNING state for CPU. */ cpu_warning: number; /** The percentage threshhold which triggers CRITICAL state for CPU. */ cpu_critical: number; /** The percentage threshhold which triggers WARNING state for MEM. */ mem_warning: number; /** The percentage threshhold which triggers CRITICAL state for MEM. */ mem_critical: number; /** The percentage threshhold which triggers WARNING state for DISK. */ disk_warning: number; /** The percentage threshhold which triggers CRITICAL state for DISK. */ disk_critical: number; /** Scheduled maintenance windows during which alerts will be suppressed. */ maintenance_windows?: MaintenanceWindow[]; } export type Server = Resource; export type GetServerResponse = Server; export interface StackActionState { pulling: boolean; deploying: boolean; starting: boolean; restarting: boolean; pausing: boolean; unpausing: boolean; stopping: boolean; destroying: boolean; } export type GetStackActionStateResponse = StackActionState; export type GetStackLogResponse = Log; export enum StackFileRequires { /** Diff requires service redeploy. */ Redeploy = "Redeploy", /** Diff requires service restart */ Restart = "Restart", /** Diff requires no action. Default. */ None = "None", } /** Configure additional file dependencies of the Stack. */ export interface StackFileDependency { /** Specify the file */ path: string; /** Specify specific service/s */ services?: string[]; /** Specify */ requires?: StackFileRequires; } /** The compose file configuration. */ export interface StackConfig { /** The server to deploy the stack on. */ server_id?: string; /** Configure quick links that are displayed in the resource header */ links?: string[]; /** * Optionally specify a custom project name for the stack. * If this is empty string, it will default to the stack name. * Used with `docker compose -p {project_name}`. * * Note. Can be used to import pre-existing stacks. */ project_name?: string; /** * Whether to automatically `compose pull` before redeploying stack. * Ensured latest images are deployed. * Will fail if the compose file specifies a locally build image. */ auto_pull: boolean; /** * Whether to `docker compose build` before `compose down` / `compose up`. * Combine with build_extra_args for custom behaviors. */ run_build?: boolean; /** Whether to poll for any updates to the images. */ poll_for_updates?: boolean; /** * Whether to automatically redeploy when * newer images are found. Will implicitly * enable `poll_for_updates`, you don't need to * enable both. */ auto_update?: boolean; /** * If auto update is enabled, Komodo will * by default only update the specific services * with image updates. If this parameter is set to true, * Komodo will redeploy the whole Stack (all services). */ auto_update_all_services?: boolean; /** Whether to run `docker compose down` before `compose up`. */ destroy_before_deploy?: boolean; /** Whether to skip secret interpolation into the stack environment variables. */ skip_secret_interp?: boolean; /** Choose a Komodo Repo (Resource) to source the compose files. */ linked_repo?: string; /** The git provider domain. Default: github.com */ git_provider: string; /** * Whether to use https to clone the repo (versus http). Default: true * * Note. Komodo does not currently support cloning repos via ssh. */ git_https: boolean; /** * The git account used to access private repos. * Passing empty string can only clone public repos. * * Note. A token for the account must be available in the core config or the builder server's periphery config * for the configured git provider. */ git_account?: string; /** * The repo used as the source of the build. * {namespace}/{repo_name} */ repo?: string; /** The branch of the repo. */ branch: string; /** Optionally set a specific commit hash. */ commit?: string; /** Optionally set a specific clone path */ clone_path?: string; /** * By default, the Stack will `git pull` the repo after it is first cloned. * If this option is enabled, the repo folder will be deleted and recloned instead. */ reclone?: boolean; /** Whether incoming webhooks actually trigger action. */ webhook_enabled: boolean; /** * Optionally provide an alternate webhook secret for this stack. * If its an empty string, use the default secret from the config. */ webhook_secret?: string; /** * By default, the Stack will `DeployStackIfChanged`. * If this option is enabled, will always run `DeployStack` without diffing. */ webhook_force_deploy?: boolean; /** * If this is checked, the stack will source the files on the host. * Use `run_directory` and `file_paths` to specify the path on the host. * This is useful for those who wish to setup their files on the host, * rather than defining the contents in UI or in a git repo. */ files_on_host?: boolean; /** Directory to change to (`cd`) before running `docker compose up -d`. */ run_directory?: string; /** * Add paths to compose files, relative to the run path. * If this is empty, will use file `compose.yaml`. */ file_paths?: string[]; /** * The name of the written environment file before `docker compose up`. * Relative to the run directory root. * Default: .env */ env_file_path: string; /** * Add additional env files to attach with `--env-file`. * Relative to the run directory root. * * Note. It is already included as an `additional_file`. * Don't add it again there. */ additional_env_files?: string[]; /** * Add additional config files either in repo or on host to track. * Can add any files associated with the stack to enable editing them in the UI. * Doing so will also include diffing these when deciding to deploy in `DeployStackIfChanged`. * Relative to the run directory. * * Note. If the config file is .env and should be included in compose command * using `--env-file`, add it to `additional_env_files` instead. */ config_files?: StackFileDependency[]; /** Whether to send StackStateChange alerts for this stack. */ send_alerts: boolean; /** Used with `registry_account` to login to a registry before docker compose up. */ registry_provider?: string; /** Used with `registry_provider` to login to a registry before docker compose up. */ registry_account?: string; /** The optional command to run before the Stack is deployed. */ pre_deploy?: SystemCommand; /** The optional command to run after the Stack is deployed. */ post_deploy?: SystemCommand; /** * The extra arguments to pass after `docker compose up -d`. * If empty, no extra arguments will be passed. */ extra_args?: string[]; /** * The extra arguments to pass after `docker compose build`. * If empty, no extra build arguments will be passed. * Only used if `run_build: true` */ build_extra_args?: string[]; /** * Ignore certain services declared in the compose file when checking * the stack status. For example, an init service might be exited, but the * stack should be healthy. This init service should be in `ignore_services` */ ignore_services?: string[]; /** * The contents of the file directly, for management in the UI. * If this is empty, it will fall back to checking git config for * repo based compose file. * Supports variable / secret interpolation. */ file_contents?: string; /** * The environment variables passed to the compose file. * They will be written to path defined in env_file_path, * which is given relative to the run directory. * * If it is empty, no file will be written. */ environment?: string; } export interface FileContents { /** The path to the file */ path: string; /** The contents of the file */ contents: string; } export interface StackServiceNames { /** The name of the service */ service_name: string; /** * Will either be the declared container_name in the compose file, * or a pattern to match auto named containers. * * Auto named containers are composed of three parts: * * 1. The name of the compose project (top level name field of compose file). * This defaults to the name of the parent folder of the compose file. * Komodo will always set it to be the name of the stack, but imported stacks * will have a different name. * 2. The service name * 3. The replica number * * Example: stacko-mongo-1. * * This stores only 1. and 2., ie stacko-mongo. * Containers will be matched via regex like `^container_name-?[0-9]*$`` */ container_name: string; /** The services image. */ image?: string; } /** * Same as [FileContents] with some extra * info specific to Stacks. */ export interface StackRemoteFileContents { /** The path to the file */ path: string; /** The contents of the file */ contents: string; /** * The services depending on this file, * or empty for global requirement (eg all compose files and env files). */ services?: string[]; /** Whether diff requires Redeploy / Restart / None */ requires?: StackFileRequires; } export interface StackInfo { /** * If any of the expected compose / additional files are missing in the repo, * they will be stored here. */ missing_files?: string[]; /** * The deployed project name. * This is updated whenever Komodo successfully deploys the stack. * If it is present, Komodo will use it for actions over other options, * to ensure control is maintained after changing the project name (there is no rename compose project api). */ deployed_project_name?: string; /** Deployed short commit hash, or null. Only for repo based stacks. */ deployed_hash?: string; /** Deployed commit message, or null. Only for repo based stacks */ deployed_message?: string; /** * The deployed compose / additional file contents. * This is updated whenever Komodo successfully deploys the stack. */ deployed_contents?: FileContents[]; /** * The deployed service names. * This is updated whenever it is empty, or deployed contents is updated. */ deployed_services?: StackServiceNames[]; /** * The output of `docker compose config`. * This is updated whenever Komodo successfully deploys the stack. */ deployed_config?: string; /** * The latest service names. * This is updated whenever the stack cache refreshes, using the latest file contents (either db defined or remote). */ latest_services?: StackServiceNames[]; /** * The remote compose / additional file contents, whether on host or in repo. * This is updated whenever Komodo refreshes the stack cache. * It will be empty if the file is defined directly in the stack config. */ remote_contents?: StackRemoteFileContents[]; /** If there was an error in getting the remote contents, it will be here. */ remote_errors?: FileContents[]; /** Latest commit hash, or null */ latest_hash?: string; /** Latest commit message, or null */ latest_message?: string; } export type Stack = Resource; export type GetStackResponse = Stack; /** System information of a server */ export interface SystemInformation { /** The system name */ name?: string; /** The system long os version */ os?: string; /** System's kernel version */ kernel?: string; /** Physical core count */ core_count?: number; /** System hostname based off DNS */ host_name?: string; /** The CPU's brand */ cpu_brand: string; /** Whether terminals are disabled on this Periphery server */ terminals_disabled: boolean; /** Whether container exec is disabled on this Periphery server */ container_exec_disabled: boolean; } export type GetSystemInformationResponse = SystemInformation; export interface SystemLoadAverage { /** 1m load average */ one: number; /** 5m load average */ five: number; /** 15m load average */ fifteen: number; } /** Info for a single disk mounted on the system. */ export interface SingleDiskUsage { /** The mount point of the disk */ mount: string; /** Detected file system */ file_system: string; /** Used portion of the disk in GB */ used_gb: number; /** Total size of the disk in GB */ total_gb: number; } export enum Timelength { /** `1-sec` */ OneSecond = "1-sec", /** `5-sec` */ FiveSeconds = "5-sec", /** `10-sec` */ TenSeconds = "10-sec", /** `15-sec` */ FifteenSeconds = "15-sec", /** `30-sec` */ ThirtySeconds = "30-sec", /** `1-min` */ OneMinute = "1-min", /** `2-min` */ TwoMinutes = "2-min", /** `5-min` */ FiveMinutes = "5-min", /** `10-min` */ TenMinutes = "10-min", /** `15-min` */ FifteenMinutes = "15-min", /** `30-min` */ ThirtyMinutes = "30-min", /** `1-hr` */ OneHour = "1-hr", /** `2-hr` */ TwoHours = "2-hr", /** `6-hr` */ SixHours = "6-hr", /** `8-hr` */ EightHours = "8-hr", /** `12-hr` */ TwelveHours = "12-hr", /** `1-day` */ OneDay = "1-day", /** `3-day` */ ThreeDay = "3-day", /** `1-wk` */ OneWeek = "1-wk", /** `2-wk` */ TwoWeeks = "2-wk", /** `30-day` */ ThirtyDays = "30-day", } /** Realtime system stats data. */ export interface SystemStats { /** Cpu usage percentage */ cpu_perc: number; /** Load average (1m, 5m, 15m) */ load_average?: SystemLoadAverage; /** * [1.15.9+] * Free memory in GB. * This is really the 'Free' memory, not the 'Available' memory. * It may be different than mem_total_gb - mem_used_gb. */ mem_free_gb?: number; /** Used memory in GB. 'Total' - 'Available' (not free) memory. */ mem_used_gb: number; /** Total memory in GB */ mem_total_gb: number; /** Breakdown of individual disks, ie their usages, sizes, and mount points */ disks: SingleDiskUsage[]; /** Network ingress usage in MB */ network_ingress_bytes?: number; /** Network egress usage in MB */ network_egress_bytes?: number; /** The rate the system stats are being polled from the system */ polling_rate: Timelength; /** Unix timestamp in milliseconds when stats were last polled */ refresh_ts: I64; /** Unix timestamp in milliseconds when disk list was last refreshed */ refresh_list_ts: I64; } export type GetSystemStatsResponse = SystemStats; export enum TagColor { LightSlate = "LightSlate", Slate = "Slate", DarkSlate = "DarkSlate", LightRed = "LightRed", Red = "Red", DarkRed = "DarkRed", LightOrange = "LightOrange", Orange = "Orange", DarkOrange = "DarkOrange", LightAmber = "LightAmber", Amber = "Amber", DarkAmber = "DarkAmber", LightYellow = "LightYellow", Yellow = "Yellow", DarkYellow = "DarkYellow", LightLime = "LightLime", Lime = "Lime", DarkLime = "DarkLime", LightGreen = "LightGreen", Green = "Green", DarkGreen = "DarkGreen", LightEmerald = "LightEmerald", Emerald = "Emerald", DarkEmerald = "DarkEmerald", LightTeal = "LightTeal", Teal = "Teal", DarkTeal = "DarkTeal", LightCyan = "LightCyan", Cyan = "Cyan", DarkCyan = "DarkCyan", LightSky = "LightSky", Sky = "Sky", DarkSky = "DarkSky", LightBlue = "LightBlue", Blue = "Blue", DarkBlue = "DarkBlue", LightIndigo = "LightIndigo", Indigo = "Indigo", DarkIndigo = "DarkIndigo", LightViolet = "LightViolet", Violet = "Violet", DarkViolet = "DarkViolet", LightPurple = "LightPurple", Purple = "Purple", DarkPurple = "DarkPurple", LightFuchsia = "LightFuchsia", Fuchsia = "Fuchsia", DarkFuchsia = "DarkFuchsia", LightPink = "LightPink", Pink = "Pink", DarkPink = "DarkPink", LightRose = "LightRose", Rose = "Rose", DarkRose = "DarkRose", } export interface Tag { /** * The Mongo ID of the tag. * This field is de/serialized from/to JSON as * `{ "_id": { "$oid": "..." }, ...(rest of serialized Tag) }` */ _id?: MongoId; name: string; owner?: string; /** Hex color code with alpha for UI display */ color?: TagColor; } export type GetTagResponse = Tag; export type GetUpdateResponse = Update; /** * Permission users at the group level. * * All users that are part of a group inherit the group's permissions. * A user can be a part of multiple groups. A user's permission on a particular resource * will be resolved to be the maximum permission level between the user's own permissions and * any groups they are a part of. */ export interface UserGroup { /** * The Mongo ID of the UserGroup. * This field is de/serialized from/to JSON as * `{ "_id": { "$oid": "..." }, ...(rest of serialized User) }` */ _id?: MongoId; /** A name for the user group */ name: string; /** Whether all users will implicitly have the permissions in this group. */ everyone?: boolean; /** User ids of group members */ users?: string[]; /** Give the user group elevated permissions on all resources of a certain type */ all?: Record; /** Unix time (ms) when user group last updated */ updated_at?: I64; } export type GetUserGroupResponse = UserGroup; export type GetUserResponse = User; export type GetVariableResponse = Variable; export enum ContainerStateStatusEnum { Running = "running", Created = "created", Paused = "paused", Restarting = "restarting", Exited = "exited", Removing = "removing", Dead = "dead", Empty = "", } export enum HealthStatusEnum { Empty = "", None = "none", Starting = "starting", Healthy = "healthy", Unhealthy = "unhealthy", } /** HealthcheckResult stores information about a single run of a healthcheck probe */ export interface HealthcheckResult { /** Date and time at which this check started in [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. */ Start?: string; /** Date and time at which this check ended in [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. */ End?: string; /** ExitCode meanings: - `0` healthy - `1` unhealthy - `2` reserved (considered unhealthy) - other values: error running probe */ ExitCode?: I64; /** Output from last check */ Output?: string; } /** Health stores information about the container's healthcheck results. */ export interface ContainerHealth { /** Status is one of `none`, `starting`, `healthy` or `unhealthy` - \"none\" Indicates there is no healthcheck - \"starting\" Starting indicates that the container is not yet ready - \"healthy\" Healthy indicates that the container is running correctly - \"unhealthy\" Unhealthy indicates that the container has a problem */ Status?: HealthStatusEnum; /** FailingStreak is the number of consecutive failures */ FailingStreak?: I64; /** Log contains the last few results (oldest first) */ Log?: HealthcheckResult[]; } /** ContainerState stores container's running state. It's part of ContainerJSONBase and will be returned by the \"inspect\" command. */ export interface ContainerState { /** String representation of the container state. Can be one of \"created\", \"running\", \"paused\", \"restarting\", \"removing\", \"exited\", or \"dead\". */ Status?: ContainerStateStatusEnum; /** Whether this container is running. Note that a running container can be _paused_. The `Running` and `Paused` booleans are not mutually exclusive: When pausing a container (on Linux), the freezer cgroup is used to suspend all processes in the container. Freezing the process requires the process to be running. As a result, paused containers are both `Running` _and_ `Paused`. Use the `Status` field instead to determine if a container's state is \"running\". */ Running?: boolean; /** Whether this container is paused. */ Paused?: boolean; /** Whether this container is restarting. */ Restarting?: boolean; /** Whether a process within this container has been killed because it ran out of memory since the container was last started. */ OOMKilled?: boolean; Dead?: boolean; /** The process ID of this container */ Pid?: I64; /** The last exit code of this container */ ExitCode?: I64; Error?: string; /** The time when this container was last started. */ StartedAt?: string; /** The time when this container last exited. */ FinishedAt?: string; Health?: ContainerHealth; } export type Usize = number; export interface ResourcesBlkioWeightDevice { Path?: string; Weight?: Usize; } export interface ThrottleDevice { /** Device path */ Path?: string; /** Rate */ Rate?: I64; } /** A device mapping between the host and container */ export interface DeviceMapping { PathOnHost?: string; PathInContainer?: string; CgroupPermissions?: string; } /** A request for devices to be sent to device drivers */ export interface DeviceRequest { Driver?: string; Count?: I64; DeviceIDs?: string[]; /** A list of capabilities; an OR list of AND lists of capabilities. */ Capabilities?: string[][]; /** Driver-specific options, specified as a key/value pairs. These options are passed directly to the driver. */ Options?: Record; } export interface ResourcesUlimits { /** Name of ulimit */ Name?: string; /** Soft limit */ Soft?: I64; /** Hard limit */ Hard?: I64; } /** The logging configuration for this container */ export interface HostConfigLogConfig { Type?: string; Config?: Record; } /** PortBinding represents a binding between a host IP address and a host port. */ export interface PortBinding { /** Host IP address that the container's port is mapped to. */ HostIp?: string; /** Host port number that the container's port is mapped to. */ HostPort?: string; } export enum RestartPolicyNameEnum { Empty = "", No = "no", Always = "always", UnlessStopped = "unless-stopped", OnFailure = "on-failure", } /** The behavior to apply when the container exits. The default is not to restart. An ever increasing delay (double the previous delay, starting at 100ms) is added before each restart to prevent flooding the server. */ export interface RestartPolicy { /** - Empty string means not to restart - `no` Do not automatically restart - `always` Always restart - `unless-stopped` Restart always except when the user has manually stopped the container - `on-failure` Restart only when the container exit code is non-zero */ Name?: RestartPolicyNameEnum; /** If `on-failure` is used, the number of times to retry before giving up. */ MaximumRetryCount?: I64; } export enum MountTypeEnum { Empty = "", Bind = "bind", Volume = "volume", Image = "image", Tmpfs = "tmpfs", Npipe = "npipe", Cluster = "cluster", } export enum MountBindOptionsPropagationEnum { Empty = "", Private = "private", Rprivate = "rprivate", Shared = "shared", Rshared = "rshared", Slave = "slave", Rslave = "rslave", } /** Optional configuration for the `bind` type. */ export interface MountBindOptions { /** A propagation mode with the value `[r]private`, `[r]shared`, or `[r]slave`. */ Propagation?: MountBindOptionsPropagationEnum; /** Disable recursive bind mount. */ NonRecursive?: boolean; /** Create mount point on host if missing */ CreateMountpoint?: boolean; /** Make the mount non-recursively read-only, but still leave the mount recursive (unless NonRecursive is set to `true` in conjunction). Addded in v1.44, before that version all read-only mounts were non-recursive by default. To match the previous behaviour this will default to `true` for clients on versions prior to v1.44. */ ReadOnlyNonRecursive?: boolean; /** Raise an error if the mount cannot be made recursively read-only. */ ReadOnlyForceRecursive?: boolean; } /** Map of driver specific options */ export interface MountVolumeOptionsDriverConfig { /** Name of the driver to use to create the volume. */ Name?: string; /** key/value map of driver specific options. */ Options?: Record; } /** Optional configuration for the `volume` type. */ export interface MountVolumeOptions { /** Populate volume with data from the target. */ NoCopy?: boolean; /** User-defined key/value metadata. */ Labels?: Record; DriverConfig?: MountVolumeOptionsDriverConfig; /** Source path inside the volume. Must be relative without any back traversals. */ Subpath?: string; } /** Optional configuration for the `tmpfs` type. */ export interface MountTmpfsOptions { /** The size for the tmpfs mount in bytes. */ SizeBytes?: I64; /** The permission mode for the tmpfs mount in an integer. */ Mode?: I64; } export interface ContainerMount { /** Container path. */ Target?: string; /** Mount source (e.g. a volume name, a host path). */ Source?: string; /** The mount type. Available types: - `bind` Mounts a file or directory from the host into the container. Must exist prior to creating the container. - `volume` Creates a volume with the given name and options (or uses a pre-existing volume with the same name and options). These are **not** removed when the container is removed. - `tmpfs` Create a tmpfs with the given options. The mount source cannot be specified for tmpfs. - `npipe` Mounts a named pipe from the host into the container. Must exist prior to creating the container. - `cluster` a Swarm cluster volume */ Type?: MountTypeEnum; /** Whether the mount should be read-only. */ ReadOnly?: boolean; /** The consistency requirement for the mount: `default`, `consistent`, `cached`, or `delegated`. */ Consistency?: string; BindOptions?: MountBindOptions; VolumeOptions?: MountVolumeOptions; TmpfsOptions?: MountTmpfsOptions; } export enum HostConfigCgroupnsModeEnum { Empty = "", Private = "private", Host = "host", } export enum HostConfigIsolationEnum { Empty = "", Default = "default", Process = "process", Hyperv = "hyperv", } /** Container configuration that depends on the host we are running on */ export interface HostConfig { /** An integer value representing this container's relative CPU weight versus other containers. */ CpuShares?: I64; /** Memory limit in bytes. */ Memory?: I64; /** Path to `cgroups` under which the container's `cgroup` is created. If the path is not absolute, the path is considered to be relative to the `cgroups` path of the init process. Cgroups are created if they do not already exist. */ CgroupParent?: string; /** Block IO weight (relative weight). */ BlkioWeight?: number; /** Block IO weight (relative device weight) in the form: ``` [{\"Path\": \"device_path\", \"Weight\": weight}] ``` */ BlkioWeightDevice?: ResourcesBlkioWeightDevice[]; /** Limit read rate (bytes per second) from a device, in the form: ``` [{\"Path\": \"device_path\", \"Rate\": rate}] ``` */ BlkioDeviceReadBps?: ThrottleDevice[]; /** Limit write rate (bytes per second) to a device, in the form: ``` [{\"Path\": \"device_path\", \"Rate\": rate}] ``` */ BlkioDeviceWriteBps?: ThrottleDevice[]; /** Limit read rate (IO per second) from a device, in the form: ``` [{\"Path\": \"device_path\", \"Rate\": rate}] ``` */ BlkioDeviceReadIOps?: ThrottleDevice[]; /** Limit write rate (IO per second) to a device, in the form: ``` [{\"Path\": \"device_path\", \"Rate\": rate}] ``` */ BlkioDeviceWriteIOps?: ThrottleDevice[]; /** The length of a CPU period in microseconds. */ CpuPeriod?: I64; /** Microseconds of CPU time that the container can get in a CPU period. */ CpuQuota?: I64; /** The length of a CPU real-time period in microseconds. Set to 0 to allocate no time allocated to real-time tasks. */ CpuRealtimePeriod?: I64; /** The length of a CPU real-time runtime in microseconds. Set to 0 to allocate no time allocated to real-time tasks. */ CpuRealtimeRuntime?: I64; /** CPUs in which to allow execution (e.g., `0-3`, `0,1`). */ CpusetCpus?: string; /** Memory nodes (MEMs) in which to allow execution (0-3, 0,1). Only effective on NUMA systems. */ CpusetMems?: string; /** A list of devices to add to the container. */ Devices?: DeviceMapping[]; /** a list of cgroup rules to apply to the container */ DeviceCgroupRules?: string[]; /** A list of requests for devices to be sent to device drivers. */ DeviceRequests?: DeviceRequest[]; /** Hard limit for kernel TCP buffer memory (in bytes). Depending on the OCI runtime in use, this option may be ignored. It is no longer supported by the default (runc) runtime. This field is omitted when empty. */ KernelMemoryTCP?: I64; /** Memory soft limit in bytes. */ MemoryReservation?: I64; /** Total memory limit (memory + swap). Set as `-1` to enable unlimited swap. */ MemorySwap?: I64; /** Tune a container's memory swappiness behavior. Accepts an integer between 0 and 100. */ MemorySwappiness?: I64; /** CPU quota in units of 10-9 CPUs. */ NanoCpus?: I64; /** Disable OOM Killer for the container. */ OomKillDisable?: boolean; /** Run an init inside the container that forwards signals and reaps processes. This field is omitted if empty, and the default (as configured on the daemon) is used. */ Init?: boolean; /** Tune a container's PIDs limit. Set `0` or `-1` for unlimited, or `null` to not change. */ PidsLimit?: I64; /** A list of resource limits to set in the container. For example: ``` {\"Name\": \"nofile\", \"Soft\": 1024, \"Hard\": 2048} ``` */ Ulimits?: ResourcesUlimits[]; /** The number of usable CPUs (Windows only). On Windows Server containers, the processor resource controls are mutually exclusive. The order of precedence is `CPUCount` first, then `CPUShares`, and `CPUPercent` last. */ CpuCount?: I64; /** The usable percentage of the available CPUs (Windows only). On Windows Server containers, the processor resource controls are mutually exclusive. The order of precedence is `CPUCount` first, then `CPUShares`, and `CPUPercent` last. */ CpuPercent?: I64; /** Maximum IOps for the container system drive (Windows only) */ IOMaximumIOps?: I64; /** Maximum IO in bytes per second for the container system drive (Windows only). */ IOMaximumBandwidth?: I64; /** A list of volume bindings for this container. Each volume binding is a string in one of these forms: - `host-src:container-dest[:options]` to bind-mount a host path into the container. Both `host-src`, and `container-dest` must be an _absolute_ path. - `volume-name:container-dest[:options]` to bind-mount a volume managed by a volume driver into the container. `container-dest` must be an _absolute_ path. `options` is an optional, comma-delimited list of: - `nocopy` disables automatic copying of data from the container path to the volume. The `nocopy` flag only applies to named volumes. - `[ro|rw]` mounts a volume read-only or read-write, respectively. If omitted or set to `rw`, volumes are mounted read-write. - `[z|Z]` applies SELinux labels to allow or deny multiple containers to read and write to the same volume. - `z`: a _shared_ content label is applied to the content. This label indicates that multiple containers can share the volume content, for both reading and writing. - `Z`: a _private unshared_ label is applied to the content. This label indicates that only the current container can use a private volume. Labeling systems such as SELinux require proper labels to be placed on volume content that is mounted into a container. Without a label, the security system can prevent a container's processes from using the content. By default, the labels set by the host operating system are not modified. - `[[r]shared|[r]slave|[r]private]` specifies mount [propagation behavior](https://www.kernel.org/doc/Documentation/filesystems/sharedsubtree.txt). This only applies to bind-mounted volumes, not internal volumes or named volumes. Mount propagation requires the source mount point (the location where the source directory is mounted in the host operating system) to have the correct propagation properties. For shared volumes, the source mount point must be set to `shared`. For slave volumes, the mount must be set to either `shared` or `slave`. */ Binds?: string[]; /** Path to a file where the container ID is written */ ContainerIDFile?: string; LogConfig?: HostConfigLogConfig; /** Network mode to use for this container. Supported standard values are: `bridge`, `host`, `none`, and `container:`. Any other value is taken as a custom network's name to which this container should connect to. */ NetworkMode?: string; PortBindings?: Record; RestartPolicy?: RestartPolicy; /** Automatically remove the container when the container's process exits. This has no effect if `RestartPolicy` is set. */ AutoRemove?: boolean; /** Driver that this container uses to mount volumes. */ VolumeDriver?: string; /** A list of volumes to inherit from another container, specified in the form `[:]`. */ VolumesFrom?: string[]; /** Specification for mounts to be added to the container. */ Mounts?: ContainerMount[]; /** Initial console size, as an `[height, width]` array. */ ConsoleSize?: number[]; /** Arbitrary non-identifying metadata attached to container and provided to the runtime when the container is started. */ Annotations?: Record; /** A list of kernel capabilities to add to the container. Conflicts with option 'Capabilities'. */ CapAdd?: string[]; /** A list of kernel capabilities to drop from the container. Conflicts with option 'Capabilities'. */ CapDrop?: string[]; /** cgroup namespace mode for the container. Possible values are: - `\"private\"`: the container runs in its own private cgroup namespace - `\"host\"`: use the host system's cgroup namespace If not specified, the daemon default is used, which can either be `\"private\"` or `\"host\"`, depending on daemon version, kernel support and configuration. */ CgroupnsMode?: HostConfigCgroupnsModeEnum; /** A list of DNS servers for the container to use. */ Dns?: string[]; /** A list of DNS options. */ DnsOptions?: string[]; /** A list of DNS search domains. */ DnsSearch?: string[]; /** A list of hostnames/IP mappings to add to the container's `/etc/hosts` file. Specified in the form `[\"hostname:IP\"]`. */ ExtraHosts?: string[]; /** A list of additional groups that the container process will run as. */ GroupAdd?: string[]; /** IPC sharing mode for the container. Possible values are: - `\"none\"`: own private IPC namespace, with /dev/shm not mounted - `\"private\"`: own private IPC namespace - `\"shareable\"`: own private IPC namespace, with a possibility to share it with other containers - `\"container:\"`: join another (shareable) container's IPC namespace - `\"host\"`: use the host system's IPC namespace If not specified, daemon default is used, which can either be `\"private\"` or `\"shareable\"`, depending on daemon version and configuration. */ IpcMode?: string; /** Cgroup to use for the container. */ Cgroup?: string; /** A list of links for the container in the form `container_name:alias`. */ Links?: string[]; /** An integer value containing the score given to the container in order to tune OOM killer preferences. */ OomScoreAdj?: I64; /** Set the PID (Process) Namespace mode for the container. It can be either: - `\"container:\"`: joins another container's PID namespace - `\"host\"`: use the host's PID namespace inside the container */ PidMode?: string; /** Gives the container full access to the host. */ Privileged?: boolean; /** Allocates an ephemeral host port for all of a container's exposed ports. Ports are de-allocated when the container stops and allocated when the container starts. The allocated port might be changed when restarting the container. The port is selected from the ephemeral port range that depends on the kernel. For example, on Linux the range is defined by `/proc/sys/net/ipv4/ip_local_port_range`. */ PublishAllPorts?: boolean; /** Mount the container's root filesystem as read only. */ ReadonlyRootfs?: boolean; /** A list of string values to customize labels for MLS systems, such as SELinux. */ SecurityOpt?: string[]; /** Storage driver options for this container, in the form `{\"size\": \"120G\"}`. */ StorageOpt?: Record; /** A map of container directories which should be replaced by tmpfs mounts, and their corresponding mount options. For example: ``` { \"/run\": \"rw,noexec,nosuid,size=65536k\" } ``` */ Tmpfs?: Record; /** UTS namespace to use for the container. */ UTSMode?: string; /** Sets the usernamespace mode for the container when usernamespace remapping option is enabled. */ UsernsMode?: string; /** Size of `/dev/shm` in bytes. If omitted, the system uses 64MB. */ ShmSize?: I64; /** A list of kernel parameters (sysctls) to set in the container. For example: ``` {\"net.ipv4.ip_forward\": \"1\"} ``` */ Sysctls?: Record; /** Runtime to use with this container. */ Runtime?: string; /** Isolation technology of the container. (Windows only) */ Isolation?: HostConfigIsolationEnum; /** The list of paths to be masked inside the container (this overrides the default set of paths). */ MaskedPaths?: string[]; /** The list of paths to be set as read-only inside the container (this overrides the default set of paths). */ ReadonlyPaths?: string[]; } /** Information about the storage driver used to store the container's and image's filesystem. */ export interface GraphDriverData { /** Name of the storage driver. */ Name?: string; /** Low-level storage metadata, provided as key/value pairs. This information is driver-specific, and depends on the storage-driver in use, and should be used for informational purposes only. */ Data?: Record; } /** MountPoint represents a mount point configuration inside the container. This is used for reporting the mountpoints in use by a container. */ export interface MountPoint { /** The mount type: - `bind` a mount of a file or directory from the host into the container. - `volume` a docker volume with the given `Name`. - `tmpfs` a `tmpfs`. - `npipe` a named pipe from the host into the container. - `cluster` a Swarm cluster volume */ Type?: MountTypeEnum; /** Name is the name reference to the underlying data defined by `Source` e.g., the volume name. */ Name?: string; /** Source location of the mount. For volumes, this contains the storage location of the volume (within `/var/lib/docker/volumes/`). For bind-mounts, and `npipe`, this contains the source (host) part of the bind-mount. For `tmpfs` mount points, this field is empty. */ Source?: string; /** Destination is the path relative to the container root (`/`) where the `Source` is mounted inside the container. */ Destination?: string; /** Driver is the volume driver used to create the volume (if it is a volume). */ Driver?: string; /** Mode is a comma separated list of options supplied by the user when creating the bind/volume mount. The default is platform-specific (`\"z\"` on Linux, empty on Windows). */ Mode?: string; /** Whether the mount is mounted writable (read-write). */ RW?: boolean; /** Propagation describes how mounts are propagated from the host into the mount point, and vice-versa. Refer to the [Linux kernel documentation](https://www.kernel.org/doc/Documentation/filesystems/sharedsubtree.txt) for details. This field is not used on Windows. */ Propagation?: string; } /** A test to perform to check that the container is healthy. */ export interface HealthConfig { /** The test to perform. Possible values are: - `[]` inherit healthcheck from image or parent image - `[\"NONE\"]` disable healthcheck - `[\"CMD\", args...]` exec arguments directly - `[\"CMD-SHELL\", command]` run command with system's default shell */ Test?: string[]; /** The time to wait between checks in nanoseconds. It should be 0 or at least 1000000 (1 ms). 0 means inherit. */ Interval?: I64; /** The time to wait before considering the check to have hung. It should be 0 or at least 1000000 (1 ms). 0 means inherit. */ Timeout?: I64; /** The number of consecutive failures needed to consider a container as unhealthy. 0 means inherit. */ Retries?: I64; /** Start period for the container to initialize before starting health-retries countdown in nanoseconds. It should be 0 or at least 1000000 (1 ms). 0 means inherit. */ StartPeriod?: I64; /** The time to wait between checks in nanoseconds during the start period. It should be 0 or at least 1000000 (1 ms). 0 means inherit. */ StartInterval?: I64; } /** Configuration for a container that is portable between hosts. When used as `ContainerConfig` field in an image, `ContainerConfig` is an optional field containing the configuration of the container that was last committed when creating the image. Previous versions of Docker builder used this field to store build cache, and it is not in active use anymore. */ export interface ContainerConfig { /** The hostname to use for the container, as a valid RFC 1123 hostname. */ Hostname?: string; /** The domain name to use for the container. */ Domainname?: string; /** The user that commands are run as inside the container. */ User?: string; /** Whether to attach to `stdin`. */ AttachStdin?: boolean; /** Whether to attach to `stdout`. */ AttachStdout?: boolean; /** Whether to attach to `stderr`. */ AttachStderr?: boolean; /** An object mapping ports to an empty object in the form: `{\"/\": {}}` */ ExposedPorts?: Record>; /** Attach standard streams to a TTY, including `stdin` if it is not closed. */ Tty?: boolean; /** Open `stdin` */ OpenStdin?: boolean; /** Close `stdin` after one attached client disconnects */ StdinOnce?: boolean; /** A list of environment variables to set inside the container in the form `[\"VAR=value\", ...]`. A variable without `=` is removed from the environment, rather than to have an empty value. */ Env?: string[]; /** Command to run specified as a string or an array of strings. */ Cmd?: string[]; Healthcheck?: HealthConfig; /** Command is already escaped (Windows only) */ ArgsEscaped?: boolean; /** The name (or reference) of the image to use when creating the container, or which was used when the container was created. */ Image?: string; /** An object mapping mount point paths inside the container to empty objects. */ Volumes?: Record>; /** The working directory for commands to run in. */ WorkingDir?: string; /** The entry point for the container as a string or an array of strings. If the array consists of exactly one empty string (`[\"\"]`) then the entry point is reset to system default (i.e., the entry point used by docker when there is no `ENTRYPOINT` instruction in the `Dockerfile`). */ Entrypoint?: string[]; /** Disable networking for the container. */ NetworkDisabled?: boolean; /** MAC address of the container. Deprecated: this field is deprecated in API v1.44 and up. Use EndpointSettings.MacAddress instead. */ MacAddress?: string; /** `ONBUILD` metadata that were defined in the image's `Dockerfile`. */ OnBuild?: string[]; /** User-defined key/value metadata. */ Labels?: Record; /** Signal to stop a container as a string or unsigned integer. */ StopSignal?: string; /** Timeout to stop a container in seconds. */ StopTimeout?: I64; /** Shell for when `RUN`, `CMD`, and `ENTRYPOINT` uses a shell. */ Shell?: string[]; } /** EndpointIPAMConfig represents an endpoint's IPAM configuration. */ export interface EndpointIpamConfig { IPv4Address?: string; IPv6Address?: string; LinkLocalIPs?: string[]; } /** Configuration for a network endpoint. */ export interface EndpointSettings { IPAMConfig?: EndpointIpamConfig; Links?: string[]; /** MAC address for the endpoint on this network. The network driver might ignore this parameter. */ MacAddress?: string; Aliases?: string[]; /** Unique ID of the network. */ NetworkID?: string; /** Unique ID for the service endpoint in a Sandbox. */ EndpointID?: string; /** Gateway address for this network. */ Gateway?: string; /** IPv4 address. */ IPAddress?: string; /** Mask length of the IPv4 address. */ IPPrefixLen?: I64; /** IPv6 gateway address. */ IPv6Gateway?: string; /** Global IPv6 address. */ GlobalIPv6Address?: string; /** Mask length of the global IPv6 address. */ GlobalIPv6PrefixLen?: I64; /** DriverOpts is a mapping of driver options and values. These options are passed directly to the driver and are driver specific. */ DriverOpts?: Record; /** List of all DNS names an endpoint has on a specific network. This list is based on the container name, network aliases, container short ID, and hostname. These DNS names are non-fully qualified but can contain several dots. You can get fully qualified DNS names by appending `.`. For instance, if container name is `my.ctr` and the network is named `testnet`, `DNSNames` will contain `my.ctr` and the FQDN will be `my.ctr.testnet`. */ DNSNames?: string[]; } /** NetworkSettings exposes the network settings in the API */ export interface NetworkSettings { /** Name of the default bridge interface when dockerd's --bridge flag is set. */ Bridge?: string; /** SandboxID uniquely represents a container's network stack. */ SandboxID?: string; Ports?: Record; /** SandboxKey is the full path of the netns handle */ SandboxKey?: string; /** Information about all networks that the container is connected to. */ Networks?: Record; } export interface Container { /** The ID of the container */ Id?: string; /** The time the container was created */ Created?: string; /** The path to the command being run */ Path?: string; /** The arguments to the command being run */ Args?: string[]; State?: ContainerState; /** The container's image ID */ Image?: string; ResolvConfPath?: string; HostnamePath?: string; HostsPath?: string; LogPath?: string; Name?: string; RestartCount?: I64; Driver?: string; Platform?: string; MountLabel?: string; ProcessLabel?: string; AppArmorProfile?: string; /** IDs of exec instances that are running in the container. */ ExecIDs?: string[]; HostConfig?: HostConfig; GraphDriver?: GraphDriverData; /** The size of files that have been created or changed by this container. */ SizeRw?: I64; /** The total size of all the files in this container. */ SizeRootFs?: I64; Mounts?: MountPoint[]; Config?: ContainerConfig; NetworkSettings?: NetworkSettings; } export type InspectDeploymentContainerResponse = Container; export type InspectDockerContainerResponse = Container; /** Information about the image's RootFS, including the layer IDs. */ export interface ImageInspectRootFs { Type?: string; Layers?: string[]; } /** Additional metadata of the image in the local cache. This information is local to the daemon, and not part of the image itself. */ export interface ImageInspectMetadata { /** Date and time at which the image was last tagged in [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. This information is only available if the image was tagged locally, and omitted otherwise. */ LastTagTime?: string; } /** Information about an image in the local image cache. */ export interface Image { /** ID is the content-addressable ID of an image. This identifier is a content-addressable digest calculated from the image's configuration (which includes the digests of layers used by the image). Note that this digest differs from the `RepoDigests` below, which holds digests of image manifests that reference the image. */ Id?: string; /** List of image names/tags in the local image cache that reference this image. Multiple image tags can refer to the same image, and this list may be empty if no tags reference the image, in which case the image is \"untagged\", in which case it can still be referenced by its ID. */ RepoTags?: string[]; /** List of content-addressable digests of locally available image manifests that the image is referenced from. Multiple manifests can refer to the same image. These digests are usually only available if the image was either pulled from a registry, or if the image was pushed to a registry, which is when the manifest is generated and its digest calculated. */ RepoDigests?: string[]; /** ID of the parent image. Depending on how the image was created, this field may be empty and is only set for images that were built/created locally. This field is empty if the image was pulled from an image registry. */ Parent?: string; /** Optional message that was set when committing or importing the image. */ Comment?: string; /** Date and time at which the image was created, formatted in [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. This information is only available if present in the image, and omitted otherwise. */ Created?: string; /** The version of Docker that was used to build the image. Depending on how the image was created, this field may be empty. */ DockerVersion?: string; /** Name of the author that was specified when committing the image, or as specified through MAINTAINER (deprecated) in the Dockerfile. */ Author?: string; /** Configuration for a container that is portable between hosts. */ Config?: ContainerConfig; /** Hardware CPU architecture that the image runs on. */ Architecture?: string; /** CPU architecture variant (presently ARM-only). */ Variant?: string; /** Operating System the image is built to run on. */ Os?: string; /** Operating System version the image is built to run on (especially for Windows). */ OsVersion?: string; /** Total size of the image including all layers it is composed of. */ Size?: I64; GraphDriver?: GraphDriverData; RootFS?: ImageInspectRootFs; Metadata?: ImageInspectMetadata; } export type InspectDockerImageResponse = Image; export interface IpamConfig { Subnet?: string; IPRange?: string; Gateway?: string; AuxiliaryAddresses: Record; } export interface Ipam { /** Name of the IPAM driver to use. */ Driver?: string; /** List of IPAM configuration options, specified as a map: ``` {\"Subnet\": , \"IPRange\": , \"Gateway\": , \"AuxAddress\": } ``` */ Config: IpamConfig[]; /** Driver-specific options, specified as a map. */ Options: Record; } export interface NetworkContainer { /** This is the key on the incoming map of NetworkContainer */ ContainerID?: string; Name?: string; EndpointID?: string; MacAddress?: string; IPv4Address?: string; IPv6Address?: string; } export interface Network { Name?: string; Id?: string; Created?: string; Scope?: string; Driver?: string; EnableIPv6?: boolean; IPAM?: Ipam; Internal?: boolean; Attachable?: boolean; Ingress?: boolean; /** This field is turned from map into array for easier usability. */ Containers: NetworkContainer[]; Options?: Record; Labels?: Record; } export type InspectDockerNetworkResponse = Network; export enum VolumeScopeEnum { Empty = "", Local = "local", Global = "global", } export type U64 = number; /** The version number of the object such as node, service, etc. This is needed to avoid conflicting writes. The client must send the version number along with the modified specification when updating these objects. This approach ensures safe concurrency and determinism in that the change on the object may not be applied if the version number has changed from the last read. In other words, if two update requests specify the same base version, only one of the requests can succeed. As a result, two separate update requests that happen at the same time will not unintentionally overwrite each other. */ export interface ObjectVersion { Index?: U64; } export enum ClusterVolumeSpecAccessModeScopeEnum { Empty = "", Single = "single", Multi = "multi", } export enum ClusterVolumeSpecAccessModeSharingEnum { Empty = "", None = "none", Readonly = "readonly", Onewriter = "onewriter", All = "all", } /** One cluster volume secret entry. Defines a key-value pair that is passed to the plugin. */ export interface ClusterVolumeSpecAccessModeSecrets { /** Key is the name of the key of the key-value pair passed to the plugin. */ Key?: string; /** Secret is the swarm Secret object from which to read data. This can be a Secret name or ID. The Secret data is retrieved by swarm and used as the value of the key-value pair passed to the plugin. */ Secret?: string; } export type Topology = Record; /** Requirements for the accessible topology of the volume. These fields are optional. For an in-depth description of what these fields mean, see the CSI specification. */ export interface ClusterVolumeSpecAccessModeAccessibilityRequirements { /** A list of required topologies, at least one of which the volume must be accessible from. */ Requisite?: Topology[]; /** A list of topologies that the volume should attempt to be provisioned in. */ Preferred?: Topology[]; } /** The desired capacity that the volume should be created with. If empty, the plugin will decide the capacity. */ export interface ClusterVolumeSpecAccessModeCapacityRange { /** The volume must be at least this big. The value of 0 indicates an unspecified minimum */ RequiredBytes?: I64; /** The volume must not be bigger than this. The value of 0 indicates an unspecified maximum. */ LimitBytes?: I64; } export enum ClusterVolumeSpecAccessModeAvailabilityEnum { Empty = "", Active = "active", Pause = "pause", Drain = "drain", } /** Defines how the volume is used by tasks. */ export interface ClusterVolumeSpecAccessMode { /** The set of nodes this volume can be used on at one time. - `single` The volume may only be scheduled to one node at a time. - `multi` the volume may be scheduled to any supported number of nodes at a time. */ Scope?: ClusterVolumeSpecAccessModeScopeEnum; /** The number and way that different tasks can use this volume at one time. - `none` The volume may only be used by one task at a time. - `readonly` The volume may be used by any number of tasks, but they all must mount the volume as readonly - `onewriter` The volume may be used by any number of tasks, but only one may mount it as read/write. - `all` The volume may have any number of readers and writers. */ Sharing?: ClusterVolumeSpecAccessModeSharingEnum; /** Swarm Secrets that are passed to the CSI storage plugin when operating on this volume. */ Secrets?: ClusterVolumeSpecAccessModeSecrets[]; AccessibilityRequirements?: ClusterVolumeSpecAccessModeAccessibilityRequirements; CapacityRange?: ClusterVolumeSpecAccessModeCapacityRange; /** The availability of the volume for use in tasks. - `active` The volume is fully available for scheduling on the cluster - `pause` No new workloads should use the volume, but existing workloads are not stopped. - `drain` All workloads using this volume should be stopped and rescheduled, and no new ones should be started. */ Availability?: ClusterVolumeSpecAccessModeAvailabilityEnum; } /** Cluster-specific options used to create the volume. */ export interface ClusterVolumeSpec { /** Group defines the volume group of this volume. Volumes belonging to the same group can be referred to by group name when creating Services. Referring to a volume by group instructs Swarm to treat volumes in that group interchangeably for the purpose of scheduling. Volumes with an empty string for a group technically all belong to the same, emptystring group. */ Group?: string; AccessMode?: ClusterVolumeSpecAccessMode; } /** Information about the global status of the volume. */ export interface ClusterVolumeInfo { /** The capacity of the volume in bytes. A value of 0 indicates that the capacity is unknown. */ CapacityBytes?: I64; /** A map of strings to strings returned from the storage plugin when the volume is created. */ VolumeContext?: Record; /** The ID of the volume as returned by the CSI storage plugin. This is distinct from the volume's ID as provided by Docker. This ID is never used by the user when communicating with Docker to refer to this volume. If the ID is blank, then the Volume has not been successfully created in the plugin yet. */ VolumeID?: string; /** The topology this volume is actually accessible from. */ AccessibleTopology?: Topology[]; } export enum ClusterVolumePublishStatusStateEnum { Empty = "", PendingPublish = "pending-publish", Published = "published", PendingNodeUnpublish = "pending-node-unpublish", PendingControllerUnpublish = "pending-controller-unpublish", } export interface ClusterVolumePublishStatus { /** The ID of the Swarm node the volume is published on. */ NodeID?: string; /** The published state of the volume. * `pending-publish` The volume should be published to this node, but the call to the controller plugin to do so has not yet been successfully completed. * `published` The volume is published successfully to the node. * `pending-node-unpublish` The volume should be unpublished from the node, and the manager is awaiting confirmation from the worker that it has done so. * `pending-controller-unpublish` The volume is successfully unpublished from the node, but has not yet been successfully unpublished on the controller. */ State?: ClusterVolumePublishStatusStateEnum; /** A map of strings to strings returned by the CSI controller plugin when a volume is published. */ PublishContext?: Record; } /** Options and information specific to, and only present on, Swarm CSI cluster volumes. */ export interface ClusterVolume { /** The Swarm ID of this volume. Because cluster volumes are Swarm objects, they have an ID, unlike non-cluster volumes. This ID can be used to refer to the Volume instead of the name. */ ID?: string; Version?: ObjectVersion; CreatedAt?: string; UpdatedAt?: string; Spec?: ClusterVolumeSpec; Info?: ClusterVolumeInfo; /** The status of the volume as it pertains to its publishing and use on specific nodes */ PublishStatus?: ClusterVolumePublishStatus[]; } /** Usage details about the volume. This information is used by the `GET /system/df` endpoint, and omitted in other endpoints. */ export interface VolumeUsageData { /** Amount of disk space used by the volume (in bytes). This information is only available for volumes created with the `\"local\"` volume driver. For volumes created with other volume drivers, this field is set to `-1` (\"not available\") */ Size: I64; /** The number of containers referencing this volume. This field is set to `-1` if the reference-count is not available. */ RefCount: I64; } export interface Volume { /** Name of the volume. */ Name: string; /** Name of the volume driver used by the volume. */ Driver: string; /** Mount path of the volume on the host. */ Mountpoint: string; /** Date/Time the volume was created. */ CreatedAt?: string; /** Low-level details about the volume, provided by the volume driver. Details are returned as a map with key/value pairs: `{\"key\":\"value\",\"key2\":\"value2\"}`. The `Status` field is optional, and is omitted if the volume driver does not support this feature. */ Status?: Record>; /** User-defined key/value metadata. */ Labels?: Record; /** The level at which the volume exists. Either `global` for cluster-wide, or `local` for machine level. */ Scope?: VolumeScopeEnum; ClusterVolume?: ClusterVolume; /** The driver specific options used when creating the volume. */ Options?: Record; UsageData?: VolumeUsageData; } export type InspectDockerVolumeResponse = Volume; export type InspectStackContainerResponse = Container; export type JsonObject = any; export type JsonValue = any; export type ListActionsResponse = ActionListItem[]; export type ListAlertersResponse = AlerterListItem[]; export enum PortTypeEnum { EMPTY = "", TCP = "tcp", UDP = "udp", SCTP = "sctp", } /** An open port on a container */ export interface Port { /** Host IP address that the container's port is mapped to */ IP?: string; /** Port on the container */ PrivatePort?: number; /** Port exposed on the host */ PublicPort?: number; Type?: PortTypeEnum; } /** Container summary returned by container list apis. */ export interface ContainerListItem { /** The Server which holds the container. */ server_id?: string; /** The first name in Names, not including the initial '/' */ name: string; /** The ID of this container */ id?: string; /** The name of the image used when creating this container */ image?: string; /** The ID of the image that this container was created from */ image_id?: string; /** When the container was created */ created?: I64; /** The size of files that have been created or changed by this container */ size_rw?: I64; /** The total size of all the files in this container */ size_root_fs?: I64; /** The state of this container (e.g. `exited`) */ state: ContainerStateStatusEnum; /** Additional human-readable status of this container (e.g. `Exit 0`) */ status?: string; /** The network mode */ network_mode?: string; /** The network names attached to container */ networks?: string[]; /** Port mappings for the container */ ports?: Port[]; /** The volume names attached to container */ volumes?: string[]; /** The container stats, if they can be retreived. */ stats?: ContainerStats; /** * The labels attached to container. * It's too big to send with container list, * can get it using InspectContainer */ labels?: Record; } export type ListAllDockerContainersResponse = ContainerListItem[]; /** An api key used to authenticate requests via request headers. */ export interface ApiKey { /** Unique key associated with secret */ key: string; /** Hash of the secret */ secret: string; /** User associated with the api key */ user_id: string; /** Name associated with the api key for management */ name: string; /** Timestamp of key creation */ created_at: I64; /** Expiry of key, or 0 if never expires */ expires: I64; } export type ListApiKeysForServiceUserResponse = ApiKey[]; export type ListApiKeysResponse = ApiKey[]; export interface BuildVersionResponseItem { version: Version; ts: I64; } export type ListBuildVersionsResponse = BuildVersionResponseItem[]; export type ListBuildersResponse = BuilderListItem[]; export type ListBuildsResponse = BuildListItem[]; export type ListCommonBuildExtraArgsResponse = string[]; export type ListCommonDeploymentExtraArgsResponse = string[]; export type ListCommonStackBuildExtraArgsResponse = string[]; export type ListCommonStackExtraArgsResponse = string[]; export interface ComposeProject { /** The compose project name. */ name: string; /** The status of the project, as returned by docker. */ status?: string; /** The compose files included in the project. */ compose_files: string[]; } export type ListComposeProjectsResponse = ComposeProject[]; export type ListDeploymentsResponse = DeploymentListItem[]; export type ListDockerContainersResponse = ContainerListItem[]; /** individual image layer information in response to ImageHistory operation */ export interface ImageHistoryResponseItem { Id: string; Created: I64; CreatedBy: string; Tags?: string[]; Size: I64; Comment: string; } export type ListDockerImageHistoryResponse = ImageHistoryResponseItem[]; export interface ImageListItem { /** The first tag in `repo_tags`, or Id if no tags. */ name: string; /** ID is the content-addressable ID of an image. This identifier is a content-addressable digest calculated from the image's configuration (which includes the digests of layers used by the image). Note that this digest differs from the `RepoDigests` below, which holds digests of image manifests that reference the image. */ id: string; /** ID of the parent image. Depending on how the image was created, this field may be empty and is only set for images that were built/created locally. This field is empty if the image was pulled from an image registry. */ parent_id: string; /** Date and time at which the image was created as a Unix timestamp (number of seconds sinds EPOCH). */ created: I64; /** Total size of the image including all layers it is composed of. */ size: I64; /** Whether the image is in use by any container */ in_use: boolean; } export type ListDockerImagesResponse = ImageListItem[]; export interface NetworkListItem { name?: string; id?: string; created?: string; scope?: string; driver?: string; enable_ipv6?: boolean; ipam_driver?: string; ipam_subnet?: string; ipam_gateway?: string; internal?: boolean; attachable?: boolean; ingress?: boolean; /** Whether the network is attached to one or more containers */ in_use: boolean; } export type ListDockerNetworksResponse = NetworkListItem[]; export interface ProviderAccount { /** The account username. Required. */ username: string; /** The account access token. Required. */ token?: string; } export interface DockerRegistry { /** The docker provider domain. Default: `docker.io`. */ domain: string; /** The accounts on the registry. Required. */ accounts: ProviderAccount[]; /** * Available organizations on the registry provider. * Used to push an image under an organization's repo rather than an account's repo. */ organizations?: string[]; } export type ListDockerRegistriesFromConfigResponse = DockerRegistry[]; export type ListDockerRegistryAccountsResponse = DockerRegistryAccount[]; export interface VolumeListItem { /** The name of the volume */ name: string; driver: string; mountpoint: string; created?: string; scope: VolumeScopeEnum; /** Amount of disk space used by the volume (in bytes). This information is only available for volumes created with the `\"local\"` volume driver. For volumes created with other volume drivers, this field is set to `-1` (\"not available\") */ size?: I64; /** Whether the volume is currently attached to any container */ in_use: boolean; } export type ListDockerVolumesResponse = VolumeListItem[]; export type ListFullActionsResponse = Action[]; export type ListFullAlertersResponse = Alerter[]; export type ListFullBuildersResponse = Builder[]; export type ListFullBuildsResponse = Build[]; export type ListFullDeploymentsResponse = Deployment[]; export type ListFullProceduresResponse = Procedure[]; export type ListFullReposResponse = Repo[]; export type ListFullResourceSyncsResponse = ResourceSync[]; export type ListFullServersResponse = Server[]; export type ListFullStacksResponse = Stack[]; export type ListGitProviderAccountsResponse = GitProviderAccount[]; export interface GitProvider { /** The git provider domain. Default: `github.com`. */ domain: string; /** Whether to use https. Default: true. */ https: boolean; /** The accounts on the git provider. Required. */ accounts: ProviderAccount[]; } export type ListGitProvidersFromConfigResponse = GitProvider[]; export type UserTarget = /** User Id */ | { type: "User", id: string } /** UserGroup Id */ | { type: "UserGroup", id: string }; /** Representation of a User or UserGroups permission on a resource. */ export interface Permission { /** The id of the permission document */ _id?: MongoId; /** The target User / UserGroup */ user_target: UserTarget; /** The target resource */ resource_target: ResourceTarget; /** The permission level for the [user_target] on the [resource_target]. */ level?: PermissionLevel; /** Any specific permissions for the [user_target] on the [resource_target]. */ specific?: Array; } export type ListPermissionsResponse = Permission[]; export enum ProcedureState { /** Currently running */ Running = "Running", /** Last run successful */ Ok = "Ok", /** Last run failed */ Failed = "Failed", /** Other case (never run) */ Unknown = "Unknown", } export interface ProcedureListItemInfo { /** Number of stages procedure has. */ stages: I64; /** Reflect whether last run successful / currently running. */ state: ProcedureState; /** Procedure last successful run timestamp in ms. */ last_run_at?: I64; /** * If the procedure has schedule enabled, this is the * next scheduled run time in unix ms. */ next_scheduled_run?: I64; /** * If there is an error parsing schedule expression, * it will be given here. */ schedule_error?: string; } export type ProcedureListItem = ResourceListItem; export type ListProceduresResponse = ProcedureListItem[]; export enum RepoState { /** Unknown case */ Unknown = "Unknown", /** Last clone / pull successful (or never cloned) */ Ok = "Ok", /** Last clone / pull failed */ Failed = "Failed", /** Currently cloning */ Cloning = "Cloning", /** Currently pulling */ Pulling = "Pulling", /** Currently building */ Building = "Building", } export interface RepoListItemInfo { /** The server that repo sits on. */ server_id: string; /** The builder that builds the repo. */ builder_id: string; /** Repo last cloned / pulled timestamp in ms. */ last_pulled_at: I64; /** Repo last built timestamp in ms. */ last_built_at: I64; /** The git provider domain */ git_provider: string; /** The configured repo */ repo: string; /** The configured branch */ branch: string; /** Full link to the repo. */ repo_link: string; /** The repo state */ state: RepoState; /** If the repo is cloned, will be the cloned short commit hash. */ cloned_hash?: string; /** If the repo is cloned, will be the cloned commit message. */ cloned_message?: string; /** If the repo is built, will be the latest built short commit hash. */ built_hash?: string; /** Will be the latest remote short commit hash. */ latest_hash?: string; } export type RepoListItem = ResourceListItem; export type ListReposResponse = RepoListItem[]; export enum ResourceSyncState { /** Currently syncing */ Syncing = "Syncing", /** Updates pending */ Pending = "Pending", /** Last sync successful (or never synced). No Changes pending */ Ok = "Ok", /** Last sync failed */ Failed = "Failed", /** Other case */ Unknown = "Unknown", } export interface ResourceSyncListItemInfo { /** Unix timestamp of last sync, or 0 */ last_sync_ts: I64; /** Whether sync is `files_on_host` mode. */ files_on_host: boolean; /** Whether sync has file contents defined. */ file_contents: boolean; /** Whether sync has `managed` mode enabled. */ managed: boolean; /** Resource paths to the files. */ resource_path: string[]; /** Linked repo, if one is attached. */ linked_repo: string; /** The git provider domain. */ git_provider: string; /** The Github repo used as the source of the sync resources */ repo: string; /** The branch of the repo */ branch: string; /** Full link to the repo. */ repo_link: string; /** Short commit hash of last sync, or empty string */ last_sync_hash?: string; /** Commit message of last sync, or empty string */ last_sync_message?: string; /** State of the sync. Reflects whether most recent sync successful. */ state: ResourceSyncState; } export type ResourceSyncListItem = ResourceListItem; export type ListResourceSyncsResponse = ResourceSyncListItem[]; /** A scheduled Action / Procedure run. */ export interface Schedule { /** Procedure or Alerter */ target: ResourceTarget; /** Readable name of the target resource */ name: string; /** The format of the schedule expression */ schedule_format: ScheduleFormat; /** The schedule for the run */ schedule: string; /** Whether the scheduled run is enabled */ enabled: boolean; /** Custom schedule timezone if it exists */ schedule_timezone: string; /** Last run timestamp in ms. */ last_run_at?: I64; /** Next scheduled run time in unix ms. */ next_scheduled_run?: I64; /** * If there is an error parsing schedule expression, * it will be given here. */ schedule_error?: string; /** Resource tags. */ tags: string[]; } export type ListSchedulesResponse = Schedule[]; export type ListSecretsResponse = string[]; export enum ServerState { /** Server health check passing. */ Ok = "Ok", /** Server is unreachable. */ NotOk = "NotOk", /** Server is disabled. */ Disabled = "Disabled", } export interface ServerListItemInfo { /** The server's state. */ state: ServerState; /** Region of the server. */ region: string; /** Address of the server. */ address: string; /** * External address of the server (reachable by users). * Used with links. */ external_address?: string; /** The Komodo Periphery version of the server. */ version: string; /** Whether server is configured to send unreachable alerts. */ send_unreachable_alerts: boolean; /** Whether server is configured to send cpu alerts. */ send_cpu_alerts: boolean; /** Whether server is configured to send mem alerts. */ send_mem_alerts: boolean; /** Whether server is configured to send disk alerts. */ send_disk_alerts: boolean; /** Whether server is configured to send version mismatch alerts. */ send_version_mismatch_alerts: boolean; /** Whether terminals are disabled for this Server. */ terminals_disabled: boolean; /** Whether container exec is disabled for this Server. */ container_exec_disabled: boolean; } export type ServerListItem = ResourceListItem; export type ListServersResponse = ServerListItem[]; export interface StackService { /** The service name */ service: string; /** The service image */ image: string; /** The container */ container?: ContainerListItem; /** Whether there is an update available for this services image. */ update_available: boolean; } export type ListStackServicesResponse = StackService[]; export enum StackState { /** The stack is currently re/deploying */ Deploying = "deploying", /** All containers are running. */ Running = "running", /** All containers are paused */ Paused = "paused", /** All contianers are stopped */ Stopped = "stopped", /** All containers are created */ Created = "created", /** All containers are restarting */ Restarting = "restarting", /** All containers are dead */ Dead = "dead", /** All containers are removing */ Removing = "removing", /** The containers are in a mix of states */ Unhealthy = "unhealthy", /** The stack is not deployed */ Down = "down", /** Server not reachable for status */ Unknown = "unknown", } export interface StackServiceWithUpdate { service: string; /** The service's image */ image: string; /** Whether there is a newer image available for this service */ update_available: boolean; } export interface StackListItemInfo { /** The server that stack is deployed on. */ server_id: string; /** Whether stack is using files on host mode */ files_on_host: boolean; /** Whether stack has file contents defined. */ file_contents: boolean; /** Linked repo, if one is attached. */ linked_repo: string; /** The git provider domain */ git_provider: string; /** The configured repo */ repo: string; /** The configured branch */ branch: string; /** Full link to the repo. */ repo_link: string; /** The stack state */ state: StackState; /** A string given by docker conveying the status of the stack. */ status?: string; /** * The services that are part of the stack. * If deployed, will be `deployed_services`. * Otherwise, its `latest_services` */ services: StackServiceWithUpdate[]; /** * Whether the compose project is missing on the host. * Ie, it does not show up in `docker compose ls`. * If true, and the stack is not Down, this is an unhealthy state. */ project_missing: boolean; /** * If any compose files are missing in the repo, the path will be here. * If there are paths here, this is an unhealthy state, and deploying will fail. */ missing_files: string[]; /** Deployed short commit hash, or null. Only for repo based stacks. */ deployed_hash?: string; /** Latest short commit hash, or null. Only for repo based stacks */ latest_hash?: string; } export type StackListItem = ResourceListItem; export type ListStacksResponse = StackListItem[]; /** Information about a process on the system. */ export interface SystemProcess { /** The process PID */ pid: number; /** The process name */ name: string; /** The path to the process executable */ exe?: string; /** The command used to start the process */ cmd: string[]; /** The time the process was started */ start_time?: number; /** * The cpu usage percentage of the process. * This is in core-percentage, eg 100% is 1 full core, and * an 8 core machine would max at 800%. */ cpu_perc: number; /** The memory usage of the process in MB */ mem_mb: number; /** Process disk read in KB/s */ disk_read_kb: number; /** Process disk write in KB/s */ disk_write_kb: number; } export type ListSystemProcessesResponse = SystemProcess[]; export type ListTagsResponse = Tag[]; /** * Info about an active terminal on a server. * Retrieve with [ListTerminals][crate::api::read::server::ListTerminals]. */ export interface TerminalInfo { /** The name of the terminal. */ name: string; /** The root program / args of the pty */ command: string; /** The size of the terminal history in memory. */ stored_size_kb: number; } export type ListTerminalsResponse = TerminalInfo[]; export type ListUserGroupsResponse = UserGroup[]; export type ListUserTargetPermissionsResponse = Permission[]; export type ListUsersResponse = User[]; export type ListVariablesResponse = Variable[]; /** The response for [LoginLocalUser] */ export type LoginLocalUserResponse = JwtResponse; export type MongoDocument = any; export interface ProcedureQuerySpecifics { } export type ProcedureQuery = ResourceQuery; export type PushRecentlyViewedResponse = NoData; export interface RepoQuerySpecifics { /** Filter repos by their repo. */ repos: string[]; } export type RepoQuery = ResourceQuery; export interface ResourceSyncQuerySpecifics { /** Filter syncs by their repo. */ repos: string[]; } export type ResourceSyncQuery = ResourceQuery; export type SearchContainerLogResponse = Log; export type SearchDeploymentLogResponse = Log; export type SearchStackLogResponse = Log; export interface ServerQuerySpecifics { } /** Server-specific query */ export type ServerQuery = ResourceQuery; export type SetLastSeenUpdateResponse = NoData; /** Response for [SignUpLocalUser]. */ export type SignUpLocalUserResponse = JwtResponse; export interface StackQuerySpecifics { /** * Query only for Stacks on these Servers. * If empty, does not filter by Server. * Only accepts Server id (not name). */ server_ids?: string[]; /** * Query only for Stacks with these linked repos. * Only accepts Repo id (not name). */ linked_repos?: string[]; /** Filter syncs by their repo. */ repos?: string[]; /** Query only for Stack with available image updates. */ update_available?: boolean; } export type StackQuery = ResourceQuery; export type UpdateDockerRegistryAccountResponse = DockerRegistryAccount; export type UpdateGitProviderAccountResponse = GitProviderAccount; export type UpdatePermissionOnResourceTypeResponse = NoData; export type UpdatePermissionOnTargetResponse = NoData; export type UpdateProcedureResponse = Procedure; export type UpdateResourceMetaResponse = NoData; export type UpdateServiceUserDescriptionResponse = User; export type UpdateUserAdminResponse = NoData; export type UpdateUserBasePermissionsResponse = NoData; export type UpdateUserPasswordResponse = NoData; export type UpdateUserUsernameResponse = NoData; export type UpdateVariableDescriptionResponse = Variable; export type UpdateVariableIsSecretResponse = Variable; export type UpdateVariableValueResponse = Variable; export type _PartialActionConfig = Partial; export type _PartialAlerterConfig = Partial; export type _PartialAwsBuilderConfig = Partial; export type _PartialBuildConfig = Partial; export type _PartialBuilderConfig = Partial; export type _PartialDeploymentConfig = Partial; export type _PartialDockerRegistryAccount = Partial; export type _PartialGitProviderAccount = Partial; export type _PartialProcedureConfig = Partial; export type _PartialRepoConfig = Partial; export type _PartialResourceSyncConfig = Partial; export type _PartialServerBuilderConfig = Partial; export type _PartialServerConfig = Partial; export type _PartialStackConfig = Partial; export type _PartialTag = Partial; export type _PartialUrlBuilderConfig = Partial; export interface __Serror { error: string; trace: string[]; } export type _Serror = __Serror; /** **Admin only.** Add a user to a user group. Response: [UserGroup] */ export interface AddUserToUserGroup { /** The name or id of UserGroup that user should be added to. */ user_group: string; /** The id or username of the user to add */ user: string; } /** Configuration for an AWS builder. */ export interface AwsBuilderConfig { /** The AWS region to create the instance in */ region: string; /** The instance type to create for the build */ instance_type: string; /** The size of the builder volume in gb */ volume_gb: number; /** * The port periphery will be running on. * Default: `8120` */ port: number; use_https: boolean; /** * The EC2 ami id to create. * The ami should have the periphery client configured to start on startup, * and should have the necessary github / dockerhub accounts configured. */ ami_id?: string; /** The subnet id to create the instance in. */ subnet_id?: string; /** The key pair name to attach to the instance */ key_pair_name?: string; /** * Whether to assign the instance a public IP address. * Likely needed for the instance to be able to reach the open internet. */ assign_public_ip?: boolean; /** * Whether core should use the public IP address to communicate with periphery on the builder. * If false, core will communicate with the instance using the private IP. */ use_public_ip?: boolean; /** * The security group ids to attach to the instance. * This should include a security group to allow core inbound access to the periphery port. */ security_group_ids?: string[]; /** The user data to deploy the instance with. */ user_data?: string; /** Which git providers are available on the AMI */ git_providers?: GitProvider[]; /** Which docker registries are available on the AMI. */ docker_registries?: DockerRegistry[]; /** Which secrets are available on the AMI. */ secrets?: string[]; } /** * Backs up the Komodo Core database to compressed jsonl files. * Admin only. Response: [Update] * * Mount a folder to `/backups`, and Core will use it to create * timestamped database dumps, which can be restored using * the Komodo CLI. * * https://komo.do/docs/setup/backup */ export interface BackupCoreDatabase { } /** Builds multiple Repos in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchBuildRepo { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* repos * foo-* * # add some more * extra-repo-1, extra-repo-2 * ``` */ pattern: string; } /** Clones multiple Repos in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchCloneRepo { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* repos * foo-* * # add some more * extra-repo-1, extra-repo-2 * ``` */ pattern: string; } /** Deploys multiple Deployments in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchDeploy { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* deployments * foo-* * # add some more * extra-deployment-1, extra-deployment-2 * ``` */ pattern: string; } /** Deploys multiple Stacks in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchDeployStack { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* stacks * foo-* * # add some more * extra-stack-1, extra-stack-2 * ``` */ pattern: string; } /** Deploys multiple Stacks if changed in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchDeployStackIfChanged { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* stacks * foo-* * # add some more * extra-stack-1, extra-stack-2 * ``` */ pattern: string; } /** Destroys multiple Deployments in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchDestroyDeployment { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* deployments * foo-* * # add some more * extra-deployment-1, extra-deployment-2 * ``` */ pattern: string; } /** Destroys multiple Stacks in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchDestroyStack { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * d * Example: * ```text * # match all foo-* stacks * foo-* * # add some more * extra-stack-1, extra-stack-2 * ``` */ pattern: string; } export interface BatchExecutionResponseItemErr { name: string; error: _Serror; } /** Pulls multiple Repos in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchPullRepo { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* repos * foo-* * # add some more * extra-repo-1, extra-repo-2 * ``` */ pattern: string; } /** Pulls multiple Stacks in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchPullStack { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* stacks * foo-* * # add some more * extra-stack-1, extra-stack-2 * ``` */ pattern: string; } /** Runs multiple Actions in parallel that match pattern. Response: [BatchExecutionResponse] */ export interface BatchRunAction { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* actions * foo-* * # add some more * extra-action-1, extra-action-2 * ``` */ pattern: string; } /** Runs multiple builds in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchRunBuild { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* builds * foo-* * # add some more * extra-build-1, extra-build-2 * ``` */ pattern: string; } /** Runs multiple Procedures in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchRunProcedure { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* procedures * foo-* * # add some more * extra-procedure-1, extra-procedure-2 * ``` */ pattern: string; } /** * Builds the target repo, using the attached builder. Response: [Update]. * * Note. Repo must have builder attached at `builder_id`. * * 1. Spawns the target builder instance (For AWS type. For Server type, just use CloneRepo). * 2. Clones the repo on the builder using `git clone https://{$token?}@github.com/${repo} -b ${branch}`. * The token will only be used if a github account is specified, * and must be declared in the periphery configuration on the builder instance. * 3. If `on_clone` and `on_pull` are specified, they will be executed. * `on_clone` will be executed before `on_pull`. */ export interface BuildRepo { /** Id or name */ repo: string; } /** Item in [GetBuildMonthlyStatsResponse] */ export interface BuildStatsDay { time: number; count: number; ts: number; } /** * Cancels the target build. * Only does anything if the build is `building` when called. * Response: [Update] */ export interface CancelBuild { /** Can be id or name */ build: string; } /** * Cancels the target repo build. * Only does anything if the repo build is `building` when called. * Response: [Update] */ export interface CancelRepoBuild { /** Can be id or name */ repo: string; } /** * Clears all repos from the Core repo cache. Admin only. * Response: [Update] */ export interface ClearRepoCache { } /** * Clones the target repo. Response: [Update]. * * Note. Repo must have server attached at `server_id`. * * 1. Clones the repo on the target server using `git clone https://{$token?}@github.com/${repo} -b ${branch}`. * The token will only be used if a github account is specified, * and must be declared in the periphery configuration on the target server. * 2. If `on_clone` and `on_pull` are specified, they will be executed. * `on_clone` will be executed before `on_pull`. */ export interface CloneRepo { /** Id or name */ repo: string; } /** * Exports matching resources, and writes to the target sync's resource file. Response: [Update] * * Note. Will fail if the Sync is not `managed`. */ export interface CommitSync { /** Id or name */ sync: string; } /** * Query to connect to a container exec session (interactive shell over websocket) on the given server. * TODO: Document calling. */ export interface ConnectContainerExecQuery { /** Server Id or name */ server: string; /** The container name */ container: string; /** The shell to use (eg. `sh` or `bash`) */ shell: string; } /** * Query to connect to a container exec session (interactive shell over websocket) on the given Deployment. * This call will use access to the Deployment Terminal to permission the call. * TODO: Document calling. */ export interface ConnectDeploymentExecQuery { /** Deployment Id or name */ deployment: string; /** The shell to use (eg. `sh` or `bash`) */ shell: string; } /** * Query to connect to a container exec session (interactive shell over websocket) on the given Stack / service. * This call will use access to the Stack Terminal to permission the call. * TODO: Document calling. */ export interface ConnectStackExecQuery { /** Stack Id or name */ stack: string; /** The service name to connect to */ service: string; /** The shell to use (eg. `sh` or `bash`) */ shell: string; } /** * Query to connect to a terminal (interactive shell over websocket) on the given server. * TODO: Document calling. */ export interface ConnectTerminalQuery { /** Server Id or name */ server: string; /** * Each periphery can keep multiple terminals open. * If a terminals with the specified name does not exist, * the call will fail. * Create a terminal using [CreateTerminal][super::write::server::CreateTerminal] */ terminal: string; } /** Blkio stats entry. This type is Linux-specific and omitted for Windows containers. */ export interface ContainerBlkioStatEntry { major?: U64; minor?: U64; op?: string; value?: U64; } /** * BlkioStats stores all IO service stats for data read and write. * This type is Linux-specific and holds many fields that are specific to cgroups v1. * On a cgroup v2 host, all fields other than `io_service_bytes_recursive` are omitted or `null`. * This type is only populated on Linux and omitted for Windows containers. */ export interface ContainerBlkioStats { io_service_bytes_recursive?: ContainerBlkioStatEntry[]; /** * This field is only available when using Linux containers with cgroups v1. * It is omitted or `null` when using cgroups v2. */ io_serviced_recursive?: ContainerBlkioStatEntry[]; /** * This field is only available when using Linux containers with cgroups v1. * It is omitted or `null` when using cgroups v2. */ io_queue_recursive?: ContainerBlkioStatEntry[]; /** * This field is only available when using Linux containers with cgroups v1. * It is omitted or `null` when using cgroups v2. */ io_service_time_recursive?: ContainerBlkioStatEntry[]; /** * This field is only available when using Linux containers with cgroups v1. * It is omitted or `null` when using cgroups v2. */ io_wait_time_recursive?: ContainerBlkioStatEntry[]; /** * This field is only available when using Linux containers with cgroups v1. * It is omitted or `null` when using cgroups v2. */ io_merged_recursive?: ContainerBlkioStatEntry[]; /** * This field is only available when using Linux containers with cgroups v1. * It is omitted or `null` when using cgroups v2. */ io_time_recursive?: ContainerBlkioStatEntry[]; /** * This field is only available when using Linux containers with cgroups v1. * It is omitted or `null` when using cgroups v2. */ sectors_recursive?: ContainerBlkioStatEntry[]; } /** All CPU stats aggregated since container inception. */ export interface ContainerCpuUsage { /** Total CPU time consumed in nanoseconds (Linux) or 100's of nanoseconds (Windows). */ total_usage?: U64; /** * Total CPU time (in nanoseconds) consumed per core (Linux). * This field is Linux-specific when using cgroups v1. * It is omitted when using cgroups v2 and Windows containers. */ percpu_usage?: U64[]; /** * Time (in nanoseconds) spent by tasks of the cgroup in kernel mode (Linux), * or time spent (in 100's of nanoseconds) by all container processes in kernel mode (Windows). * Not populated for Windows containers using Hyper-V isolation. */ usage_in_kernelmode?: U64; /** * Time (in nanoseconds) spent by tasks of the cgroup in user mode (Linux), * or time spent (in 100's of nanoseconds) by all container processes in kernel mode (Windows). * Not populated for Windows containers using Hyper-V isolation. */ usage_in_usermode?: U64; } /** * CPU throttling stats of the container. * This type is Linux-specific and omitted for Windows containers. */ export interface ContainerThrottlingData { /** Number of periods with throttling active. */ periods?: U64; /** Number of periods when the container hit its throttling limit. */ throttled_periods?: U64; /** Aggregated time (in nanoseconds) the container was throttled for. */ throttled_time?: U64; } /** CPU related info of the container */ export interface ContainerCpuStats { /** All CPU stats aggregated since container inception. */ cpu_usage?: ContainerCpuUsage; /** * System Usage. * This field is Linux-specific and omitted for Windows containers. */ system_cpu_usage?: U64; /** * Number of online CPUs. * This field is Linux-specific and omitted for Windows containers. */ online_cpus?: number; /** * CPU throttling stats of the container. * This type is Linux-specific and omitted for Windows containers. */ throttling_data?: ContainerThrottlingData; } /** * Aggregates all memory stats since container inception on Linux. * Windows returns stats for commit and private working set only. */ export interface ContainerMemoryStats { /** * Current `res_counter` usage for memory. * This field is Linux-specific and omitted for Windows containers. */ usage?: U64; /** * Maximum usage ever recorded. * This field is Linux-specific and only supported on cgroups v1. * It is omitted when using cgroups v2 and for Windows containers. */ max_usage?: U64; /** * All the stats exported via memory.stat. when using cgroups v2. * This field is Linux-specific and omitted for Windows containers. */ stats?: Record; /** Number of times memory usage hits limits. This field is Linux-specific and only supported on cgroups v1. It is omitted when using cgroups v2 and for Windows containers. */ failcnt?: U64; /** This field is Linux-specific and omitted for Windows containers. */ limit?: U64; /** * Committed bytes. * This field is Windows-specific and omitted for Linux containers. */ commitbytes?: U64; /** * Peak committed bytes. * This field is Windows-specific and omitted for Linux containers. */ commitpeakbytes?: U64; /** * Private working set. * This field is Windows-specific and omitted for Linux containers. */ privateworkingset?: U64; } /** Aggregates the network stats of one container */ export interface ContainerNetworkStats { /** Bytes received. Windows and Linux. */ rx_bytes?: U64; /** Packets received. Windows and Linux. */ rx_packets?: U64; /** * Received errors. Not used on Windows. * This field is Linux-specific and always zero for Windows containers. */ rx_errors?: U64; /** Incoming packets dropped. Windows and Linux. */ rx_dropped?: U64; /** Bytes sent. Windows and Linux. */ tx_bytes?: U64; /** Packets sent. Windows and Linux. */ tx_packets?: U64; /** * Sent errors. Not used on Windows. * This field is Linux-specific and always zero for Windows containers. */ tx_errors?: U64; /** Outgoing packets dropped. Windows and Linux. */ tx_dropped?: U64; /** * Endpoint ID. Not used on Linux. * This field is Windows-specific and omitted for Linux containers. */ endpoint_id?: string; /** * Instance ID. Not used on Linux. * This field is Windows-specific and omitted for Linux containers. */ instance_id?: string; } /** PidsStats contains Linux-specific stats of a container's process-IDs (PIDs). This type is Linux-specific and omitted for Windows containers. */ export interface ContainerPidsStats { /** Current is the number of PIDs in the cgroup. */ current?: U64; /** Limit is the hard limit on the number of pids in the cgroup. A \"Limit\" of 0 means that there is no limit. */ limit?: U64; } /** * StorageStats is the disk I/O stats for read/write on Windows. * This type is Windows-specific and omitted for Linux containers. */ export interface ContainerStorageStats { read_count_normalized?: U64; read_size_bytes?: U64; write_count_normalized?: U64; write_size_bytes?: U64; } export interface Conversion { /** reference on the server. */ local: string; /** reference in the container. */ container: string; } /** * Creates a new action with given `name` and the configuration * of the action at the given `id`. Response: [Action]. */ export interface CopyAction { /** The name of the new action. */ name: string; /** The id of the action to copy. */ id: string; } /** * Creates a new alerter with given `name` and the configuration * of the alerter at the given `id`. Response: [Alerter]. */ export interface CopyAlerter { /** The name of the new alerter. */ name: string; /** The id of the alerter to copy. */ id: string; } /** * Creates a new build with given `name` and the configuration * of the build at the given `id`. Response: [Build]. */ export interface CopyBuild { /** The name of the new build. */ name: string; /** The id of the build to copy. */ id: string; } /** * Creates a new builder with given `name` and the configuration * of the builder at the given `id`. Response: [Builder] */ export interface CopyBuilder { /** The name of the new builder. */ name: string; /** The id of the builder to copy. */ id: string; } /** * Creates a new deployment with given `name` and the configuration * of the deployment at the given `id`. Response: [Deployment] */ export interface CopyDeployment { /** The name of the new deployment. */ name: string; /** The id of the deployment to copy. */ id: string; } /** * Creates a new procedure with given `name` and the configuration * of the procedure at the given `id`. Response: [Procedure]. */ export interface CopyProcedure { /** The name of the new procedure. */ name: string; /** The id of the procedure to copy. */ id: string; } /** * Creates a new repo with given `name` and the configuration * of the repo at the given `id`. Response: [Repo]. */ export interface CopyRepo { /** The name of the new repo. */ name: string; /** The id of the repo to copy. */ id: string; } /** * Creates a new sync with given `name` and the configuration * of the sync at the given `id`. Response: [ResourceSync]. */ export interface CopyResourceSync { /** The name of the new sync. */ name: string; /** The id of the sync to copy. */ id: string; } /** * Creates a new server with given `name` and the configuration * of the server at the given `id`. Response: [Server]. */ export interface CopyServer { /** The name of the new server. */ name: string; /** The id of the server to copy. */ id: string; } /** * Creates a new stack with given `name` and the configuration * of the stack at the given `id`. Response: [Stack]. */ export interface CopyStack { /** The name of the new stack. */ name: string; /** The id of the stack to copy. */ id: string; } /** Create a action. Response: [Action]. */ export interface CreateAction { /** The name given to newly created action. */ name: string; /** Optional partial config to initialize the action with. */ config?: _PartialActionConfig; } /** * Create a webhook on the github action attached to the Action resource. * passed in request. Response: [CreateActionWebhookResponse] */ export interface CreateActionWebhook { /** Id or name */ action: string; } /** Create an alerter. Response: [Alerter]. */ export interface CreateAlerter { /** The name given to newly created alerter. */ name: string; /** Optional partial config to initialize the alerter with. */ config?: _PartialAlerterConfig; } /** * Create an api key for the calling user. * Response: [CreateApiKeyResponse]. * * Note. After the response is served, there will be no way * to get the secret later. */ export interface CreateApiKey { /** The name for the api key. */ name: string; /** * A unix timestamp in millseconds specifying api key expire time. * Default is 0, which means no expiry. */ expires?: I64; } /** * Admin only method to create an api key for a service user. * Response: [CreateApiKeyResponse]. */ export interface CreateApiKeyForServiceUser { /** Must be service user */ user_id: string; /** The name for the api key */ name: string; /** * A unix timestamp in millseconds specifying api key expire time. * Default is 0, which means no expiry. */ expires?: I64; } /** Create a build. Response: [Build]. */ export interface CreateBuild { /** The name given to newly created build. */ name: string; /** Optional partial config to initialize the build with. */ config?: _PartialBuildConfig; } /** * Create a webhook on the github repo attached to the build * passed in request. Response: [CreateBuildWebhookResponse] */ export interface CreateBuildWebhook { /** Id or name */ build: string; } /** Partial representation of [BuilderConfig] */ export type PartialBuilderConfig = | { type: "Url", params: _PartialUrlBuilderConfig } | { type: "Server", params: _PartialServerBuilderConfig } | { type: "Aws", params: _PartialAwsBuilderConfig }; /** Create a builder. Response: [Builder]. */ export interface CreateBuilder { /** The name given to newly created builder. */ name: string; /** Optional partial config to initialize the builder with. */ config?: PartialBuilderConfig; } /** Create a deployment. Response: [Deployment]. */ export interface CreateDeployment { /** The name given to newly created deployment. */ name: string; /** Optional partial config to initialize the deployment with. */ config?: _PartialDeploymentConfig; } /** Create a Deployment from an existing container. Response: [Deployment]. */ export interface CreateDeploymentFromContainer { /** The name or id of the existing container. */ name: string; /** The server id or name on which container exists. */ server: string; } /** * **Admin only.** Create a docker registry account. * Response: [DockerRegistryAccount]. */ export interface CreateDockerRegistryAccount { account: _PartialDockerRegistryAccount; } /** * **Admin only.** Create a git provider account. * Response: [GitProviderAccount]. */ export interface CreateGitProviderAccount { /** * The initial account config. Anything in the _id field will be ignored, * as this is generated on creation. */ account: _PartialGitProviderAccount; } /** * **Admin only.** Create a local user. * Response: [User]. * * Note. Not to be confused with /auth/SignUpLocalUser. * This method requires admin user credentials, and can * bypass disabled user registration. */ export interface CreateLocalUser { /** The username for the local user. */ username: string; /** A password for the local user. */ password: string; } /** * Create a docker network on the server. * Response: [Update] * * `docker network create {name}` */ export interface CreateNetwork { /** Server Id or name */ server: string; /** The name of the network to create. */ name: string; } /** Create a procedure. Response: [Procedure]. */ export interface CreateProcedure { /** The name given to newly created build. */ name: string; /** Optional partial config to initialize the procedure with. */ config?: _PartialProcedureConfig; } /** Create a repo. Response: [Repo]. */ export interface CreateRepo { /** The name given to newly created repo. */ name: string; /** Optional partial config to initialize the repo with. */ config?: _PartialRepoConfig; } export enum RepoWebhookAction { Clone = "Clone", Pull = "Pull", Build = "Build", } /** * Create a webhook on the github repo attached to the (Komodo) Repo resource. * passed in request. Response: [CreateRepoWebhookResponse] */ export interface CreateRepoWebhook { /** Id or name */ repo: string; /** "Clone" or "Pull" or "Build" */ action: RepoWebhookAction; } /** Create a sync. Response: [ResourceSync]. */ export interface CreateResourceSync { /** The name given to newly created sync. */ name: string; /** Optional partial config to initialize the sync with. */ config?: _PartialResourceSyncConfig; } /** Create a server. Response: [Server]. */ export interface CreateServer { /** The name given to newly created server. */ name: string; /** Optional partial config to initialize the server with. */ config?: _PartialServerConfig; } /** * **Admin only.** Create a service user. * Response: [User]. */ export interface CreateServiceUser { /** The username for the service user. */ username: string; /** A description for the service user. */ description: string; } /** Create a stack. Response: [Stack]. */ export interface CreateStack { /** The name given to newly created stack. */ name: string; /** Optional partial config to initialize the stack with. */ config?: _PartialStackConfig; } export enum StackWebhookAction { Refresh = "Refresh", Deploy = "Deploy", } /** * Create a webhook on the github repo attached to the stack * passed in request. Response: [CreateStackWebhookResponse] */ export interface CreateStackWebhook { /** Id or name */ stack: string; /** "Refresh" or "Deploy" */ action: StackWebhookAction; } export enum SyncWebhookAction { Refresh = "Refresh", Sync = "Sync", } /** * Create a webhook on the github repo attached to the sync * passed in request. Response: [CreateSyncWebhookResponse] */ export interface CreateSyncWebhook { /** Id or name */ sync: string; /** "Refresh" or "Sync" */ action: SyncWebhookAction; } /** Create a tag. Response: [Tag]. */ export interface CreateTag { /** The name of the tag. */ name: string; /** Tag color. Default: Slate. */ color?: TagColor; } /** * Configures the behavior of [CreateTerminal] if the * specified terminal name already exists. */ export enum TerminalRecreateMode { /** * Never kill the old terminal if it already exists. * If the command is different, returns error. */ Never = "Never", /** Always kill the old terminal and create new one */ Always = "Always", /** Only kill and recreate if the command is different. */ DifferentCommand = "DifferentCommand", } /** * Create a terminal on the server. * Response: [NoData] */ export interface CreateTerminal { /** Server Id or name */ server: string; /** The name of the terminal on the server to create. */ name: string; /** * The shell command (eg `bash`) to init the shell. * * This can also include args: * `docker exec -it container sh` * * Default: `bash` */ command: string; /** Default: `Never` */ recreate?: TerminalRecreateMode; } /** **Admin only.** Create a user group. Response: [UserGroup] */ export interface CreateUserGroup { /** The name to assign to the new UserGroup */ name: string; } /** **Admin only.** Create variable. Response: [Variable]. */ export interface CreateVariable { /** The name of the variable to create. */ name: string; /** The initial value of the variable. defualt: "". */ value?: string; /** The initial value of the description. default: "". */ description?: string; /** Whether to make this a secret variable. */ is_secret?: boolean; } /** Configuration for a Custom alerter endpoint. */ export interface CustomAlerterEndpoint { /** The http/s endpoint to send the POST to */ url: string; } /** * Deletes the action at the given id, and returns the deleted action. * Response: [Action] */ export interface DeleteAction { /** The id or name of the action to delete. */ id: string; } /** * Delete the webhook on the github action attached to the Action resource. * passed in request. Response: [DeleteActionWebhookResponse] */ export interface DeleteActionWebhook { /** Id or name */ action: string; } /** * Deletes the alerter at the given id, and returns the deleted alerter. * Response: [Alerter] */ export interface DeleteAlerter { /** The id or name of the alerter to delete. */ id: string; } /** * Delete all terminals on the server. * Response: [NoData] */ export interface DeleteAllTerminals { /** Server Id or name */ server: string; } /** * Delete an api key for the calling user. * Response: [NoData] */ export interface DeleteApiKey { /** The key which the user intends to delete. */ key: string; } /** * Admin only method to delete an api key for a service user. * Response: [NoData]. */ export interface DeleteApiKeyForServiceUser { key: string; } /** * Deletes the build at the given id, and returns the deleted build. * Response: [Build] */ export interface DeleteBuild { /** The id or name of the build to delete. */ id: string; } /** * Delete a webhook on the github repo attached to the build * passed in request. Response: [CreateBuildWebhookResponse] */ export interface DeleteBuildWebhook { /** Id or name */ build: string; } /** * Deletes the builder at the given id, and returns the deleted builder. * Response: [Builder] */ export interface DeleteBuilder { /** The id or name of the builder to delete. */ id: string; } /** * Deletes the deployment at the given id, and returns the deleted deployment. * Response: [Deployment]. * * Note. If the associated container is running, it will be deleted as part of * the deployment clean up. */ export interface DeleteDeployment { /** The id or name of the deployment to delete. */ id: string; } /** * **Admin only.** Delete a docker registry account. * Response: [DockerRegistryAccount]. */ export interface DeleteDockerRegistryAccount { /** The id of the docker registry account to delete */ id: string; } /** * **Admin only.** Delete a git provider account. * Response: [DeleteGitProviderAccountResponse]. */ export interface DeleteGitProviderAccount { /** The id of the git provider to delete */ id: string; } /** * Delete a docker image. * Response: [Update] */ export interface DeleteImage { /** Id or name. */ server: string; /** The name of the image to delete. */ name: string; } /** * Delete a docker network. * Response: [Update] */ export interface DeleteNetwork { /** Id or name. */ server: string; /** The name of the network to delete. */ name: string; } /** * Deletes the procedure at the given id, and returns the deleted procedure. * Response: [Procedure] */ export interface DeleteProcedure { /** The id or name of the procedure to delete. */ id: string; } /** * Deletes the repo at the given id, and returns the deleted repo. * Response: [Repo] */ export interface DeleteRepo { /** The id or name of the repo to delete. */ id: string; } /** * Delete the webhook on the github repo attached to the (Komodo) Repo resource. * passed in request. Response: [DeleteRepoWebhookResponse] */ export interface DeleteRepoWebhook { /** Id or name */ repo: string; /** "Clone" or "Pull" or "Build" */ action: RepoWebhookAction; } /** * Deletes the sync at the given id, and returns the deleted sync. * Response: [ResourceSync] */ export interface DeleteResourceSync { /** The id or name of the sync to delete. */ id: string; } /** * Deletes the server at the given id, and returns the deleted server. * Response: [Server] */ export interface DeleteServer { /** The id or name of the server to delete. */ id: string; } /** * Deletes the stack at the given id, and returns the deleted stack. * Response: [Stack] */ export interface DeleteStack { /** The id or name of the stack to delete. */ id: string; } /** * Delete the webhook on the github repo attached to the stack * passed in request. Response: [DeleteStackWebhookResponse] */ export interface DeleteStackWebhook { /** Id or name */ stack: string; /** "Refresh" or "Deploy" */ action: StackWebhookAction; } /** * Delete the webhook on the github repo attached to the sync * passed in request. Response: [DeleteSyncWebhookResponse] */ export interface DeleteSyncWebhook { /** Id or name */ sync: string; /** "Refresh" or "Sync" */ action: SyncWebhookAction; } /** * Delete a tag, and return the deleted tag. Response: [Tag]. * * Note. Will also remove this tag from all attached resources. */ export interface DeleteTag { /** The id of the tag to delete. */ id: string; } /** * Delete a terminal on the server. * Response: [NoData] */ export interface DeleteTerminal { /** Server Id or name */ server: string; /** The name of the terminal on the server to delete. */ terminal: string; } /** * **Admin only**. Delete a user. * Admins can delete any non-admin user. * Only Super Admin can delete an admin. * No users can delete a Super Admin user. * User cannot delete themselves. * Response: [NoData]. */ export interface DeleteUser { /** User id or username */ user: string; } /** **Admin only.** Delete a user group. Response: [UserGroup] */ export interface DeleteUserGroup { /** The id of the UserGroup */ id: string; } /** **Admin only.** Delete a variable. Response: [Variable]. */ export interface DeleteVariable { name: string; } /** * Delete a docker volume. * Response: [Update] */ export interface DeleteVolume { /** Id or name. */ server: string; /** The name of the volume to delete. */ name: string; } /** * Deploys the container for the target deployment. Response: [Update]. * * 1. Pulls the image onto the target server. * 2. If the container is already running, * it will be stopped and removed using `docker container rm ${container_name}`. * 3. The container will be run using `docker run {...params}`, * where params are determined by the deployment's configuration. */ export interface Deploy { /** Name or id */ deployment: string; /** * Override the default termination signal specified in the deployment. * Only used when deployment needs to be taken down before redeploy. */ stop_signal?: TerminationSignal; /** * Override the default termination max time. * Only used when deployment needs to be taken down before redeploy. */ stop_time?: number; } /** Deploys the target stack. `docker compose up`. Response: [Update] */ export interface DeployStack { /** Id or name */ stack: string; /** * Filter to only deploy specific services. * If empty, will deploy all services. */ services?: string[]; /** * Override the default termination max time. * Only used if the stack needs to be taken down first. */ stop_time?: number; } /** * Checks deployed contents vs latest contents, * and only if any changes found * will `docker compose up`. Response: [Update] */ export interface DeployStackIfChanged { /** Id or name */ stack: string; /** * Override the default termination max time. * Only used if the stack needs to be taken down first. */ stop_time?: number; } /** * Stops and destroys the container on the target server. * Reponse: [Update]. * * 1. The container is stopped and removed using `docker container rm ${container_name}`. */ export interface DestroyContainer { /** Name or id */ server: string; /** The container name */ container: string; /** Override the default termination signal. */ signal?: TerminationSignal; /** Override the default termination max time. */ time?: number; } /** * Stops and destroys the container for the target deployment. * Reponse: [Update]. * * 1. The container is stopped and removed using `docker container rm ${container_name}`. */ export interface DestroyDeployment { /** Name or id. */ deployment: string; /** Override the default termination signal specified in the deployment. */ signal?: TerminationSignal; /** Override the default termination max time. */ time?: number; } /** Destoys the target stack. `docker compose down`. Response: [Update] */ export interface DestroyStack { /** Id or name */ stack: string; /** * Filter to only destroy specific services. * If empty, will destroy all services. */ services?: string[]; /** Pass `--remove-orphans` */ remove_orphans?: boolean; /** Override the default termination max time. */ stop_time?: number; } /** Configuration for a Discord alerter. */ export interface DiscordAlerterEndpoint { /** The Discord webhook url */ url: string; } export interface EnvironmentVar { variable: string; value: string; } /** * Exchange a single use exchange token (safe for transport in url query) * for a jwt. * Response: [ExchangeForJwtResponse]. */ export interface ExchangeForJwt { /** The 'exchange token' */ token: string; } /** * Execute a command in the given containers shell. * TODO: Document calling. */ export interface ExecuteContainerExecBody { /** Server Id or name */ server: string; /** The container name */ container: string; /** The shell to use (eg. `sh` or `bash`) */ shell: string; /** The command to execute. */ command: string; } /** * Execute a command in the given containers shell. * TODO: Document calling. */ export interface ExecuteDeploymentExecBody { /** Deployment Id or name */ deployment: string; /** The shell to use (eg. `sh` or `bash`) */ shell: string; /** The command to execute. */ command: string; } /** * Execute a command in the given containers shell. * TODO: Document calling. */ export interface ExecuteStackExecBody { /** Stack Id or name */ stack: string; /** The service name to connect to */ service: string; /** The shell to use (eg. `sh` or `bash`) */ shell: string; /** The command to execute. */ command: string; } /** * Execute a terminal command on the given server. * TODO: Document calling. */ export interface ExecuteTerminalBody { /** Server Id or name */ server: string; /** * The name of the terminal on the server to use to execute. * If the terminal at name exists, it will be used to execute the command. * Otherwise, a new terminal will be created for this command, which will * persist until it exits or is deleted. */ terminal: string; /** The command to execute. */ command: string; } /** * Get pretty formatted monrun sync toml for all resources * which the user has permissions to view. * Response: [TomlResponse]. */ export interface ExportAllResourcesToToml { /** * Whether to include any resources (servers, stacks, etc.) * in the exported contents. * Default: `true` */ include_resources: boolean; /** * Filter resources by tag. * Accepts tag name or id. Empty array will not filter by tag. */ tags?: string[]; /** * Whether to include variables in the exported contents. * Default: false */ include_variables?: boolean; /** * Whether to include user groups in the exported contents. * Default: false */ include_user_groups?: boolean; } /** * Get pretty formatted monrun sync toml for specific resources and user groups. * Response: [TomlResponse]. */ export interface ExportResourcesToToml { /** The targets to include in the export. */ targets?: ResourceTarget[]; /** The user group names or ids to include in the export. */ user_groups?: string[]; /** Whether to include variables */ include_variables?: boolean; } /** * **Admin only.** * Find a user. * Response: [FindUserResponse] */ export interface FindUser { /** Id or username */ user: string; } /** Statistics sample for a container. */ export interface FullContainerStats { /** Name of the container */ name: string; /** ID of the container */ id?: string; /** * Date and time at which this sample was collected. * The value is formatted as [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) with nano-seconds. */ read?: string; /** * Date and time at which this first sample was collected. * This field is not propagated if the \"one-shot\" option is set. * If the \"one-shot\" option is set, this field may be omitted, empty, * or set to a default date (`0001-01-01T00:00:00Z`). * The value is formatted as [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) with nano-seconds. */ preread?: string; /** * PidsStats contains Linux-specific stats of a container's process-IDs (PIDs). * This type is Linux-specific and omitted for Windows containers. */ pids_stats?: ContainerPidsStats; /** * BlkioStats stores all IO service stats for data read and write. * This type is Linux-specific and holds many fields that are specific to cgroups v1. * On a cgroup v2 host, all fields other than `io_service_bytes_recursive` are omitted or `null`. * This type is only populated on Linux and omitted for Windows containers. */ blkio_stats?: ContainerBlkioStats; /** * The number of processors on the system. * This field is Windows-specific and always zero for Linux containers. */ num_procs?: number; storage_stats?: ContainerStorageStats; cpu_stats?: ContainerCpuStats; precpu_stats?: ContainerCpuStats; memory_stats?: ContainerMemoryStats; /** Network statistics for the container per interface. This field is omitted if the container has no networking enabled. */ networks?: Record; } /** Get a specific action. Response: [Action]. */ export interface GetAction { /** Id or name */ action: string; } /** Get current action state for the action. Response: [ActionActionState]. */ export interface GetActionActionState { /** Id or name */ action: string; } /** * Gets a summary of data relating to all actions. * Response: [GetActionsSummaryResponse]. */ export interface GetActionsSummary { } /** Response for [GetActionsSummary]. */ export interface GetActionsSummaryResponse { /** The total number of actions. */ total: number; /** The number of actions with Ok state. */ ok: number; /** The number of actions currently running. */ running: number; /** The number of actions with failed state. */ failed: number; /** The number of actions with unknown state. */ unknown: number; } /** Get an alert: Response: [Alert]. */ export interface GetAlert { id: string; } /** Get a specific alerter. Response: [Alerter]. */ export interface GetAlerter { /** Id or name */ alerter: string; } /** * Gets a summary of data relating to all alerters. * Response: [GetAlertersSummaryResponse]. */ export interface GetAlertersSummary { } /** Response for [GetAlertersSummary]. */ export interface GetAlertersSummaryResponse { total: number; } /** Get a specific build. Response: [Build]. */ export interface GetBuild { /** Id or name */ build: string; } /** Get current action state for the build. Response: [BuildActionState]. */ export interface GetBuildActionState { /** Id or name */ build: string; } /** * Gets summary and timeseries breakdown of the last months build count / time for charting. * Response: [GetBuildMonthlyStatsResponse]. * * Note. This method is paginated. One page is 30 days of data. * Query for older pages by incrementing the page, starting at 0. */ export interface GetBuildMonthlyStats { /** * Query for older data by incrementing the page. * `page: 0` is the default, and will return the most recent data. */ page?: number; } /** Response for [GetBuildMonthlyStats]. */ export interface GetBuildMonthlyStatsResponse { total_time: number; total_count: number; days: BuildStatsDay[]; } /** Get whether a Build's target repo has a webhook for the build configured. Response: [GetBuildWebhookEnabledResponse]. */ export interface GetBuildWebhookEnabled { /** Id or name */ build: string; } /** Response for [GetBuildWebhookEnabled] */ export interface GetBuildWebhookEnabledResponse { /** * Whether the repo webhooks can even be managed. * The repo owner must be in `github_webhook_app.owners` list to be managed. */ managed: boolean; /** Whether pushes to branch trigger build. Will always be false if managed is false. */ enabled: boolean; } /** Get a specific builder by id or name. Response: [Builder]. */ export interface GetBuilder { /** Id or name */ builder: string; } /** * Gets a summary of data relating to all builders. * Response: [GetBuildersSummaryResponse]. */ export interface GetBuildersSummary { } /** Response for [GetBuildersSummary]. */ export interface GetBuildersSummaryResponse { /** The total number of builders. */ total: number; } /** * Gets a summary of data relating to all builds. * Response: [GetBuildsSummaryResponse]. */ export interface GetBuildsSummary { } /** Response for [GetBuildsSummary]. */ export interface GetBuildsSummaryResponse { /** The total number of builds in Komodo. */ total: number; /** The number of builds with Ok state. */ ok: number; /** The number of builds with Failed state. */ failed: number; /** The number of builds currently building. */ building: number; /** The number of builds with unknown state. */ unknown: number; } /** * Get the container log's tail, split by stdout/stderr. * Response: [Log]. * * Note. This call will hit the underlying server directly for most up to date log. */ export interface GetContainerLog { /** Id or name */ server: string; /** The container name */ container: string; /** * The number of lines of the log tail to include. * Default: 100. * Max: 5000. */ tail: U64; /** Enable `--timestamps` */ timestamps?: boolean; } /** * Get info about the core api configuration. * Response: [GetCoreInfoResponse]. */ export interface GetCoreInfo { } /** Response for [GetCoreInfo]. */ export interface GetCoreInfoResponse { /** The title assigned to this core api. */ title: string; /** The monitoring interval of this core api. */ monitoring_interval: Timelength; /** The webhook base url. */ webhook_base_url: string; /** Whether transparent mode is enabled, which gives all users read access to all resources. */ transparent_mode: boolean; /** Whether UI write access should be disabled */ ui_write_disabled: boolean; /** Whether non admins can create resources */ disable_non_admin_create: boolean; /** Whether confirm dialog should be disabled */ disable_confirm_dialog: boolean; /** The repo owners for which github webhook management api is available */ github_webhook_owners: string[]; /** Whether to disable websocket automatic reconnect. */ disable_websocket_reconnect: boolean; /** Whether to enable fancy toml highlighting. */ enable_fancy_toml: boolean; /** TZ identifier Core is using, if manually set. */ timezone: string; } /** Get a specific deployment by name or id. Response: [Deployment]. */ export interface GetDeployment { /** Id or name */ deployment: string; } /** * Get current action state for the deployment. * Response: [DeploymentActionState]. */ export interface GetDeploymentActionState { /** Id or name */ deployment: string; } /** * Get the container, including image / status, of the target deployment. * Response: [GetDeploymentContainerResponse]. * * Note. This does not hit the server directly. The status comes from an * in memory cache on the core, which hits the server periodically * to keep it up to date. */ export interface GetDeploymentContainer { /** Id or name */ deployment: string; } /** Response for [GetDeploymentContainer]. */ export interface GetDeploymentContainerResponse { state: DeploymentState; container?: ContainerListItem; } /** * Get the deployment log's tail, split by stdout/stderr. * Response: [Log]. * * Note. This call will hit the underlying server directly for most up to date log. */ export interface GetDeploymentLog { /** Id or name */ deployment: string; /** * The number of lines of the log tail to include. * Default: 100. * Max: 5000. */ tail: U64; /** Enable `--timestamps` */ timestamps?: boolean; } /** * Get the deployment container's stats using `docker stats`. * Response: [GetDeploymentStatsResponse]. * * Note. This call will hit the underlying server directly for most up to date stats. */ export interface GetDeploymentStats { /** Id or name */ deployment: string; } /** * Gets a summary of data relating to all deployments. * Response: [GetDeploymentsSummaryResponse]. */ export interface GetDeploymentsSummary { } /** Response for [GetDeploymentsSummary]. */ export interface GetDeploymentsSummaryResponse { /** The total number of Deployments */ total: I64; /** The number of Deployments with Running state */ running: I64; /** The number of Deployments with Stopped or Paused state */ stopped: I64; /** The number of Deployments with NotDeployed state */ not_deployed: I64; /** The number of Deployments with Restarting or Dead or Created (other) state */ unhealthy: I64; /** The number of Deployments with Unknown state */ unknown: I64; } /** * Gets a summary of data relating to all containers. * Response: [GetDockerContainersSummaryResponse]. */ export interface GetDockerContainersSummary { } /** Response for [GetDockerContainersSummary] */ export interface GetDockerContainersSummaryResponse { /** The total number of Containers */ total: number; /** The number of Containers with Running state */ running: number; /** The number of Containers with Stopped or Paused or Created state */ stopped: number; /** The number of Containers with Restarting or Dead state */ unhealthy: number; /** The number of Containers with Unknown state */ unknown: number; } /** * Get a specific docker registry account. * Response: [GetDockerRegistryAccountResponse]. */ export interface GetDockerRegistryAccount { id: string; } /** * Get a specific git provider account. * Response: [GetGitProviderAccountResponse]. */ export interface GetGitProviderAccount { id: string; } /** * Paginated endpoint serving historical (timeseries) server stats for graphing. * Response: [GetHistoricalServerStatsResponse]. */ export interface GetHistoricalServerStats { /** Id or name */ server: string; /** The granularity of the data. */ granularity: Timelength; /** * Page of historical data. Default is 0, which is the most recent data. * Use with the `next_page` field of the response. */ page?: number; } /** System stats stored on the database. */ export interface SystemStatsRecord { /** Unix timestamp in milliseconds */ ts: I64; /** Server id */ sid: string; /** Cpu usage percentage */ cpu_perc: number; /** Load average (1m, 5m, 15m) */ load_average?: SystemLoadAverage; /** Memory used in GB */ mem_used_gb: number; /** Total memory in GB */ mem_total_gb: number; /** Disk used in GB */ disk_used_gb: number; /** Total disk size in GB */ disk_total_gb: number; /** Breakdown of individual disks, including their usage, total size, and mount point */ disks: SingleDiskUsage[]; /** Total network ingress in bytes */ network_ingress_bytes?: number; /** Total network egress in bytes */ network_egress_bytes?: number; } /** Response to [GetHistoricalServerStats]. */ export interface GetHistoricalServerStatsResponse { /** The timeseries page of data. */ stats: SystemStatsRecord[]; /** If there is a next page of data, pass this to `page` to get it. */ next_page?: number; } /** * Non authenticated route to see the available options * users have to login to Komodo, eg. local auth, github, google. * Response: [GetLoginOptionsResponse]. */ export interface GetLoginOptions { } /** The response for [GetLoginOptions]. */ export interface GetLoginOptionsResponse { /** Whether local auth is enabled. */ local: boolean; /** Whether github login is enabled. */ github: boolean; /** Whether google login is enabled. */ google: boolean; /** Whether OIDC login is enabled. */ oidc: boolean; /** Whether user registration (Sign Up) has been disabled */ registration_disabled: boolean; } /** * Get the version of the Komodo Periphery agent on the target server. * Response: [GetPeripheryVersionResponse]. */ export interface GetPeripheryVersion { /** Id or name */ server: string; } /** Response for [GetPeripheryVersion]. */ export interface GetPeripheryVersionResponse { /** The version of periphery. */ version: string; } /** * Gets the calling user's permission level on a specific resource. * Factors in any UserGroup's permissions they may be a part of. * Response: [PermissionLevel] */ export interface GetPermission { /** The target to get user permission on. */ target: ResourceTarget; } /** Get a specific procedure. Response: [Procedure]. */ export interface GetProcedure { /** Id or name */ procedure: string; } /** Get current action state for the procedure. Response: [ProcedureActionState]. */ export interface GetProcedureActionState { /** Id or name */ procedure: string; } /** * Gets a summary of data relating to all procedures. * Response: [GetProceduresSummaryResponse]. */ export interface GetProceduresSummary { } /** Response for [GetProceduresSummary]. */ export interface GetProceduresSummaryResponse { /** The total number of procedures. */ total: number; /** The number of procedures with Ok state. */ ok: number; /** The number of procedures currently running. */ running: number; /** The number of procedures with failed state. */ failed: number; /** The number of procedures with unknown state. */ unknown: number; } /** Get a specific repo. Response: [Repo]. */ export interface GetRepo { /** Id or name */ repo: string; } /** Get current action state for the repo. Response: [RepoActionState]. */ export interface GetRepoActionState { /** Id or name */ repo: string; } /** Get a target Repo's configured webhooks. Response: [GetRepoWebhooksEnabledResponse]. */ export interface GetRepoWebhooksEnabled { /** Id or name */ repo: string; } /** Response for [GetRepoWebhooksEnabled] */ export interface GetRepoWebhooksEnabledResponse { /** * Whether the repo webhooks can even be managed. * The repo owner must be in `github_webhook_app.owners` list to be managed. */ managed: boolean; /** Whether pushes to branch trigger clone. Will always be false if managed is false. */ clone_enabled: boolean; /** Whether pushes to branch trigger pull. Will always be false if managed is false. */ pull_enabled: boolean; /** Whether pushes to branch trigger build. Will always be false if managed is false. */ build_enabled: boolean; } /** * Gets a summary of data relating to all repos. * Response: [GetReposSummaryResponse]. */ export interface GetReposSummary { } /** Response for [GetReposSummary] */ export interface GetReposSummaryResponse { /** The total number of repos */ total: number; /** The number of repos with Ok state. */ ok: number; /** The number of repos currently cloning. */ cloning: number; /** The number of repos currently pulling. */ pulling: number; /** The number of repos currently building. */ building: number; /** The number of repos with failed state. */ failed: number; /** The number of repos with unknown state. */ unknown: number; } /** Find the attached resource for a container. Either Deployment or Stack. Response: [GetResourceMatchingContainerResponse]. */ export interface GetResourceMatchingContainer { /** Id or name */ server: string; /** The container name */ container: string; } /** Response for [GetResourceMatchingContainer]. Resource is either Deployment, Stack, or None. */ export interface GetResourceMatchingContainerResponse { resource?: ResourceTarget; } /** Get a specific sync. Response: [ResourceSync]. */ export interface GetResourceSync { /** Id or name */ sync: string; } /** Get current action state for the sync. Response: [ResourceSyncActionState]. */ export interface GetResourceSyncActionState { /** Id or name */ sync: string; } /** * Gets a summary of data relating to all syncs. * Response: [GetResourceSyncsSummaryResponse]. */ export interface GetResourceSyncsSummary { } /** Response for [GetResourceSyncsSummary] */ export interface GetResourceSyncsSummaryResponse { /** The total number of syncs */ total: number; /** The number of syncs with Ok state. */ ok: number; /** The number of syncs currently syncing. */ syncing: number; /** The number of syncs with pending updates */ pending: number; /** The number of syncs with failed state. */ failed: number; /** The number of syncs with unknown state. */ unknown: number; } /** Get a specific server. Response: [Server]. */ export interface GetServer { /** Id or name */ server: string; } /** Get current action state for the servers. Response: [ServerActionState]. */ export interface GetServerActionState { /** Id or name */ server: string; } /** Get the state of the target server. Response: [GetServerStateResponse]. */ export interface GetServerState { /** Id or name */ server: string; } /** The response for [GetServerState]. */ export interface GetServerStateResponse { /** The server status. */ status: ServerState; } /** * Gets a summary of data relating to all servers. * Response: [GetServersSummaryResponse]. */ export interface GetServersSummary { } /** Response for [GetServersSummary]. */ export interface GetServersSummaryResponse { /** The total number of servers. */ total: I64; /** The number of healthy (`status: OK`) servers. */ healthy: I64; /** The number of servers with warnings (e.g., version mismatch). */ warning: I64; /** The number of unhealthy servers. */ unhealthy: I64; /** The number of disabled servers. */ disabled: I64; } /** Get a specific stack. Response: [Stack]. */ export interface GetStack { /** Id or name */ stack: string; } /** Get current action state for the stack. Response: [StackActionState]. */ export interface GetStackActionState { /** Id or name */ stack: string; } /** * Get a stack's logs. Filter down included services. Response: [GetStackLogResponse]. * * Note. This call will hit the underlying server directly for most up to date log. */ export interface GetStackLog { /** Id or name */ stack: string; /** * Filter the logs to only ones from specific services. * If empty, will include logs from all services. */ services: string[]; /** * The number of lines of the log tail to include. * Default: 100. * Max: 5000. */ tail: U64; /** Enable `--timestamps` */ timestamps?: boolean; } /** Get a target stack's configured webhooks. Response: [GetStackWebhooksEnabledResponse]. */ export interface GetStackWebhooksEnabled { /** Id or name */ stack: string; } /** Response for [GetStackWebhooksEnabled] */ export interface GetStackWebhooksEnabledResponse { /** * Whether the repo webhooks can even be managed. * The repo owner must be in `github_webhook_app.owners` list to be managed. */ managed: boolean; /** Whether pushes to branch trigger refresh. Will always be false if managed is false. */ refresh_enabled: boolean; /** Whether pushes to branch trigger stack execution. Will always be false if managed is false. */ deploy_enabled: boolean; } /** * Gets a summary of data relating to all syncs. * Response: [GetStacksSummaryResponse]. */ export interface GetStacksSummary { } /** Response for [GetStacksSummary] */ export interface GetStacksSummaryResponse { /** The total number of stacks */ total: number; /** The number of stacks with Running state. */ running: number; /** The number of stacks with Stopped or Paused state. */ stopped: number; /** The number of stacks with Down state. */ down: number; /** The number of stacks with Unhealthy or Restarting or Dead or Created or Removing state. */ unhealthy: number; /** The number of stacks with Unknown state. */ unknown: number; } /** Get a target Sync's configured webhooks. Response: [GetSyncWebhooksEnabledResponse]. */ export interface GetSyncWebhooksEnabled { /** Id or name */ sync: string; } /** Response for [GetSyncWebhooksEnabled] */ export interface GetSyncWebhooksEnabledResponse { /** * Whether the repo webhooks can even be managed. * The repo owner must be in `github_webhook_app.owners` list to be managed. */ managed: boolean; /** Whether pushes to branch trigger refresh. Will always be false if managed is false. */ refresh_enabled: boolean; /** Whether pushes to branch trigger sync execution. Will always be false if managed is false. */ sync_enabled: boolean; } /** * Get the system information of the target server. * Response: [SystemInformation]. */ export interface GetSystemInformation { /** Id or name */ server: string; } /** * Get the system stats on the target server. Response: [SystemStats]. * * Note. This does not hit the server directly. The stats come from an * in memory cache on the core, which hits the server periodically * to keep it up to date. */ export interface GetSystemStats { /** Id or name */ server: string; } /** Get data for a specific tag. Response [Tag]. */ export interface GetTag { /** Id or name */ tag: string; } /** * Get all data for the target update. * Response: [Update]. */ export interface GetUpdate { /** The update id. */ id: string; } /** * Get the user extracted from the request headers. * Response: [User]. */ export interface GetUser { } /** * Get a specific user group by name or id. * Response: [UserGroup]. */ export interface GetUserGroup { /** Name or Id */ user_group: string; } /** * Gets the username of a specific user. * Response: [GetUsernameResponse] */ export interface GetUsername { /** The id of the user. */ user_id: string; } /** Response for [GetUsername]. */ export interface GetUsernameResponse { /** The username of the user. */ username: string; /** An optional icon for the user. */ avatar?: string; } /** * List all available global variables. * Response: [Variable] * * Note. For non admin users making this call, * secret variables will have their values obscured. */ export interface GetVariable { /** The name of the variable to get. */ name: string; } /** * Get the version of the Komodo Core api. * Response: [GetVersionResponse]. */ export interface GetVersion { } /** Response for [GetVersion]. */ export interface GetVersionResponse { /** The version of the core api. */ version: string; } /** * Trigger a global poll for image updates on Stacks and Deployments * with `poll_for_updates` or `auto_update` enabled. * Admin only. Response: [Update] * * 1. `docker compose pull` any Stacks / Deployments with `poll_for_updates` or `auto_update` enabled. This will pick up any available updates. * 2. Redeploy Stacks / Deployments that have updates found and 'auto_update' enabled. */ export interface GlobalAutoUpdate { } /** * Inspect the docker container associated with the Deployment. * Response: [Container]. */ export interface InspectDeploymentContainer { /** Id or name */ deployment: string; } /** Inspect a docker container on the server. Response: [Container]. */ export interface InspectDockerContainer { /** Id or name */ server: string; /** The container name */ container: string; } /** Inspect a docker image on the server. Response: [Image]. */ export interface InspectDockerImage { /** Id or name */ server: string; /** The image name */ image: string; } /** Inspect a docker network on the server. Response: [InspectDockerNetworkResponse]. */ export interface InspectDockerNetwork { /** Id or name */ server: string; /** The network name */ network: string; } /** Inspect a docker volume on the server. Response: [Volume]. */ export interface InspectDockerVolume { /** Id or name */ server: string; /** The volume name */ volume: string; } /** * Inspect the docker container associated with the Stack. * Response: [Container]. */ export interface InspectStackContainer { /** Id or name */ stack: string; /** The service name to inspect */ service: string; } export interface LatestCommit { hash: string; message: string; } /** List actions matching optional query. Response: [ListActionsResponse]. */ export interface ListActions { /** optional structured query to filter actions. */ query?: ActionQuery; } /** List alerters matching optional query. Response: [ListAlertersResponse]. */ export interface ListAlerters { /** Structured query to filter alerters. */ query?: AlerterQuery; } /** * Get a paginated list of alerts sorted by timestamp descending. * Response: [ListAlertsResponse]. */ export interface ListAlerts { /** * Pass a custom mongo query to filter the alerts. * * ## Example JSON * ```json * { * "resolved": "false", * "level": "CRITICAL", * "$or": [ * { * "target": { * "type": "Server", * "id": "6608bf89cb2a12b257ab6c09" * } * }, * { * "target": { * "type": "Server", * "id": "660a5f60b74f90d5dae45fa3" * } * } * ] * } * ``` * This will filter to only include open alerts that have CRITICAL level on those two servers. */ query?: MongoDocument; /** * Retrieve older results by incrementing the page. * `page: 0` is default, and returns the most recent results. */ page?: U64; } /** Response for [ListAlerts]. */ export interface ListAlertsResponse { alerts: Alert[]; /** * If more alerts exist, the next page will be given here. * Otherwise it will be `null` */ next_page?: I64; } /** * List all docker containers on the target server. * Response: [ListDockerContainersResponse]. */ export interface ListAllDockerContainers { /** Filter by server id or name. */ servers?: string[]; } /** * Gets list of api keys for the calling user. * Response: [ListApiKeysResponse] */ export interface ListApiKeys { } /** * **Admin only.** * Gets list of api keys for the user. * Will still fail if you call for a user_id that isn't a service user. * Response: [ListApiKeysForServiceUserResponse] */ export interface ListApiKeysForServiceUser { /** Id or username */ user: string; } /** * Retrieve versions of the build that were built in the past and available for deployment, * sorted by most recent first. * Response: [ListBuildVersionsResponse]. */ export interface ListBuildVersions { /** Id or name */ build: string; /** Filter to only include versions matching this major version. */ major?: number; /** Filter to only include versions matching this minor version. */ minor?: number; /** Filter to only include versions matching this patch version. */ patch?: number; /** Limit the number of included results. Default is no limit. */ limit?: I64; } /** List builders matching structured query. Response: [ListBuildersResponse]. */ export interface ListBuilders { query?: BuilderQuery; } /** List builds matching optional query. Response: [ListBuildsResponse]. */ export interface ListBuilds { /** optional structured query to filter builds. */ query?: BuildQuery; } /** * Gets a list of existing values used as extra args across other builds. * Useful to offer suggestions. Response: [ListCommonBuildExtraArgsResponse] */ export interface ListCommonBuildExtraArgs { /** optional structured query to filter builds. */ query?: BuildQuery; } /** * Gets a list of existing values used as extra args across other deployments. * Useful to offer suggestions. Response: [ListCommonDeploymentExtraArgsResponse] */ export interface ListCommonDeploymentExtraArgs { /** optional structured query to filter deployments. */ query?: DeploymentQuery; } /** * Gets a list of existing values used as build extra args across other stacks. * Useful to offer suggestions. Response: [ListCommonStackBuildExtraArgsResponse] */ export interface ListCommonStackBuildExtraArgs { /** optional structured query to filter stacks. */ query?: StackQuery; } /** * Gets a list of existing values used as extra args across other stacks. * Useful to offer suggestions. Response: [ListCommonStackExtraArgsResponse] */ export interface ListCommonStackExtraArgs { /** optional structured query to filter stacks. */ query?: StackQuery; } /** * List all docker compose projects on the target server. * Response: [ListComposeProjectsResponse]. */ export interface ListComposeProjects { /** Id or name */ server: string; } /** * List deployments matching optional query. * Response: [ListDeploymentsResponse]. */ export interface ListDeployments { /** optional structured query to filter deployments. */ query?: DeploymentQuery; } /** * List all docker containers on the target server. * Response: [ListDockerContainersResponse]. */ export interface ListDockerContainers { /** Id or name */ server: string; } /** Get image history from the server. Response: [ListDockerImageHistoryResponse]. */ export interface ListDockerImageHistory { /** Id or name */ server: string; /** The image name */ image: string; } /** * List the docker images locally cached on the target server. * Response: [ListDockerImagesResponse]. */ export interface ListDockerImages { /** Id or name */ server: string; } /** List the docker networks on the server. Response: [ListDockerNetworksResponse]. */ export interface ListDockerNetworks { /** Id or name */ server: string; } /** * List the docker registry providers available in Core / Periphery config files. * Response: [ListDockerRegistriesFromConfigResponse]. * * Includes: * - registries in core config * - registries configured on builds, deployments * - registries on the optional Server or Builder */ export interface ListDockerRegistriesFromConfig { /** * Accepts an optional Server or Builder target to expand the core list with * providers available on that specific resource. */ target?: ResourceTarget; } /** * List docker registry accounts matching optional query. * Response: [ListDockerRegistryAccountsResponse]. */ export interface ListDockerRegistryAccounts { /** Optionally filter by accounts with a specific domain. */ domain?: string; /** Optionally filter by accounts with a specific username. */ username?: string; } /** * List all docker volumes on the target server. * Response: [ListDockerVolumesResponse]. */ export interface ListDockerVolumes { /** Id or name */ server: string; } /** List actions matching optional query. Response: [ListFullActionsResponse]. */ export interface ListFullActions { /** optional structured query to filter actions. */ query?: ActionQuery; } /** List full alerters matching optional query. Response: [ListFullAlertersResponse]. */ export interface ListFullAlerters { /** Structured query to filter alerters. */ query?: AlerterQuery; } /** List builders matching structured query. Response: [ListFullBuildersResponse]. */ export interface ListFullBuilders { query?: BuilderQuery; } /** List builds matching optional query. Response: [ListFullBuildsResponse]. */ export interface ListFullBuilds { /** optional structured query to filter builds. */ query?: BuildQuery; } /** * List deployments matching optional query. * Response: [ListFullDeploymentsResponse]. */ export interface ListFullDeployments { /** optional structured query to filter deployments. */ query?: DeploymentQuery; } /** List procedures matching optional query. Response: [ListFullProceduresResponse]. */ export interface ListFullProcedures { /** optional structured query to filter procedures. */ query?: ProcedureQuery; } /** List repos matching optional query. Response: [ListFullReposResponse]. */ export interface ListFullRepos { /** optional structured query to filter repos. */ query?: RepoQuery; } /** List syncs matching optional query. Response: [ListFullResourceSyncsResponse]. */ export interface ListFullResourceSyncs { /** optional structured query to filter syncs. */ query?: ResourceSyncQuery; } /** List servers matching optional query. Response: [ListFullServersResponse]. */ export interface ListFullServers { /** optional structured query to filter servers. */ query?: ServerQuery; } /** List stacks matching optional query. Response: [ListFullStacksResponse]. */ export interface ListFullStacks { /** optional structured query to filter stacks. */ query?: StackQuery; } /** * List git provider accounts matching optional query. * Response: [ListGitProviderAccountsResponse]. */ export interface ListGitProviderAccounts { /** Optionally filter by accounts with a specific domain. */ domain?: string; /** Optionally filter by accounts with a specific username. */ username?: string; } /** * List the git providers available in Core / Periphery config files. * Response: [ListGitProvidersFromConfigResponse]. * * Includes: * - providers in core config * - providers configured on builds, repos, syncs * - providers on the optional Server or Builder */ export interface ListGitProvidersFromConfig { /** * Accepts an optional Server or Builder target to expand the core list with * providers available on that specific resource. */ target?: ResourceTarget; } /** * List permissions for the calling user. * Does not include any permissions on UserGroups they may be a part of. * Response: [ListPermissionsResponse] */ export interface ListPermissions { } /** List procedures matching optional query. Response: [ListProceduresResponse]. */ export interface ListProcedures { /** optional structured query to filter procedures. */ query?: ProcedureQuery; } /** List repos matching optional query. Response: [ListReposResponse]. */ export interface ListRepos { /** optional structured query to filter repos. */ query?: RepoQuery; } /** List syncs matching optional query. Response: [ListResourceSyncsResponse]. */ export interface ListResourceSyncs { /** optional structured query to filter syncs. */ query?: ResourceSyncQuery; } /** * List configured schedules. * Response: [ListSchedulesResponse]. */ export interface ListSchedules { /** Pass Vec of tag ids or tag names */ tags?: string[]; /** 'All' or 'Any' */ tag_behavior?: TagQueryBehavior; } /** * List the available secrets from the core config. * Response: [ListSecretsResponse]. */ export interface ListSecrets { /** * Accepts an optional Server or Builder target to expand the core list with * providers available on that specific resource. */ target?: ResourceTarget; } /** List servers matching optional query. Response: [ListServersResponse]. */ export interface ListServers { /** optional structured query to filter servers. */ query?: ServerQuery; } /** Lists a specific stacks services (the containers). Response: [ListStackServicesResponse]. */ export interface ListStackServices { /** Id or name */ stack: string; } /** List stacks matching optional query. Response: [ListStacksResponse]. */ export interface ListStacks { /** optional structured query to filter stacks. */ query?: StackQuery; } /** * List the processes running on the target server. * Response: [ListSystemProcessesResponse]. * * Note. This does not hit the server directly. The procedures come from an * in memory cache on the core, which hits the server periodically * to keep it up to date. */ export interface ListSystemProcesses { /** Id or name */ server: string; } /** * List data for tags matching optional mongo query. * Response: [ListTagsResponse]. */ export interface ListTags { query?: MongoDocument; } /** * List the current terminals on specified server. * Response: [ListTerminalsResponse]. */ export interface ListTerminals { /** Id or name */ server: string; /** * Force a fresh call to Periphery for the list. * Otherwise the response will be cached for 30s */ fresh?: boolean; } /** * Paginated endpoint for updates matching optional query. * More recent updates will be returned first. */ export interface ListUpdates { /** An optional mongo query to filter the updates. */ query?: MongoDocument; /** * Page of updates. Default is 0, which is the most recent data. * Use with the `next_page` field of the response. */ page?: number; } /** Minimal representation of an action performed by Komodo. */ export interface UpdateListItem { /** The id of the update */ id: string; /** Which operation was run */ operation: Operation; /** The starting time of the operation */ start_ts: I64; /** Whether the operation was successful */ success: boolean; /** The username of the user performing update */ username: string; /** * The user id that triggered the update. * * Also can take these values for operations triggered automatically: * - `Procedure`: The operation was triggered as part of a procedure run * - `Github`: The operation was triggered by a github webhook * - `Auto Redeploy`: The operation (always `Deploy`) was triggered by an attached build finishing. */ operator: string; /** The target resource to which this update refers */ target: ResourceTarget; /** * The status of the update * - `Queued` * - `InProgress` * - `Complete` */ status: UpdateStatus; /** An optional version on the update, ie build version or deployed version. */ version?: Version; /** Some unstructured, operation specific data. Not for general usage. */ other_data?: string; } /** Response for [ListUpdates]. */ export interface ListUpdatesResponse { /** The page of updates, sorted by timestamp descending. */ updates: UpdateListItem[]; /** If there is a next page of data, pass this to `page` to get it. */ next_page?: number; } /** * List all user groups which user can see. Response: [ListUserGroupsResponse]. * * Admins can see all user groups, * and users can see user groups to which they belong. */ export interface ListUserGroups { } /** * List permissions for a specific user. **Admin only**. * Response: [ListUserTargetPermissionsResponse] */ export interface ListUserTargetPermissions { /** Specify either a user or a user group. */ user_target: UserTarget; } /** * **Admin only.** * Gets list of Komodo users. * Response: [ListUsersResponse] */ export interface ListUsers { } /** * List all available global variables. * Response: [ListVariablesResponse] * * Note. For non admin users making this call, * secret variables will have their values obscured. */ export interface ListVariables { } /** * Login as a local user. Will fail if the users credentials don't match * any local user. * * Note. This method is only available if the core api has `local_auth` enabled. */ export interface LoginLocalUser { /** The user's username */ username: string; /** The user's password */ password: string; } export interface NameAndId { name: string; id: string; } /** Configuration for a Ntfy alerter. */ export interface NtfyAlerterEndpoint { /** The ntfy topic URL */ url: string; /** * Optional E-Mail Address to enable ntfy email notifications. * SMTP must be configured on the ntfy server. */ email?: string; } /** Pauses all containers on the target server. Response: [Update] */ export interface PauseAllContainers { /** Name or id */ server: string; } /** * Pauses the container on the target server. Response: [Update] * * 1. Runs `docker pause ${container_name}`. */ export interface PauseContainer { /** Name or id */ server: string; /** The container name */ container: string; } /** * Pauses the container for the target deployment. Response: [Update] * * 1. Runs `docker pause ${container_name}`. */ export interface PauseDeployment { /** Name or id */ deployment: string; } /** Pauses the target stack. `docker compose pause`. Response: [Update] */ export interface PauseStack { /** Id or name */ stack: string; /** * Filter to only pause specific services. * If empty, will pause all services. */ services?: string[]; } export interface PermissionToml { /** * Id can be: * - resource name. `id = "abcd-build"` * - regex matching resource names. `id = "\^(.+)-build-([0-9]+)$\"` */ target: ResourceTarget; /** * The permission level: * - None * - Read * - Execute * - Write */ level?: PermissionLevel; /** Any [SpecificPermissions](SpecificPermission) on the resource */ specific?: Array; } /** * Prunes the docker buildx cache on the target server. Response: [Update]. * * 1. Runs `docker buildx prune -a -f`. */ export interface PruneBuildx { /** Id or name */ server: string; } /** * Prunes the docker containers on the target server. Response: [Update]. * * 1. Runs `docker container prune -f`. */ export interface PruneContainers { /** Id or name */ server: string; } /** * Prunes the docker builders (build cache) on the target server. Response: [Update]. * * 1. Runs `docker builder prune -a -f`. */ export interface PruneDockerBuilders { /** Id or name */ server: string; } /** * Prunes the docker images on the target server. Response: [Update]. * * 1. Runs `docker image prune -a -f`. */ export interface PruneImages { /** Id or name */ server: string; } /** * Prunes the docker networks on the target server. Response: [Update]. * * 1. Runs `docker network prune -f`. */ export interface PruneNetworks { /** Id or name */ server: string; } /** * Prunes the docker system on the target server, including volumes. Response: [Update]. * * 1. Runs `docker system prune -a -f --volumes`. */ export interface PruneSystem { /** Id or name */ server: string; } /** * Prunes the docker volumes on the target server. Response: [Update]. * * 1. Runs `docker volume prune -a -f`. */ export interface PruneVolumes { /** Id or name */ server: string; } /** Pulls the image for the target deployment. Response: [Update] */ export interface PullDeployment { /** Name or id */ deployment: string; } /** * Pulls the target repo. Response: [Update]. * * Note. Repo must have server attached at `server_id`. * * 1. Pulls the repo on the target server using `git pull`. * 2. If `on_pull` is specified, it will be executed after the pull is complete. */ export interface PullRepo { /** Id or name */ repo: string; } /** Pulls images for the target stack. `docker compose pull`. Response: [Update] */ export interface PullStack { /** Id or name */ stack: string; /** * Filter to only pull specific services. * If empty, will pull all services. */ services?: string[]; } /** * Push a resource to the front of the users 10 most recently viewed resources. * Response: [NoData]. */ export interface PushRecentlyViewed { /** The target to push. */ resource: ResourceTarget; } /** Configuration for a Pushover alerter. */ export interface PushoverAlerterEndpoint { /** The pushover URL including application and user tokens in parameters. */ url: string; } /** Trigger a refresh of the cached latest hash and message. */ export interface RefreshBuildCache { /** Id or name */ build: string; } /** Trigger a refresh of the cached latest hash and message. */ export interface RefreshRepoCache { /** Id or name */ repo: string; } /** Trigger a refresh of the computed diff logs for view. Response: [ResourceSync] */ export interface RefreshResourceSyncPending { /** Id or name */ sync: string; } /** * Trigger a refresh of the cached compose file contents. * Refreshes: * - Whether the remote file is missing * - The latest json, and for repos, the remote contents, hash, and message. */ export interface RefreshStackCache { /** Id or name */ stack: string; } /** **Admin only.** Remove a user from a user group. Response: [UserGroup] */ export interface RemoveUserFromUserGroup { /** The name or id of UserGroup that user should be removed from. */ user_group: string; /** The id or username of the user to remove */ user: string; } /** * Rename the Action at id to the given name. * Response: [Update]. */ export interface RenameAction { /** The id or name of the Action to rename. */ id: string; /** The new name. */ name: string; } /** * Rename the Alerter at id to the given name. * Response: [Update]. */ export interface RenameAlerter { /** The id or name of the Alerter to rename. */ id: string; /** The new name. */ name: string; } /** * Rename the Build at id to the given name. * Response: [Update]. */ export interface RenameBuild { /** The id or name of the Build to rename. */ id: string; /** The new name. */ name: string; } /** * Rename the Builder at id to the given name. * Response: [Update]. */ export interface RenameBuilder { /** The id or name of the Builder to rename. */ id: string; /** The new name. */ name: string; } /** * Rename the deployment at id to the given name. Response: [Update]. * * Note. If a container is created for the deployment, it will be renamed using * `docker rename ...`. */ export interface RenameDeployment { /** The id of the deployment to rename. */ id: string; /** The new name. */ name: string; } /** * Rename the Procedure at id to the given name. * Response: [Update]. */ export interface RenameProcedure { /** The id or name of the Procedure to rename. */ id: string; /** The new name. */ name: string; } /** * Rename the Repo at id to the given name. * Response: [Update]. */ export interface RenameRepo { /** The id or name of the Repo to rename. */ id: string; /** The new name. */ name: string; } /** * Rename the ResourceSync at id to the given name. * Response: [Update]. */ export interface RenameResourceSync { /** The id or name of the ResourceSync to rename. */ id: string; /** The new name. */ name: string; } /** * Rename an Server to the given name. * Response: [Update]. */ export interface RenameServer { /** The id or name of the Server to rename. */ id: string; /** The new name. */ name: string; } /** Rename the stack at id to the given name. Response: [Update]. */ export interface RenameStack { /** The id of the stack to rename. */ id: string; /** The new name. */ name: string; } /** Rename a tag at id. Response: [Tag]. */ export interface RenameTag { /** The id of the tag to rename. */ id: string; /** The new name of the tag. */ name: string; } /** **Admin only.** Rename a user group. Response: [UserGroup] */ export interface RenameUserGroup { /** The id of the UserGroup */ id: string; /** The new name for the UserGroup */ name: string; } export enum DefaultRepoFolder { /** /${root_directory}/stacks */ Stacks = "Stacks", /** /${root_directory}/builds */ Builds = "Builds", /** /${root_directory}/repos */ Repos = "Repos", /** * If the repo is only cloned * in the core repo cache (resource sync), * this isn't relevant. */ NotApplicable = "NotApplicable", } export interface RepoExecutionArgs { /** Resource name (eg Build name, Repo name) */ name: string; /** Git provider domain. Default: `github.com` */ provider: string; /** Use https (vs http). */ https: boolean; /** Configure the account used to access repo (if private) */ account?: string; /** * Full repo identifier. {namespace}/{repo_name} * Its optional to force checking and produce error if not defined. */ repo?: string; /** Git Branch. Default: `main` */ branch: string; /** Specific commit hash. Optional */ commit?: string; /** The clone destination path */ destination?: string; /** * The default folder to use. * Depends on the resource type. */ default_folder: DefaultRepoFolder; } export interface RepoExecutionResponse { /** Response logs */ logs: Log[]; /** Absolute path to the repo root on the host. */ path: string; /** Latest short commit hash, if it could be retrieved */ commit_hash?: string; /** Latest commit message, if it could be retrieved */ commit_message?: string; } export interface ResourceToml { /** The resource name. Required */ name: string; /** The resource description. Optional. */ description?: string; /** Mark resource as a template */ template?: boolean; /** Tag ids or names. Optional */ tags?: string[]; /** * Optional. Only relevant for deployments / stacks. * * Will ensure deployment / stack is running with the latest configuration. * Deploy actions to achieve this will be included in the sync. * Default is false. */ deploy?: boolean; /** * Optional. Only relevant for deployments / stacks using the 'deploy' sync feature. * * Specify other deployments / stacks by name as dependencies. * The sync will ensure the deployment / stack will only be deployed 'after' its dependencies. */ after?: string[]; /** Resource specific configuration. */ config?: PartialConfig; } export interface UserGroupToml { /** User group name */ name: string; /** Whether all users will implicitly have the permissions in this group. */ everyone?: boolean; /** Users in the group */ users?: string[]; /** Give the user group elevated permissions on all resources of a certain type */ all?: Record; /** Permissions given to the group */ permissions?: PermissionToml[]; } /** Specifies resources to sync on Komodo */ export interface ResourcesToml { servers?: ResourceToml<_PartialServerConfig>[]; deployments?: ResourceToml<_PartialDeploymentConfig>[]; stacks?: ResourceToml<_PartialStackConfig>[]; builds?: ResourceToml<_PartialBuildConfig>[]; repos?: ResourceToml<_PartialRepoConfig>[]; procedures?: ResourceToml<_PartialProcedureConfig>[]; actions?: ResourceToml<_PartialActionConfig>[]; alerters?: ResourceToml<_PartialAlerterConfig>[]; builders?: ResourceToml<_PartialBuilderConfig>[]; resource_syncs?: ResourceToml<_PartialResourceSyncConfig>[]; user_groups?: UserGroupToml[]; variables?: Variable[]; } /** Restarts all containers on the target server. Response: [Update] */ export interface RestartAllContainers { /** Name or id */ server: string; } /** * Restarts the container on the target server. Response: [Update] * * 1. Runs `docker restart ${container_name}`. */ export interface RestartContainer { /** Name or id */ server: string; /** The container name */ container: string; } /** * Restarts the container for the target deployment. Response: [Update] * * 1. Runs `docker restart ${container_name}`. */ export interface RestartDeployment { /** Name or id */ deployment: string; } /** Restarts the target stack. `docker compose restart`. Response: [Update] */ export interface RestartStack { /** Id or name */ stack: string; /** * Filter to only restart specific services. * If empty, will restart all services. */ services?: string[]; } /** Runs the target Action. Response: [Update] */ export interface RunAction { /** Id or name */ action: string; /** * Custom arguments which are merged on top of the default arguments. * CLI Format: `"VAR1=val1&VAR2=val2"` * * Webhook-triggered actions use this to pass WEBHOOK_BRANCH and WEBHOOK_BODY. */ args?: JsonObject; } /** * Runs the target build. Response: [Update]. * * 1. Get a handle to the builder. If using AWS builder, this means starting a builder ec2 instance. * * 2. Clone the repo on the builder. If an `on_clone` commmand is given, it will be executed. * * 3. Execute `docker build {...params}`, where params are determined using the builds configuration. * * 4. If a docker registry is configured, the build will be pushed to the registry. * * 5. If using AWS builder, destroy the builder ec2 instance. * * 6. Deploy any Deployments with *Redeploy on Build* enabled. */ export interface RunBuild { /** Can be build id or name */ build: string; } /** Runs the target Procedure. Response: [Update] */ export interface RunProcedure { /** Id or name */ procedure: string; } /** Runs a one-time command against a service using `docker compose run`. Response: [Update] */ export interface RunStackService { /** Id or name */ stack: string; /** Service to run */ service: string; /** Command and args to pass to the service container */ command?: string[]; /** Do not allocate TTY */ no_tty?: boolean; /** Do not start linked services */ no_deps?: boolean; /** Detach container on run */ detach?: boolean; /** Map service ports to the host */ service_ports?: boolean; /** Extra environment variables for the run */ env?: Record; /** Working directory inside the container */ workdir?: string; /** User to run as inside the container */ user?: string; /** Override the default entrypoint */ entrypoint?: string; /** Pull the image before running */ pull?: boolean; } /** Runs the target resource sync. Response: [Update] */ export interface RunSync { /** Id or name */ sync: string; /** * Only execute sync on a specific resource type. * Combine with `resource_id` to specify resource. */ resource_type?: ResourceTarget["type"]; /** * Only execute sync on a specific resources. * Combine with `resource_type` to specify resources. * Supports name or id. */ resources?: string[]; } export enum SearchCombinator { Or = "Or", And = "And", } /** * Search the container log's tail using `grep`. All lines go to stdout. * Response: [Log]. * * Note. This call will hit the underlying server directly for most up to date log. */ export interface SearchContainerLog { /** Id or name */ server: string; /** The container name */ container: string; /** The terms to search for. */ terms: string[]; /** * When searching for multiple terms, can use `AND` or `OR` combinator. * * - `AND`: Only include lines with **all** terms present in that line. * - `OR`: Include lines that have one or more matches in the terms. */ combinator?: SearchCombinator; /** Invert the results, ie return all lines that DON'T match the terms / combinator. */ invert?: boolean; /** Enable `--timestamps` */ timestamps?: boolean; } /** * Search the deployment log's tail using `grep`. All lines go to stdout. * Response: [Log]. * * Note. This call will hit the underlying server directly for most up to date log. */ export interface SearchDeploymentLog { /** Id or name */ deployment: string; /** The terms to search for. */ terms: string[]; /** * When searching for multiple terms, can use `AND` or `OR` combinator. * * - `AND`: Only include lines with **all** terms present in that line. * - `OR`: Include lines that have one or more matches in the terms. */ combinator?: SearchCombinator; /** Invert the results, ie return all lines that DON'T match the terms / combinator. */ invert?: boolean; /** Enable `--timestamps` */ timestamps?: boolean; } /** * Search the stack log's tail using `grep`. All lines go to stdout. * Response: [SearchStackLogResponse]. * * Note. This call will hit the underlying server directly for most up to date log. */ export interface SearchStackLog { /** Id or name */ stack: string; /** * Filter the logs to only ones from specific services. * If empty, will include logs from all services. */ services: string[]; /** The terms to search for. */ terms: string[]; /** * When searching for multiple terms, can use `AND` or `OR` combinator. * * - `AND`: Only include lines with **all** terms present in that line. * - `OR`: Include lines that have one or more matches in the terms. */ combinator?: SearchCombinator; /** Invert the results, ie return all lines that DON'T match the terms / combinator. */ invert?: boolean; /** Enable `--timestamps` */ timestamps?: boolean; } /** Send a custom alert message to configured Alerters. Response: [Update] */ export interface SendAlert { /** The alert level. */ level?: SeverityLevel; /** The alert message. Required. */ message: string; /** The alert details. Optional. */ details?: string; /** * Specific alerter names or ids. * If empty / not passed, sends to all configured alerters * with the `Custom` alert type whitelisted / not blacklisted. */ alerters?: string[]; } /** Configuration for a Komodo Server Builder. */ export interface ServerBuilderConfig { /** The server id of the builder */ server_id?: string; } /** The health of a part of the server. */ export interface ServerHealthState { level: SeverityLevel; /** Whether the health is good enough to close an open alert. */ should_close_alert: boolean; } /** Summary of the health of the server. */ export interface ServerHealth { cpu: ServerHealthState; mem: ServerHealthState; disks: Record; } /** * **Admin only.** Set `everyone` property of User Group. * Response: [UserGroup] */ export interface SetEveryoneUserGroup { /** Id or name. */ user_group: string; /** Whether this user group applies to everyone. */ everyone: boolean; } /** * Set the time the user last opened the UI updates. * Used for unseen notification dot. * Response: [NoData] */ export interface SetLastSeenUpdate { } /** * **Admin only.** Completely override the users in the group. * Response: [UserGroup] */ export interface SetUsersInUserGroup { /** Id or name. */ user_group: string; /** The user ids or usernames to hard set as the group's users. */ users: string[]; } /** * Sign up a new local user account. Will fail if a user with the * given username already exists. * Response: [SignUpLocalUserResponse]. * * Note. This method is only available if the core api has `local_auth` enabled, * and if user registration is not disabled (after the first user). */ export interface SignUpLocalUser { /** The username for the new user. */ username: string; /** * The password for the new user. * This cannot be retreived later. */ password: string; } /** Info for network interface usage. */ export interface SingleNetworkInterfaceUsage { /** The network interface name */ name: string; /** The ingress in bytes */ ingress_bytes: number; /** The egress in bytes */ egress_bytes: number; } /** Configuration for a Slack alerter. */ export interface SlackAlerterEndpoint { /** The Slack app webhook url */ url: string; } /** Sleeps for the specified time. */ export interface Sleep { duration_ms?: I64; } /** Starts all containers on the target server. Response: [Update] */ export interface StartAllContainers { /** Name or id */ server: string; } /** * Starts the container on the target server. Response: [Update] * * 1. Runs `docker start ${container_name}`. */ export interface StartContainer { /** Name or id */ server: string; /** The container name */ container: string; } /** * Starts the container for the target deployment. Response: [Update] * * 1. Runs `docker start ${container_name}`. */ export interface StartDeployment { /** Name or id */ deployment: string; } /** Starts the target stack. `docker compose start`. Response: [Update] */ export interface StartStack { /** Id or name */ stack: string; /** * Filter to only start specific services. * If empty, will start all services. */ services?: string[]; } /** Stops all containers on the target server. Response: [Update] */ export interface StopAllContainers { /** Name or id */ server: string; } /** * Stops the container on the target server. Response: [Update] * * 1. Runs `docker stop ${container_name}`. */ export interface StopContainer { /** Name or id */ server: string; /** The container name */ container: string; /** Override the default termination signal. */ signal?: TerminationSignal; /** Override the default termination max time. */ time?: number; } /** * Stops the container for the target deployment. Response: [Update] * * 1. Runs `docker stop ${container_name}`. */ export interface StopDeployment { /** Name or id */ deployment: string; /** Override the default termination signal specified in the deployment. */ signal?: TerminationSignal; /** Override the default termination max time. */ time?: number; } /** Stops the target stack. `docker compose stop`. Response: [Update] */ export interface StopStack { /** Id or name */ stack: string; /** Override the default termination max time. */ stop_time?: number; /** * Filter to only stop specific services. * If empty, will stop all services. */ services?: string[]; } export interface TerminationSignalLabel { signal: TerminationSignal; label: string; } /** Tests an Alerters ability to reach the configured endpoint. Response: [Update] */ export interface TestAlerter { /** Name or id */ alerter: string; } /** Info for the all system disks combined. */ export interface TotalDiskUsage { /** Used portion in GB */ used_gb: number; /** Total size in GB */ total_gb: number; } /** Unpauses all containers on the target server. Response: [Update] */ export interface UnpauseAllContainers { /** Name or id */ server: string; } /** * Unpauses the container on the target server. Response: [Update] * * 1. Runs `docker unpause ${container_name}`. * * Note. This is the only way to restart a paused container. */ export interface UnpauseContainer { /** Name or id */ server: string; /** The container name */ container: string; } /** * Unpauses the container for the target deployment. Response: [Update] * * 1. Runs `docker unpause ${container_name}`. * * Note. This is the only way to restart a paused container. */ export interface UnpauseDeployment { /** Name or id */ deployment: string; } /** * Unpauses the target stack. `docker compose unpause`. Response: [Update]. * * Note. This is the only way to restart a paused container. */ export interface UnpauseStack { /** Id or name */ stack: string; /** * Filter to only unpause specific services. * If empty, will unpause all services. */ services?: string[]; } /** * Update the action at the given id, and return the updated action. * Response: [Action]. * * Note. This method updates only the fields which are set in the [_PartialActionConfig], * effectively merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateAction { /** The id of the action to update. */ id: string; /** The partial config update to apply. */ config: _PartialActionConfig; } /** * Update the alerter at the given id, and return the updated alerter. Response: [Alerter]. * * Note. This method updates only the fields which are set in the [PartialAlerterConfig][crate::entities::alerter::PartialAlerterConfig], * effectively merging diffs into the final document. This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateAlerter { /** The id of the alerter to update. */ id: string; /** The partial config update to apply. */ config: _PartialAlerterConfig; } /** * Update the build at the given id, and return the updated build. * Response: [Build]. * * Note. This method updates only the fields which are set in the [_PartialBuildConfig], * effectively merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateBuild { /** The id or name of the build to update. */ id: string; /** The partial config update to apply. */ config: _PartialBuildConfig; } /** * Update the builder at the given id, and return the updated builder. * Response: [Builder]. * * Note. This method updates only the fields which are set in the [PartialBuilderConfig], * effectively merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateBuilder { /** The id of the builder to update. */ id: string; /** The partial config update to apply. */ config: PartialBuilderConfig; } /** * Update the deployment at the given id, and return the updated deployment. * Response: [Deployment]. * * Note. If the attached server for the deployment changes, * the deployment will be deleted / cleaned up on the old server. * * Note. This method updates only the fields which are set in the [_PartialDeploymentConfig], * effectively merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateDeployment { /** The deployment id to update. */ id: string; /** The partial config update. */ config: _PartialDeploymentConfig; } /** * **Admin only.** Update a docker registry account. * Response: [DockerRegistryAccount]. */ export interface UpdateDockerRegistryAccount { /** The id of the docker registry to update */ id: string; /** The partial docker registry account. */ account: _PartialDockerRegistryAccount; } /** * **Admin only.** Update a git provider account. * Response: [GitProviderAccount]. */ export interface UpdateGitProviderAccount { /** The id of the git provider account to update. */ id: string; /** The partial git provider account. */ account: _PartialGitProviderAccount; } /** * **Admin only.** Update a user or user groups base permission level on a resource type. * Response: [NoData]. */ export interface UpdatePermissionOnResourceType { /** Specify the user or user group. */ user_target: UserTarget; /** The resource type: eg. Server, Build, Deployment, etc. */ resource_type: ResourceTarget["type"]; /** The base permission level. */ permission: PermissionLevelAndSpecifics | PermissionLevel; } /** * **Admin only.** Update a user or user groups permission on a resource. * Response: [NoData]. */ export interface UpdatePermissionOnTarget { /** Specify the user or user group. */ user_target: UserTarget; /** Specify the target resource. */ resource_target: ResourceTarget; /** Specify the permission level. */ permission: PermissionLevelAndSpecifics | PermissionLevel; } /** * Update the procedure at the given id, and return the updated procedure. * Response: [Procedure]. * * Note. This method updates only the fields which are set in the [_PartialProcedureConfig], * effectively merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateProcedure { /** The id of the procedure to update. */ id: string; /** The partial config update. */ config: _PartialProcedureConfig; } /** * Update the repo at the given id, and return the updated repo. * Response: [Repo]. * * Note. If the attached server for the repo changes, * the repo will be deleted / cleaned up on the old server. * * Note. This method updates only the fields which are set in the [_PartialRepoConfig], * effectively merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateRepo { /** The id of the repo to update. */ id: string; /** The partial config update to apply. */ config: _PartialRepoConfig; } /** * Update a resources common meta fields. * - description * - template * - tags * Response: [NoData]. */ export interface UpdateResourceMeta { /** The target resource to set update meta. */ target: ResourceTarget; /** * New description to set, * or null for no update */ description?: string; /** * New template value (true or false), * or null for no update */ template?: boolean; /** * The exact tags to set, * or null for no update */ tags?: string[]; } /** * Update the sync at the given id, and return the updated sync. * Response: [ResourceSync]. * * Note. This method updates only the fields which are set in the [_PartialResourceSyncConfig], * effectively merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateResourceSync { /** The id of the sync to update. */ id: string; /** The partial config update to apply. */ config: _PartialResourceSyncConfig; } /** * Update the server at the given id, and return the updated server. * Response: [Server]. * * Note. This method updates only the fields which are set in the [_PartialServerConfig], * effectively merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateServer { /** The id or name of the server to update. */ id: string; /** The partial config update to apply. */ config: _PartialServerConfig; } /** * **Admin only.** Update a service user's description. * Response: [User]. */ export interface UpdateServiceUserDescription { /** The service user's username */ username: string; /** A new description for the service user. */ description: string; } /** * Update the stack at the given id, and return the updated stack. * Response: [Stack]. * * Note. If the attached server for the stack changes, * the stack will be deleted / cleaned up on the old server. * * Note. This method updates only the fields which are set in the [_PartialStackConfig], * merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateStack { /** The id of the Stack to update. */ id: string; /** The partial config update to apply. */ config: _PartialStackConfig; } /** Update color for tag. Response: [Tag]. */ export interface UpdateTagColor { /** The name or id of the tag to update. */ tag: string; /** The new color for the tag. */ color: TagColor; } /** * **Super Admin only.** Update's whether a user is admin. * Response: [NoData]. */ export interface UpdateUserAdmin { /** The target user. */ user_id: string; /** Whether user should be admin. */ admin: boolean; } /** * **Admin only.** Update a user's "base" permissions, eg. "enabled". * Response: [NoData]. */ export interface UpdateUserBasePermissions { /** The target user. */ user_id: string; /** If specified, will update users enabled state. */ enabled?: boolean; /** If specified, will update user's ability to create servers. */ create_servers?: boolean; /** If specified, will update user's ability to create builds. */ create_builds?: boolean; } /** * **Only for local users**. Update the calling users password. * Response: [NoData]. */ export interface UpdateUserPassword { password: string; } /** * **Only for local users**. Update the calling users username. * Response: [NoData]. */ export interface UpdateUserUsername { username: string; } /** **Admin only.** Update variable description. Response: [Variable]. */ export interface UpdateVariableDescription { /** The name of the variable to update. */ name: string; /** The description to set. */ description: string; } /** **Admin only.** Update whether variable is secret. Response: [Variable]. */ export interface UpdateVariableIsSecret { /** The name of the variable to update. */ name: string; /** Whether variable is secret. */ is_secret: boolean; } /** **Admin only.** Update variable value. Response: [Variable]. */ export interface UpdateVariableValue { /** The name of the variable to update. */ name: string; /** The value to set. */ value: string; } /** Configuration for a Komodo Url Builder. */ export interface UrlBuilderConfig { /** The address of the Periphery agent */ address: string; /** A custom passkey to use. Otherwise, use the default passkey. */ passkey?: string; } /** Update dockerfile contents in Files on Server or Git Repo mode. Response: [Update]. */ export interface WriteBuildFileContents { /** The name or id of the target Build. */ build: string; /** The dockerfile contents to write. */ contents: string; } /** Update file contents in Files on Server or Git Repo mode. Response: [Update]. */ export interface WriteStackFileContents { /** The name or id of the target Stack. */ stack: string; /** * The file path relative to the stack run directory, * or absolute path. */ file_path: string; /** The contents to write. */ contents: string; } /** Rename the stack at id to the given name. Response: [Update]. */ export interface WriteSyncFileContents { /** The name or id of the target Sync. */ sync: string; /** * If this file was under a resource folder, this will be the folder. * Otherwise, it should be empty string. */ resource_path: string; /** The file path relative to the resource path. */ file_path: string; /** The contents to write. */ contents: string; } export type AuthRequest = | { type: "GetLoginOptions", params: GetLoginOptions } | { type: "SignUpLocalUser", params: SignUpLocalUser } | { type: "LoginLocalUser", params: LoginLocalUser } | { type: "ExchangeForJwt", params: ExchangeForJwt } | { type: "GetUser", params: GetUser }; /** Days of the week */ export enum DayOfWeek { Monday = "Monday", Tuesday = "Tuesday", Wednesday = "Wednesday", Thursday = "Thursday", Friday = "Friday", Saturday = "Saturday", Sunday = "Sunday", } export type ExecuteRequest = | { type: "StartContainer", params: StartContainer } | { type: "RestartContainer", params: RestartContainer } | { type: "PauseContainer", params: PauseContainer } | { type: "UnpauseContainer", params: UnpauseContainer } | { type: "StopContainer", params: StopContainer } | { type: "DestroyContainer", params: DestroyContainer } | { type: "StartAllContainers", params: StartAllContainers } | { type: "RestartAllContainers", params: RestartAllContainers } | { type: "PauseAllContainers", params: PauseAllContainers } | { type: "UnpauseAllContainers", params: UnpauseAllContainers } | { type: "StopAllContainers", params: StopAllContainers } | { type: "PruneContainers", params: PruneContainers } | { type: "DeleteNetwork", params: DeleteNetwork } | { type: "PruneNetworks", params: PruneNetworks } | { type: "DeleteImage", params: DeleteImage } | { type: "PruneImages", params: PruneImages } | { type: "DeleteVolume", params: DeleteVolume } | { type: "PruneVolumes", params: PruneVolumes } | { type: "PruneDockerBuilders", params: PruneDockerBuilders } | { type: "PruneBuildx", params: PruneBuildx } | { type: "PruneSystem", params: PruneSystem } | { type: "DeployStack", params: DeployStack } | { type: "BatchDeployStack", params: BatchDeployStack } | { type: "DeployStackIfChanged", params: DeployStackIfChanged } | { type: "BatchDeployStackIfChanged", params: BatchDeployStackIfChanged } | { type: "PullStack", params: PullStack } | { type: "BatchPullStack", params: BatchPullStack } | { type: "StartStack", params: StartStack } | { type: "RestartStack", params: RestartStack } | { type: "StopStack", params: StopStack } | { type: "PauseStack", params: PauseStack } | { type: "UnpauseStack", params: UnpauseStack } | { type: "DestroyStack", params: DestroyStack } | { type: "BatchDestroyStack", params: BatchDestroyStack } | { type: "RunStackService", params: RunStackService } | { type: "Deploy", params: Deploy } | { type: "BatchDeploy", params: BatchDeploy } | { type: "PullDeployment", params: PullDeployment } | { type: "StartDeployment", params: StartDeployment } | { type: "RestartDeployment", params: RestartDeployment } | { type: "PauseDeployment", params: PauseDeployment } | { type: "UnpauseDeployment", params: UnpauseDeployment } | { type: "StopDeployment", params: StopDeployment } | { type: "DestroyDeployment", params: DestroyDeployment } | { type: "BatchDestroyDeployment", params: BatchDestroyDeployment } | { type: "RunBuild", params: RunBuild } | { type: "BatchRunBuild", params: BatchRunBuild } | { type: "CancelBuild", params: CancelBuild } | { type: "CloneRepo", params: CloneRepo } | { type: "BatchCloneRepo", params: BatchCloneRepo } | { type: "PullRepo", params: PullRepo } | { type: "BatchPullRepo", params: BatchPullRepo } | { type: "BuildRepo", params: BuildRepo } | { type: "BatchBuildRepo", params: BatchBuildRepo } | { type: "CancelRepoBuild", params: CancelRepoBuild } | { type: "RunProcedure", params: RunProcedure } | { type: "BatchRunProcedure", params: BatchRunProcedure } | { type: "RunAction", params: RunAction } | { type: "BatchRunAction", params: BatchRunAction } | { type: "TestAlerter", params: TestAlerter } | { type: "SendAlert", params: SendAlert } | { type: "RunSync", params: RunSync } | { type: "ClearRepoCache", params: ClearRepoCache } | { type: "BackupCoreDatabase", params: BackupCoreDatabase } | { type: "GlobalAutoUpdate", params: GlobalAutoUpdate }; /** * One representative IANA zone for each distinct base UTC offset in the tz database. * https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. * * The `serde`/`strum` renames ensure the canonical identifier is used * when serializing or parsing from a string such as `"Etc/UTC"`. */ export enum IanaTimezone { /** UTC−12:00 */ EtcGmtMinus12 = "Etc/GMT+12", /** UTC−11:00 */ PacificPagoPago = "Pacific/Pago_Pago", /** UTC−10:00 */ PacificHonolulu = "Pacific/Honolulu", /** UTC−09:30 */ PacificMarquesas = "Pacific/Marquesas", /** UTC−09:00 */ AmericaAnchorage = "America/Anchorage", /** UTC−08:00 */ AmericaLosAngeles = "America/Los_Angeles", /** UTC−07:00 */ AmericaDenver = "America/Denver", /** UTC−06:00 */ AmericaChicago = "America/Chicago", /** UTC−05:00 */ AmericaNewYork = "America/New_York", /** UTC−04:00 */ AmericaHalifax = "America/Halifax", /** UTC−03:30 */ AmericaStJohns = "America/St_Johns", /** UTC−03:00 */ AmericaSaoPaulo = "America/Sao_Paulo", /** UTC−02:00 */ AmericaNoronha = "America/Noronha", /** UTC−01:00 */ AtlanticAzores = "Atlantic/Azores", /** UTC±00:00 */ EtcUtc = "Etc/UTC", /** UTC+01:00 */ EuropeBerlin = "Europe/Berlin", /** UTC+02:00 */ EuropeBucharest = "Europe/Bucharest", /** UTC+03:00 */ EuropeMoscow = "Europe/Moscow", /** UTC+03:30 */ AsiaTehran = "Asia/Tehran", /** UTC+04:00 */ AsiaDubai = "Asia/Dubai", /** UTC+04:30 */ AsiaKabul = "Asia/Kabul", /** UTC+05:00 */ AsiaKarachi = "Asia/Karachi", /** UTC+05:30 */ AsiaKolkata = "Asia/Kolkata", /** UTC+05:45 */ AsiaKathmandu = "Asia/Kathmandu", /** UTC+06:00 */ AsiaDhaka = "Asia/Dhaka", /** UTC+06:30 */ AsiaYangon = "Asia/Yangon", /** UTC+07:00 */ AsiaBangkok = "Asia/Bangkok", /** UTC+08:00 */ AsiaShanghai = "Asia/Shanghai", /** UTC+08:45 */ AustraliaEucla = "Australia/Eucla", /** UTC+09:00 */ AsiaTokyo = "Asia/Tokyo", /** UTC+09:30 */ AustraliaAdelaide = "Australia/Adelaide", /** UTC+10:00 */ AustraliaSydney = "Australia/Sydney", /** UTC+10:30 */ AustraliaLordHowe = "Australia/Lord_Howe", /** UTC+11:00 */ PacificPortMoresby = "Pacific/Port_Moresby", /** UTC+12:00 */ PacificAuckland = "Pacific/Auckland", /** UTC+12:45 */ PacificChatham = "Pacific/Chatham", /** UTC+13:00 */ PacificTongatapu = "Pacific/Tongatapu", /** UTC+14:00 */ PacificKiritimati = "Pacific/Kiritimati", } export type ReadRequest = | { type: "GetVersion", params: GetVersion } | { type: "GetCoreInfo", params: GetCoreInfo } | { type: "ListSecrets", params: ListSecrets } | { type: "ListGitProvidersFromConfig", params: ListGitProvidersFromConfig } | { type: "ListDockerRegistriesFromConfig", params: ListDockerRegistriesFromConfig } | { type: "GetUsername", params: GetUsername } | { type: "GetPermission", params: GetPermission } | { type: "FindUser", params: FindUser } | { type: "ListUsers", params: ListUsers } | { type: "ListApiKeys", params: ListApiKeys } | { type: "ListApiKeysForServiceUser", params: ListApiKeysForServiceUser } | { type: "ListPermissions", params: ListPermissions } | { type: "ListUserTargetPermissions", params: ListUserTargetPermissions } | { type: "GetUserGroup", params: GetUserGroup } | { type: "ListUserGroups", params: ListUserGroups } | { type: "GetProceduresSummary", params: GetProceduresSummary } | { type: "GetProcedure", params: GetProcedure } | { type: "GetProcedureActionState", params: GetProcedureActionState } | { type: "ListProcedures", params: ListProcedures } | { type: "ListFullProcedures", params: ListFullProcedures } | { type: "GetActionsSummary", params: GetActionsSummary } | { type: "GetAction", params: GetAction } | { type: "GetActionActionState", params: GetActionActionState } | { type: "ListActions", params: ListActions } | { type: "ListFullActions", params: ListFullActions } | { type: "ListSchedules", params: ListSchedules } | { type: "GetServersSummary", params: GetServersSummary } | { type: "GetServer", params: GetServer } | { type: "GetServerState", params: GetServerState } | { type: "GetPeripheryVersion", params: GetPeripheryVersion } | { type: "GetServerActionState", params: GetServerActionState } | { type: "GetHistoricalServerStats", params: GetHistoricalServerStats } | { type: "ListServers", params: ListServers } | { type: "ListFullServers", params: ListFullServers } | { type: "InspectDockerContainer", params: InspectDockerContainer } | { type: "GetResourceMatchingContainer", params: GetResourceMatchingContainer } | { type: "GetContainerLog", params: GetContainerLog } | { type: "SearchContainerLog", params: SearchContainerLog } | { type: "InspectDockerNetwork", params: InspectDockerNetwork } | { type: "InspectDockerImage", params: InspectDockerImage } | { type: "ListDockerImageHistory", params: ListDockerImageHistory } | { type: "InspectDockerVolume", params: InspectDockerVolume } | { type: "GetDockerContainersSummary", params: GetDockerContainersSummary } | { type: "ListAllDockerContainers", params: ListAllDockerContainers } | { type: "ListDockerContainers", params: ListDockerContainers } | { type: "ListDockerNetworks", params: ListDockerNetworks } | { type: "ListDockerImages", params: ListDockerImages } | { type: "ListDockerVolumes", params: ListDockerVolumes } | { type: "ListComposeProjects", params: ListComposeProjects } | { type: "ListTerminals", params: ListTerminals } | { type: "GetSystemInformation", params: GetSystemInformation } | { type: "GetSystemStats", params: GetSystemStats } | { type: "ListSystemProcesses", params: ListSystemProcesses } | { type: "GetStacksSummary", params: GetStacksSummary } | { type: "GetStack", params: GetStack } | { type: "GetStackActionState", params: GetStackActionState } | { type: "GetStackWebhooksEnabled", params: GetStackWebhooksEnabled } | { type: "GetStackLog", params: GetStackLog } | { type: "SearchStackLog", params: SearchStackLog } | { type: "InspectStackContainer", params: InspectStackContainer } | { type: "ListStacks", params: ListStacks } | { type: "ListFullStacks", params: ListFullStacks } | { type: "ListStackServices", params: ListStackServices } | { type: "ListCommonStackExtraArgs", params: ListCommonStackExtraArgs } | { type: "ListCommonStackBuildExtraArgs", params: ListCommonStackBuildExtraArgs } | { type: "GetDeploymentsSummary", params: GetDeploymentsSummary } | { type: "GetDeployment", params: GetDeployment } | { type: "GetDeploymentContainer", params: GetDeploymentContainer } | { type: "GetDeploymentActionState", params: GetDeploymentActionState } | { type: "GetDeploymentStats", params: GetDeploymentStats } | { type: "GetDeploymentLog", params: GetDeploymentLog } | { type: "SearchDeploymentLog", params: SearchDeploymentLog } | { type: "InspectDeploymentContainer", params: InspectDeploymentContainer } | { type: "ListDeployments", params: ListDeployments } | { type: "ListFullDeployments", params: ListFullDeployments } | { type: "ListCommonDeploymentExtraArgs", params: ListCommonDeploymentExtraArgs } | { type: "GetBuildsSummary", params: GetBuildsSummary } | { type: "GetBuild", params: GetBuild } | { type: "GetBuildActionState", params: GetBuildActionState } | { type: "GetBuildMonthlyStats", params: GetBuildMonthlyStats } | { type: "ListBuildVersions", params: ListBuildVersions } | { type: "GetBuildWebhookEnabled", params: GetBuildWebhookEnabled } | { type: "ListBuilds", params: ListBuilds } | { type: "ListFullBuilds", params: ListFullBuilds } | { type: "ListCommonBuildExtraArgs", params: ListCommonBuildExtraArgs } | { type: "GetReposSummary", params: GetReposSummary } | { type: "GetRepo", params: GetRepo } | { type: "GetRepoActionState", params: GetRepoActionState } | { type: "GetRepoWebhooksEnabled", params: GetRepoWebhooksEnabled } | { type: "ListRepos", params: ListRepos } | { type: "ListFullRepos", params: ListFullRepos } | { type: "GetResourceSyncsSummary", params: GetResourceSyncsSummary } | { type: "GetResourceSync", params: GetResourceSync } | { type: "GetResourceSyncActionState", params: GetResourceSyncActionState } | { type: "GetSyncWebhooksEnabled", params: GetSyncWebhooksEnabled } | { type: "ListResourceSyncs", params: ListResourceSyncs } | { type: "ListFullResourceSyncs", params: ListFullResourceSyncs } | { type: "GetBuildersSummary", params: GetBuildersSummary } | { type: "GetBuilder", params: GetBuilder } | { type: "ListBuilders", params: ListBuilders } | { type: "ListFullBuilders", params: ListFullBuilders } | { type: "GetAlertersSummary", params: GetAlertersSummary } | { type: "GetAlerter", params: GetAlerter } | { type: "ListAlerters", params: ListAlerters } | { type: "ListFullAlerters", params: ListFullAlerters } | { type: "ExportAllResourcesToToml", params: ExportAllResourcesToToml } | { type: "ExportResourcesToToml", params: ExportResourcesToToml } | { type: "GetTag", params: GetTag } | { type: "ListTags", params: ListTags } | { type: "GetUpdate", params: GetUpdate } | { type: "ListUpdates", params: ListUpdates } | { type: "ListAlerts", params: ListAlerts } | { type: "GetAlert", params: GetAlert } | { type: "GetVariable", params: GetVariable } | { type: "ListVariables", params: ListVariables } | { type: "GetGitProviderAccount", params: GetGitProviderAccount } | { type: "ListGitProviderAccounts", params: ListGitProviderAccounts } | { type: "GetDockerRegistryAccount", params: GetDockerRegistryAccount } | { type: "ListDockerRegistryAccounts", params: ListDockerRegistryAccounts }; /** The specific types of permission that a User or UserGroup can have on a resource. */ export enum SpecificPermission { /** * On **Server** * - Access the terminal apis * On **Stack / Deployment** * - Access the container exec Apis */ Terminal = "Terminal", /** * On **Server** * - Allowed to attach Stacks, Deployments, Repos, Builders to the Server * On **Builder** * - Allowed to attach Builds to the Builder * On **Build** * - Allowed to attach Deployments to the Build */ Attach = "Attach", /** * On **Server** * - Access the `container inspect` apis * On **Stack / Deployment** * - Access `container inspect` apis for associated containers */ Inspect = "Inspect", /** * On **Server** * - Read all container logs on the server * On **Stack / Deployment** * - Read the container logs */ Logs = "Logs", /** * On **Server** * - Read all the processes on the host */ Processes = "Processes", } export type UserRequest = | { type: "PushRecentlyViewed", params: PushRecentlyViewed } | { type: "SetLastSeenUpdate", params: SetLastSeenUpdate } | { type: "CreateApiKey", params: CreateApiKey } | { type: "DeleteApiKey", params: DeleteApiKey }; export type WriteRequest = | { type: "CreateLocalUser", params: CreateLocalUser } | { type: "UpdateUserUsername", params: UpdateUserUsername } | { type: "UpdateUserPassword", params: UpdateUserPassword } | { type: "DeleteUser", params: DeleteUser } | { type: "CreateServiceUser", params: CreateServiceUser } | { type: "UpdateServiceUserDescription", params: UpdateServiceUserDescription } | { type: "CreateApiKeyForServiceUser", params: CreateApiKeyForServiceUser } | { type: "DeleteApiKeyForServiceUser", params: DeleteApiKeyForServiceUser } | { type: "CreateUserGroup", params: CreateUserGroup } | { type: "RenameUserGroup", params: RenameUserGroup } | { type: "DeleteUserGroup", params: DeleteUserGroup } | { type: "AddUserToUserGroup", params: AddUserToUserGroup } | { type: "RemoveUserFromUserGroup", params: RemoveUserFromUserGroup } | { type: "SetUsersInUserGroup", params: SetUsersInUserGroup } | { type: "SetEveryoneUserGroup", params: SetEveryoneUserGroup } | { type: "UpdateUserAdmin", params: UpdateUserAdmin } | { type: "UpdateUserBasePermissions", params: UpdateUserBasePermissions } | { type: "UpdatePermissionOnResourceType", params: UpdatePermissionOnResourceType } | { type: "UpdatePermissionOnTarget", params: UpdatePermissionOnTarget } | { type: "UpdateResourceMeta", params: UpdateResourceMeta } | { type: "CreateServer", params: CreateServer } | { type: "CopyServer", params: CopyServer } | { type: "DeleteServer", params: DeleteServer } | { type: "UpdateServer", params: UpdateServer } | { type: "RenameServer", params: RenameServer } | { type: "CreateNetwork", params: CreateNetwork } | { type: "CreateTerminal", params: CreateTerminal } | { type: "DeleteTerminal", params: DeleteTerminal } | { type: "DeleteAllTerminals", params: DeleteAllTerminals } | { type: "CreateStack", params: CreateStack } | { type: "CopyStack", params: CopyStack } | { type: "DeleteStack", params: DeleteStack } | { type: "UpdateStack", params: UpdateStack } | { type: "RenameStack", params: RenameStack } | { type: "WriteStackFileContents", params: WriteStackFileContents } | { type: "RefreshStackCache", params: RefreshStackCache } | { type: "CreateStackWebhook", params: CreateStackWebhook } | { type: "DeleteStackWebhook", params: DeleteStackWebhook } | { type: "CreateDeployment", params: CreateDeployment } | { type: "CopyDeployment", params: CopyDeployment } | { type: "CreateDeploymentFromContainer", params: CreateDeploymentFromContainer } | { type: "DeleteDeployment", params: DeleteDeployment } | { type: "UpdateDeployment", params: UpdateDeployment } | { type: "RenameDeployment", params: RenameDeployment } | { type: "CreateBuild", params: CreateBuild } | { type: "CopyBuild", params: CopyBuild } | { type: "DeleteBuild", params: DeleteBuild } | { type: "UpdateBuild", params: UpdateBuild } | { type: "RenameBuild", params: RenameBuild } | { type: "WriteBuildFileContents", params: WriteBuildFileContents } | { type: "RefreshBuildCache", params: RefreshBuildCache } | { type: "CreateBuildWebhook", params: CreateBuildWebhook } | { type: "DeleteBuildWebhook", params: DeleteBuildWebhook } | { type: "CreateBuilder", params: CreateBuilder } | { type: "CopyBuilder", params: CopyBuilder } | { type: "DeleteBuilder", params: DeleteBuilder } | { type: "UpdateBuilder", params: UpdateBuilder } | { type: "RenameBuilder", params: RenameBuilder } | { type: "CreateRepo", params: CreateRepo } | { type: "CopyRepo", params: CopyRepo } | { type: "DeleteRepo", params: DeleteRepo } | { type: "UpdateRepo", params: UpdateRepo } | { type: "RenameRepo", params: RenameRepo } | { type: "RefreshRepoCache", params: RefreshRepoCache } | { type: "CreateRepoWebhook", params: CreateRepoWebhook } | { type: "DeleteRepoWebhook", params: DeleteRepoWebhook } | { type: "CreateAlerter", params: CreateAlerter } | { type: "CopyAlerter", params: CopyAlerter } | { type: "DeleteAlerter", params: DeleteAlerter } | { type: "UpdateAlerter", params: UpdateAlerter } | { type: "RenameAlerter", params: RenameAlerter } | { type: "CreateProcedure", params: CreateProcedure } | { type: "CopyProcedure", params: CopyProcedure } | { type: "DeleteProcedure", params: DeleteProcedure } | { type: "UpdateProcedure", params: UpdateProcedure } | { type: "RenameProcedure", params: RenameProcedure } | { type: "CreateAction", params: CreateAction } | { type: "CopyAction", params: CopyAction } | { type: "DeleteAction", params: DeleteAction } | { type: "UpdateAction", params: UpdateAction } | { type: "RenameAction", params: RenameAction } | { type: "CreateResourceSync", params: CreateResourceSync } | { type: "CopyResourceSync", params: CopyResourceSync } | { type: "DeleteResourceSync", params: DeleteResourceSync } | { type: "UpdateResourceSync", params: UpdateResourceSync } | { type: "RenameResourceSync", params: RenameResourceSync } | { type: "WriteSyncFileContents", params: WriteSyncFileContents } | { type: "CommitSync", params: CommitSync } | { type: "RefreshResourceSyncPending", params: RefreshResourceSyncPending } | { type: "CreateSyncWebhook", params: CreateSyncWebhook } | { type: "DeleteSyncWebhook", params: DeleteSyncWebhook } | { type: "CreateTag", params: CreateTag } | { type: "DeleteTag", params: DeleteTag } | { type: "RenameTag", params: RenameTag } | { type: "UpdateTagColor", params: UpdateTagColor } | { type: "CreateVariable", params: CreateVariable } | { type: "UpdateVariableValue", params: UpdateVariableValue } | { type: "UpdateVariableDescription", params: UpdateVariableDescription } | { type: "UpdateVariableIsSecret", params: UpdateVariableIsSecret } | { type: "DeleteVariable", params: DeleteVariable } | { type: "CreateGitProviderAccount", params: CreateGitProviderAccount } | { type: "UpdateGitProviderAccount", params: UpdateGitProviderAccount } | { type: "DeleteGitProviderAccount", params: DeleteGitProviderAccount } | { type: "CreateDockerRegistryAccount", params: CreateDockerRegistryAccount } | { type: "UpdateDockerRegistryAccount", params: UpdateDockerRegistryAccount } | { type: "DeleteDockerRegistryAccount", params: DeleteDockerRegistryAccount }; export type WsLoginMessage = | { type: "Jwt", params: { jwt: string; }} | { type: "ApiKeys", params: { key: string; secret: string; }}; ================================================ FILE: client/core/ts/tsconfig.json ================================================ { "compilerOptions": { "strict": true, "target": "ESNext", "module": "ESNext", "moduleResolution": "node", "allowSyntheticDefaultImports": true, "esModuleInterop": true, "isolatedModules": true, "outDir": "dist", "declaration": true } } ================================================ FILE: client/periphery/rs/Cargo.toml ================================================ [package] name = "periphery_client" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true homepage.workspace = true repository.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] # local komodo_client.workspace = true # mogh resolver_api.workspace = true serror.workspace = true # external tokio-tungstenite.workspace = true serde_json.workspace = true serde_qs.workspace = true reqwest.workspace = true tracing.workspace = true anyhow.workspace = true rustls.workspace = true tokio.workspace = true serde.workspace = true ================================================ FILE: client/periphery/rs/src/api/build.rs ================================================ use komodo_client::entities::{ FileContents, repo::Repo, update::Log, }; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(BuildResponse)] #[error(serror::Error)] pub struct Build { pub build: komodo_client::entities::build::Build, /// Send the linked repo if it exists. pub repo: Option, /// Override registry tokens with ones sent from core. /// maps (domain, account) -> token. #[serde(default)] pub registry_tokens: Vec<(String, String, String)>, /// Propogate any secret replacers from core interpolation. #[serde(default)] pub replacers: Vec<(String, String)>, /// Pass the commit hash to use with tagging pub commit_hash: Option, /// Add more tags for this build in addition to the version tags. #[serde(default)] pub additional_tags: Vec, } pub type BuildResponse = Vec; // /// Get the dockerfile contents on the host, for builds using /// `files_on_host`. #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] #[response(GetDockerfileContentsOnHostResponse)] #[error(serror::Error)] pub struct GetDockerfileContentsOnHost { /// The name of the build pub name: String, /// The build path for the build. pub build_path: String, /// The dockerfile path for the build, relative to the build_path pub dockerfile_path: String, } pub type GetDockerfileContentsOnHostResponse = FileContents; // /// Write the dockerfile contents to the file on the host, for build using /// `files_on_host`. #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct WriteDockerfileContentsToHost { /// The name of the build pub name: String, /// The build path for the build. pub build_path: String, /// The dockerfile path for the build, relative to the build_path pub dockerfile_path: String, /// The contents to write. pub contents: String, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct PruneBuilders {} // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct PruneBuildx {} ================================================ FILE: client/periphery/rs/src/api/compose.rs ================================================ use komodo_client::entities::{ FileContents, RepoExecutionResponse, SearchCombinator, repo::Repo, stack::{ ComposeProject, Stack, StackFileDependency, StackRemoteFileContents, StackServiceNames, }, update::Log, }; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// List the compose project names that are on the host. /// List running `docker compose ls` /// /// Incoming from docker like: /// [{"Name":"project_name","Status":"running(1)","ConfigFiles":"/root/compose/compose.yaml,/root/compose/compose2.yaml"}] #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] #[response(Vec)] #[error(serror::Error)] pub struct ListComposeProjects {} // /// Get the compose contents on the host, for stacks using /// `files_on_host`. #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] #[response(GetComposeContentsOnHostResponse)] #[error(serror::Error)] pub struct GetComposeContentsOnHost { /// The name of the stack pub name: String, pub run_directory: String, /// Both compose files and env / additional files, all relative to run directory. pub file_paths: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct GetComposeContentsOnHostResponse { pub contents: Vec, pub errors: Vec, } // /// The stack folder must already exist for this to work #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct GetComposeLog { /// The name of the project pub project: String, /// Filter the logs to only ones from specific services. /// If empty, will include logs from all services. #[serde(default)] pub services: Vec, /// Pass `--tail` for only recent log contents. Max of 5000 #[serde(default = "default_tail")] pub tail: u64, /// Enable `--timestamps` #[serde(default)] pub timestamps: bool, } fn default_tail() -> u64 { 50 } // /// The stack folder must already exist for this to work #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct GetComposeLogSearch { /// The name of the project pub project: String, /// Filter the logs to only ones from specific services. /// If empty, will include logs from all services. #[serde(default)] pub services: Vec, /// The search terms. pub terms: Vec, /// And: Only lines matching all terms /// Or: Lines matching any one of the terms #[serde(default)] pub combinator: SearchCombinator, /// Invert the search (search for everything not matching terms) #[serde(default)] pub invert: bool, /// Enable `--timestamps` #[serde(default)] pub timestamps: bool, } // /// Write the compose / additional file contents to the file on the host, for stacks using /// `files_on_host`. #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct WriteComposeContentsToHost { /// The name of the stack pub name: String, /// The run directory of the stack pub run_directory: String, /// Relative to the stack folder + run directory, /// or absolute path. pub file_path: String, /// The contents to write. pub contents: String, } // /// Write and commit compose contents. /// Only works with git repo based stacks. #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] #[response(RepoExecutionResponse)] #[error(serror::Error)] pub struct WriteCommitComposeContents { /// The stack to write to. pub stack: Stack, /// Optional linked repo. pub repo: Option, /// The username of user which committed the file. pub username: Option, /// Relative to the stack folder + run directory. pub file_path: String, /// The contents to write. pub contents: String, /// If provided, use it to login in. Otherwise check periphery local git providers. pub git_token: Option, } // /// Rewrites the compose directory, pulls any images, takes down existing containers, /// and runs docker compose up. Response: [ComposePullResponse] #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] #[response(ComposePullResponse)] #[error(serror::Error)] pub struct ComposePull { /// The stack to deploy pub stack: Stack, /// Filter to only pull specific services. /// If empty, will pull all services. #[serde(default)] pub services: Vec, /// The linked repo, if it exists. pub repo: Option, /// If provided, use it to login in. Otherwise check periphery local git providers. pub git_token: Option, /// If provided, use it to login in. Otherwise check periphery local registry providers. pub registry_token: Option, /// Propogate any secret replacers from core interpolation. #[serde(default)] pub replacers: Vec<(String, String)>, } /// Response for [ComposePull] #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ComposePullResponse { /// If any of the required files are missing, they will be here. pub missing_files: Vec, /// The error in getting remote file contents at the path, or null pub remote_errors: Vec, /// The logs produced by the pull pub logs: Vec, } // /// docker compose up. #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] #[response(ComposeUpResponse)] #[error(serror::Error)] pub struct ComposeUp { /// The stack to deploy pub stack: Stack, /// Filter to only deploy specific services. /// If empty, will deploy all services. #[serde(default)] pub services: Vec, /// The linked repo, if it exists. pub repo: Option, /// If provided, use it to login in. Otherwise check periphery local registries. pub git_token: Option, /// If provided, use it to login in. Otherwise check periphery local git providers. pub registry_token: Option, /// Propogate any secret replacers from core interpolation. #[serde(default)] pub replacers: Vec<(String, String)>, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ComposeUpResponse { /// If any of the required files are missing, they will be here. pub missing_files: Vec, /// The logs produced by the deploy pub logs: Vec, /// Whether stack was successfully deployed pub deployed: bool, /// The stack services. /// /// Note. The "image" is after interpolation. #[serde(default)] pub services: Vec, /// The deploy compose file contents if they could be acquired, or empty vec. pub file_contents: Vec, /// The error in getting remote file contents at the path, or null pub remote_errors: Vec, /// The output of `docker compose config` at deploy time pub compose_config: Option, /// If its a repo based stack, will include the latest commit hash pub commit_hash: Option, /// If its a repo based stack, will include the latest commit message pub commit_message: Option, } // #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ComposeRunResponse { /// Logs produced during stack write/prepare for the run pub logs: Vec, } // /// General compose command runner #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct ComposeExecution { /// The compose project name to run the execution on. /// Usually its he name of the stack / folder under the `stack_dir`. pub project: String, /// The command in `docker compose -p {project} {command}` pub command: String, } // /// docker compose run one-time service execution. #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct ComposeRun { /// The stack to run a service for pub stack: Stack, /// The linked repo, if it exists. pub repo: Option, /// If provided, use it to login in. Otherwise check periphery local registries. pub git_token: Option, /// If provided, use it to login in. Otherwise check periphery local git providers. pub registry_token: Option, /// Propogate any secret replacers from core interpolation. #[serde(default)] pub replacers: Vec<(String, String)>, /// Service to run pub service: String, /// Command #[serde(default)] pub command: Option>, /// Do not allocate TTY #[serde(default)] pub no_tty: Option, /// Do not start linked services #[serde(default)] pub no_deps: Option, /// Detach container on run #[serde(default)] pub detach: Option, /// Map service ports to the host #[serde(default)] pub service_ports: Option, /// Extra environment variables for the run #[serde(default)] pub env: Option>, /// Working directory inside the container #[serde(default)] pub workdir: Option, /// User to run as inside the container #[serde(default)] pub user: Option, /// Override the default entrypoint #[serde(default)] pub entrypoint: Option, /// Pull the image before running #[serde(default)] pub pull: Option, } ================================================ FILE: client/periphery/rs/src/api/container.rs ================================================ use komodo_client::entities::{ SearchCombinator, TerminationSignal, deployment::Deployment, docker::{ container::{Container, ContainerStats}, stats::FullContainerStats, }, update::Log, }; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Container)] #[error(serror::Error)] pub struct InspectContainer { pub name: String, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct GetContainerLog { pub name: String, #[serde(default = "default_tail")] pub tail: u64, /// Enable `--timestamps` #[serde(default)] pub timestamps: bool, } fn default_tail() -> u64 { 50 } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct GetContainerLogSearch { pub name: String, pub terms: Vec, #[serde(default)] pub combinator: SearchCombinator, #[serde(default)] pub invert: bool, /// Enable `--timestamps` #[serde(default)] pub timestamps: bool, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(ContainerStats)] #[error(serror::Error)] pub struct GetContainerStats { pub name: String, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Vec)] #[error(serror::Error)] pub struct GetContainerStatsList {} // // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(FullContainerStats)] #[error(serror::Error)] pub struct GetFullContainerStats { pub name: String, } // // ======= // ACTIONS // ======= #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct Deploy { pub deployment: Deployment, pub stop_signal: Option, pub stop_time: Option, /// Override registry token with one sent from core. pub registry_token: Option, /// Propogate any secret replacers from core interpolation. #[serde(default)] pub replacers: Vec<(String, String)>, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct StartContainer { pub name: String, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct RestartContainer { pub name: String, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct PauseContainer { pub name: String, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct UnpauseContainer { pub name: String, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct StopContainer { pub name: String, pub signal: Option, pub time: Option, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct RemoveContainer { pub name: String, pub signal: Option, pub time: Option, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct RenameContainer { pub curr_name: String, pub new_name: String, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct PruneContainers {} // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Vec)] #[error(serror::Error)] pub struct StartAllContainers {} // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Vec)] #[error(serror::Error)] pub struct RestartAllContainers {} // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Vec)] #[error(serror::Error)] pub struct PauseAllContainers {} // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Vec)] #[error(serror::Error)] pub struct UnpauseAllContainers {} // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Vec)] #[error(serror::Error)] pub struct StopAllContainers {} ================================================ FILE: client/periphery/rs/src/api/git.rs ================================================ use std::path::PathBuf; use komodo_client::entities::{ EnvironmentVar, LatestCommit, RepoExecutionArgs, RepoExecutionResponse, SystemCommand, update::Log, }; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; /// Returns `null` if not a repo #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] #[response(Option)] #[error(serror::Error)] pub struct GetLatestCommit { pub name: String, pub path: Option, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(PeripheryRepoExecutionResponse)] #[error(serror::Error)] pub struct CloneRepo { pub args: RepoExecutionArgs, /// Override git token with one sent from core. pub git_token: Option, #[serde(default)] pub environment: Vec, /// Relative to repo root #[serde(default = "default_env_file_path")] pub env_file_path: String, pub on_clone: Option, pub on_pull: Option, #[serde(default)] pub skip_secret_interp: bool, /// Propogate any secret replacers from core interpolation. #[serde(default)] pub replacers: Vec<(String, String)>, } fn default_env_file_path() -> String { String::from(".env") } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(PeripheryRepoExecutionResponse)] #[error(serror::Error)] pub struct PullRepo { pub args: RepoExecutionArgs, /// Override git token with one sent from core. pub git_token: Option, #[serde(default)] pub environment: Vec, #[serde(default = "default_env_file_path")] pub env_file_path: String, pub on_pull: Option, #[serde(default)] pub skip_secret_interp: bool, /// Propogate any secret replacers from core interpolation. #[serde(default)] pub replacers: Vec<(String, String)>, } // /// Either pull or clone depending on whether it exists. #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(PeripheryRepoExecutionResponse)] #[error(serror::Error)] pub struct PullOrCloneRepo { pub args: RepoExecutionArgs, /// Override git token with one sent from core. pub git_token: Option, #[serde(default)] pub environment: Vec, #[serde(default = "default_env_file_path")] pub env_file_path: String, pub on_clone: Option, pub on_pull: Option, #[serde(default)] pub skip_secret_interp: bool, /// Propogate any secret replacers from core interpolation. #[serde(default)] pub replacers: Vec<(String, String)>, } // #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PeripheryRepoExecutionResponse { pub res: RepoExecutionResponse, pub env_file_path: Option, } // // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct RenameRepo { pub curr_name: String, pub new_name: String, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct DeleteRepo { pub name: String, /// Clears pub is_build: bool, } ================================================ FILE: client/periphery/rs/src/api/image.rs ================================================ use komodo_client::entities::{ docker::image::{Image, ImageHistoryResponseItem}, update::Log, }; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; // #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] #[response(Image)] #[error(serror::Error)] pub struct InspectImage { pub name: String, } // #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] #[response(Vec)] #[error(serror::Error)] pub struct ImageHistory { pub name: String, } // #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct PullImage { /// The name of the image. pub name: String, /// Optional account to use to pull the image pub account: Option, /// Override registry token for account with one sent from core. pub token: Option, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct DeleteImage { /// Id or name pub name: String, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct PruneImages {} ================================================ FILE: client/periphery/rs/src/api/mod.rs ================================================ use komodo_client::entities::{ SystemCommand, config::{DockerRegistry, GitProvider}, docker::{ container::ContainerListItem, image::ImageListItem, network::NetworkListItem, volume::VolumeListItem, }, stack::ComposeProject, update::Log, }; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; use serror::Serror; pub mod build; pub mod compose; pub mod container; pub mod git; pub mod image; pub mod network; pub mod stats; pub mod terminal; pub mod volume; // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(GetHealthResponse)] #[error(serror::Error)] pub struct GetHealth {} #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetHealthResponse {} // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(GetVersionResponse)] #[error(serror::Error)] pub struct GetVersion {} #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetVersionResponse { pub version: String, } /// Returns all containers, networks, images, compose projects #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(GetDockerListsResponse)] #[error(serror::Error)] pub struct GetDockerLists {} #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GetDockerListsResponse { pub containers: Result, Serror>, pub networks: Result, Serror>, pub images: Result, Serror>, pub volumes: Result, Serror>, pub projects: Result, Serror>, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(ListGitProvidersResponse)] #[error(serror::Error)] pub struct ListGitProviders {} pub type ListGitProvidersResponse = Vec; // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(ListDockerRegistriesResponse)] #[error(serror::Error)] pub struct ListDockerRegistries {} pub type ListDockerRegistriesResponse = Vec; // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Vec)] #[error(serror::Error)] pub struct ListSecrets {} // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct PruneSystem {} // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct RunCommand { pub command: SystemCommand, } ================================================ FILE: client/periphery/rs/src/api/network.rs ================================================ use komodo_client::entities::{ docker::network::Network, update::Log, }; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Network)] #[error(serror::Error)] pub struct InspectNetwork { pub name: String, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct CreateNetwork { pub name: String, pub driver: Option, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct DeleteNetwork { /// Id or name pub name: String, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct PruneNetworks {} ================================================ FILE: client/periphery/rs/src/api/stats.rs ================================================ use komodo_client::entities::stats::{ SystemInformation, SystemProcess, SystemStats, }; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(SystemInformation)] #[error(serror::Error)] pub struct GetSystemInformation {} // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(SystemStats)] #[error(serror::Error)] pub struct GetSystemStats {} // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Vec)] #[error(serror::Error)] pub struct GetSystemProcesses {} // ================================================ FILE: client/periphery/rs/src/api/terminal.rs ================================================ use komodo_client::{ api::write::TerminalRecreateMode, entities::{NoData, server::TerminalInfo}, }; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Vec)] #[error(serror::Error)] pub struct ListTerminals {} #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(NoData)] #[error(serror::Error)] pub struct CreateTerminal { /// The name of the terminal to create pub name: String, /// The shell command (eg `bash`) to init the shell. /// /// This can also include args: /// `docker exec -it container sh` #[serde(default = "default_command")] pub command: String, /// Default: `Never` #[serde(default)] pub recreate: TerminalRecreateMode, } fn default_command() -> String { String::from("bash") } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(NoData)] #[error(serror::Error)] pub struct DeleteTerminal { /// The name of the terminal to delete pub terminal: String, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(NoData)] #[error(serror::Error)] pub struct DeleteAllTerminals {} // /// Create a single use auth token to connect to periphery terminal websocket. #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(CreateTerminalAuthTokenResponse)] #[error(serror::Error)] pub struct CreateTerminalAuthToken {} #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CreateTerminalAuthTokenResponse { pub token: String, } // #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ConnectTerminalQuery { /// Use [CreateTerminalAuthToken] to create a single-use /// token to send in the query. pub token: String, /// Each periphery can keep multiple terminals open. /// If a terminal with the specified name already exists, /// it will be attached to. Otherwise, it will fail. pub terminal: String, } // /// Note: The `terminal` must already exist, created by [CreateTerminal]. #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ExecuteTerminalBody { /// Specify the terminal to execute the command on. pub terminal: String, /// The command to execute. pub command: String, } // #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ConnectContainerExecQuery { /// Use [CreateTerminalAuthToken] to create a single-use /// token to send in the query. pub token: String, /// The name of the container to connect to. pub container: String, /// The shell to start inside container. /// Default: `sh` #[serde(default = "default_container_shell")] pub shell: String, } // #[derive(Serialize, Deserialize, Debug, Clone)] pub struct ExecuteContainerExecBody { /// The name of the container to execute command in. pub container: String, /// The shell to start inside container. /// Default: `sh` #[serde(default = "default_container_shell")] pub shell: String, /// The command to execute. pub command: String, } fn default_container_shell() -> String { String::from("sh") } ================================================ FILE: client/periphery/rs/src/api/volume.rs ================================================ use komodo_client::entities::{docker::volume::Volume, update::Log}; use resolver_api::Resolve; use serde::{Deserialize, Serialize}; // #[derive(Debug, Clone, Serialize, Deserialize, Resolve)] #[response(Volume)] #[error(serror::Error)] pub struct InspectVolume { pub name: String, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct DeleteVolume { /// Id or name pub name: String, } // #[derive(Serialize, Deserialize, Debug, Clone, Resolve)] #[response(Log)] #[error(serror::Error)] pub struct PruneVolumes {} ================================================ FILE: client/periphery/rs/src/lib.rs ================================================ use std::{sync::OnceLock, time::Duration}; use anyhow::Context; use reqwest::StatusCode; use resolver_api::HasResponse; use serde::{Serialize, de::DeserializeOwned}; use serde_json::json; pub mod api; mod terminal; fn periphery_http_client() -> &'static reqwest::Client { static PERIPHERY_HTTP_CLIENT: OnceLock = OnceLock::new(); PERIPHERY_HTTP_CLIENT.get_or_init(|| { reqwest::Client::builder() // Use to allow communication with Periphery self-signed certs. .danger_accept_invalid_certs(true) .build() .expect("Failed to build Periphery http client") }) } pub struct PeripheryClient { address: String, passkey: String, timeout: Duration, } impl PeripheryClient { pub fn new( address: impl Into, passkey: impl Into, timeout: impl Into, ) -> PeripheryClient { PeripheryClient { address: address.into(), passkey: passkey.into(), timeout: timeout.into(), } } // tracing will skip self, to avoid including passkey in traces #[tracing::instrument( name = "PeripheryRequest", level = "debug", skip(self) )] pub async fn request( &self, request: T, ) -> anyhow::Result where T: std::fmt::Debug + Serialize + HasResponse, T::Response: DeserializeOwned, { tracing::debug!("running health check"); self.health_check().await?; tracing::debug!("health check passed. running inner request"); self.request_inner(request, None).await } #[tracing::instrument(level = "debug", skip(self))] pub async fn health_check(&self) -> anyhow::Result<()> { self .request_inner(api::GetHealth {}, Some(self.timeout)) .await?; Ok(()) } #[tracing::instrument(level = "debug", skip(self))] async fn request_inner( &self, request: T, timeout: Option, ) -> anyhow::Result where T: std::fmt::Debug + Serialize + HasResponse, T::Response: DeserializeOwned, { let req_type = T::req_type(); tracing::trace!( "sending request | type: {req_type} | body: {request:?}" ); let mut req = periphery_http_client() .post(&self.address) .json(&json!({ "type": req_type, "params": request })) .header("authorization", &self.passkey); if let Some(timeout) = timeout { req = req.timeout(timeout); } let res = req.send().await.context("failed at request to periphery")?; let status = res.status(); tracing::debug!( "got response | type: {req_type} | {status} | response: {res:?}", ); if status == StatusCode::OK { tracing::debug!("response ok, deserializing"); res.json().await.with_context(|| format!( "failed to parse response to json | type: {req_type} | request: {request:?}" )) } else { tracing::debug!("response is non-200"); let text = res .text() .await .context("failed to convert response to text")?; tracing::debug!("got response text, deserializing error"); let error = serror::deserialize_error(text).context(status); Err(error) } } } ================================================ FILE: client/periphery/rs/src/terminal.rs ================================================ use std::sync::Arc; use anyhow::Context; use komodo_client::terminal::TerminalStreamResponse; use reqwest::RequestBuilder; use rustls::{ClientConfig, client::danger::ServerCertVerifier}; use tokio::net::TcpStream; use tokio_tungstenite::{Connector, MaybeTlsStream, WebSocketStream}; use crate::{PeripheryClient, api::terminal::*}; impl PeripheryClient { /// Handles ws connect and login. /// Does not handle reconnect. pub async fn connect_terminal( &self, terminal: String, ) -> anyhow::Result>> { tracing::trace!( "request | type: ConnectTerminal | terminal name: {terminal}", ); let token = self .request(CreateTerminalAuthToken {}) .await .context("Failed to create terminal auth token")?; let query_str = serde_qs::to_string(&ConnectTerminalQuery { token: token.token, terminal, }) .context("Failed to serialize query string")?; let url = format!( "{}/terminal?{query_str}", self.address.replacen("http", "ws", 1) ); connect_websocket(&url).await } /// Executes command on specified terminal, /// and streams the response ending in [KOMODO_EXIT_CODE][komodo_client::entities::KOMODO_EXIT_CODE] /// sentinal value as the expected final line of the stream. /// /// Example final line: /// ```text /// __KOMODO_EXIT_CODE:0 /// ``` /// /// This means the command exited with code 0 (success). /// /// If this value is NOT the final item before stream closes, it means /// the terminal exited mid command, before giving status. Example: running `exit`. #[tracing::instrument(level = "debug", skip(self))] pub async fn execute_terminal( &self, terminal: String, command: String, ) -> anyhow::Result { tracing::trace!( "sending request | type: ExecuteTerminal | terminal name: {terminal} | command: {command}", ); let req = crate::periphery_http_client() .post(format!("{}/terminal/execute", self.address)) .json(&ExecuteTerminalBody { terminal, command }) .header("authorization", &self.passkey); terminal_stream_response(req).await } /// Handles ws connect and login. /// Does not handle reconnect. pub async fn connect_container_exec( &self, container: String, shell: String, ) -> anyhow::Result>> { tracing::trace!( "request | type: ConnectContainerExec | container name: {container} | shell: {shell}", ); let token = self .request(CreateTerminalAuthToken {}) .await .context("Failed to create terminal auth token")?; let query_str = serde_qs::to_string(&ConnectContainerExecQuery { token: token.token, container, shell, }) .context("Failed to serialize query string")?; let url = format!( "{}/terminal/container?{query_str}", self.address.replacen("http", "ws", 1) ); connect_websocket(&url).await } /// Executes command on specified container, /// and streams the response ending in [KOMODO_EXIT_CODE][komodo_client::entities::KOMODO_EXIT_CODE] /// sentinal value as the expected final line of the stream. /// /// Example final line: /// ```text /// __KOMODO_EXIT_CODE:0 /// ``` /// /// This means the command exited with code 0 (success). /// /// If this value is NOT the final item before stream closes, it means /// the container shell exited mid command, before giving status. Example: running `exit`. #[tracing::instrument(level = "debug", skip(self))] pub async fn execute_container_exec( &self, container: String, shell: String, command: String, ) -> anyhow::Result { tracing::trace!( "sending request | type: ExecuteContainerExec | container: {container} | shell: {shell} | command: {command}", ); let req = crate::periphery_http_client() .post(format!("{}/terminal/execute/container", self.address)) .json(&ExecuteContainerExecBody { container, shell, command, }) .header("authorization", &self.passkey); terminal_stream_response(req).await } } async fn connect_websocket( url: &str, ) -> anyhow::Result>> { let (stream, _) = if url.starts_with("wss") { tokio_tungstenite::connect_async_tls_with_config( url, None, false, Some(Connector::Rustls(Arc::new( ClientConfig::builder() .dangerous() .with_custom_certificate_verifier(Arc::new( InsecureVerifier, )) .with_no_client_auth(), ))), ) .await .with_context(|| { format!("failed to connect to websocket | url: {url}") })? } else { tokio_tungstenite::connect_async(url).await.with_context( || format!("failed to connect to websocket | url: {url}"), )? }; Ok(stream) } async fn terminal_stream_response( req: RequestBuilder, ) -> anyhow::Result { let res = req.send().await.context("Failed at request to periphery")?; let status = res.status(); tracing::debug!( "got response | type: ExecuteTerminal | {status} | response: {res:?}", ); if status.is_success() { Ok(TerminalStreamResponse(res)) } else { tracing::debug!("response is non-200"); let text = res .text() .await .context("Failed to convert response to text")?; tracing::debug!("got response text, deserializing error"); let error = serror::deserialize_error(text).context(status); Err(error) } } #[derive(Debug)] struct InsecureVerifier; impl ServerCertVerifier for InsecureVerifier { fn verify_server_cert( &self, _end_entity: &rustls::pki_types::CertificateDer<'_>, _intermediates: &[rustls::pki_types::CertificateDer<'_>], _server_name: &rustls::pki_types::ServerName<'_>, _ocsp_response: &[u8], _now: rustls::pki_types::UnixTime, ) -> Result { Ok(rustls::client::danger::ServerCertVerified::assertion()) } fn verify_tls12_signature( &self, _message: &[u8], _cert: &rustls::pki_types::CertificateDer<'_>, _dss: &rustls::DigitallySignedStruct, ) -> Result< rustls::client::danger::HandshakeSignatureValid, rustls::Error, > { Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) } fn verify_tls13_signature( &self, _message: &[u8], _cert: &rustls::pki_types::CertificateDer<'_>, _dss: &rustls::DigitallySignedStruct, ) -> Result< rustls::client::danger::HandshakeSignatureValid, rustls::Error, > { Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) } fn supported_verify_schemes(&self) -> Vec { vec![ rustls::SignatureScheme::RSA_PKCS1_SHA1, rustls::SignatureScheme::ECDSA_SHA1_Legacy, rustls::SignatureScheme::RSA_PKCS1_SHA256, rustls::SignatureScheme::ECDSA_NISTP256_SHA256, rustls::SignatureScheme::RSA_PKCS1_SHA384, rustls::SignatureScheme::ECDSA_NISTP384_SHA384, rustls::SignatureScheme::RSA_PKCS1_SHA512, rustls::SignatureScheme::ECDSA_NISTP521_SHA512, rustls::SignatureScheme::RSA_PSS_SHA256, rustls::SignatureScheme::RSA_PSS_SHA384, rustls::SignatureScheme::RSA_PSS_SHA512, rustls::SignatureScheme::ED25519, rustls::SignatureScheme::ED448, ] } } ================================================ FILE: compose/compose.env ================================================ #################################### # 🦎 KOMODO COMPOSE - VARIABLES 🦎 # #################################### ## These compose variables can be used with all Komodo deployment options. ## Pass these variables to the compose up command using `--env-file komodo/compose.env`. ## Additionally, they are passed to both Komodo Core and Komodo Periphery with `env_file: ./compose.env`, ## so you can pass any additional environment variables to Core / Periphery directly in this file as well. ## Stick to a specific version, or use `latest` COMPOSE_KOMODO_IMAGE_TAG=latest ## Store dated database backups on the host - https://komo.do/docs/setup/backup COMPOSE_KOMODO_BACKUPS_PATH=/etc/komodo/backups ## DB credentials KOMODO_DB_USERNAME=admin KOMODO_DB_PASSWORD=admin ## Configure a secure passkey to authenticate between Core / Periphery. KOMODO_PASSKEY=a_random_passkey ## Set your time zone for schedules ## https://en.wikipedia.org/wiki/List_of_tz_database_time_zones TZ=Etc/UTC #=-------------------------=# #= Komodo Core Environment =# #=-------------------------=# ## Full variable list + descriptions are available here: ## 🦎 https://github.com/moghtech/komodo/blob/main/config/core.config.toml 🦎 ## Note. Secret variables also support `${VARIABLE}_FILE` syntax to pass docker compose secrets. ## Docs: https://docs.docker.com/compose/how-tos/use-secrets/#examples ## Used for Oauth / Webhook url suggestion / Caddy reverse proxy. KOMODO_HOST=https://demo.komo.do ## Displayed in the browser tab. KOMODO_TITLE=Komodo ## Create a server matching this address as the "first server". ## Use `https://host.docker.internal:8120` when using systemd-managed Periphery. KOMODO_FIRST_SERVER=https://periphery:8120 ## Give the first server a custom name. KOMODO_FIRST_SERVER_NAME=Local ## Make all buttons just double-click, rather than the full confirmation dialog. KOMODO_DISABLE_CONFIRM_DIALOG=false ## Rate Komodo polls your servers for ## status / container status / system stats / alerting. ## Options: 1-sec, 5-sec, 15-sec, 1-min, 5-min, 15-min ## Default: 15-sec KOMODO_MONITORING_INTERVAL="15-sec" ## Interval at which to poll Resources for any updates / automated actions. ## Options: 15-min, 1-hr, 2-hr, 6-hr, 12-hr, 1-day ## Default: 1-hr KOMODO_RESOURCE_POLL_INTERVAL="1-hr" ## Used to auth incoming webhooks. Alt: KOMODO_WEBHOOK_SECRET_FILE KOMODO_WEBHOOK_SECRET=a_random_secret ## Used to generate jwt. Alt: KOMODO_JWT_SECRET_FILE KOMODO_JWT_SECRET=a_random_jwt_secret ## Time to live for jwt tokens. ## Options: 1-hr, 12-hr, 1-day, 3-day, 1-wk, 2-wk KOMODO_JWT_TTL="1-day" ## Enable login with username + password. KOMODO_LOCAL_AUTH=true ## Set the initial admin username created upon first launch. ## Comment out to disable initial user creation, ## and create first user using signup button. KOMODO_INIT_ADMIN_USERNAME=admin ## Set the initial admin password KOMODO_INIT_ADMIN_PASSWORD=changeme ## Disable new user signups. KOMODO_DISABLE_USER_REGISTRATION=false ## All new logins are auto enabled KOMODO_ENABLE_NEW_USERS=false ## Disable non-admins from creating new resources. KOMODO_DISABLE_NON_ADMIN_CREATE=false ## Allows all users to have Read level access to all resources. KOMODO_TRANSPARENT_MODE=false ## Prettier logging with empty lines between logs KOMODO_LOGGING_PRETTY=false ## More human readable logging of startup config (multi-line) KOMODO_PRETTY_STARTUP_CONFIG=false ## OIDC Login KOMODO_OIDC_ENABLED=false ## Must reachable from Komodo Core container # KOMODO_OIDC_PROVIDER=https://oidc.provider.internal/application/o/komodo ## Change the host to one reachable be reachable by users (optional if it is the same as above). ## DO NOT include the `path` part of the URL. # KOMODO_OIDC_REDIRECT_HOST=https://oidc.provider.external ## Your OIDC client id # KOMODO_OIDC_CLIENT_ID= # Alt: KOMODO_OIDC_CLIENT_ID_FILE ## Your OIDC client secret. ## If your provider supports PKCE flow, this can be ommitted. # KOMODO_OIDC_CLIENT_SECRET= # Alt: KOMODO_OIDC_CLIENT_SECRET_FILE ## Make usernames the full email. ## Note. This does not work for all OIDC providers. # KOMODO_OIDC_USE_FULL_EMAIL=true ## Add additional trusted audiences for token claims verification. ## Supports comma separated list, and passing with _FILE (for compose secrets). # KOMODO_OIDC_ADDITIONAL_AUDIENCES=abc,123 # Alt: KOMODO_OIDC_ADDITIONAL_AUDIENCES_FILE ## Github Oauth KOMODO_GITHUB_OAUTH_ENABLED=false # KOMODO_GITHUB_OAUTH_ID= # Alt: KOMODO_GITHUB_OAUTH_ID_FILE # KOMODO_GITHUB_OAUTH_SECRET= # Alt: KOMODO_GITHUB_OAUTH_SECRET_FILE ## Google Oauth KOMODO_GOOGLE_OAUTH_ENABLED=false # KOMODO_GOOGLE_OAUTH_ID= # Alt: KOMODO_GOOGLE_OAUTH_ID_FILE # KOMODO_GOOGLE_OAUTH_SECRET= # Alt: KOMODO_GOOGLE_OAUTH_SECRET_FILE ## Aws - Used to launch Builder instances. KOMODO_AWS_ACCESS_KEY_ID= # Alt: KOMODO_AWS_ACCESS_KEY_ID_FILE KOMODO_AWS_SECRET_ACCESS_KEY= # Alt: KOMODO_AWS_SECRET_ACCESS_KEY_FILE #=------------------------------=# #= Komodo Periphery Environment =# #=------------------------------=# ## Full variable list + descriptions are available here: ## 🦎 https://github.com/moghtech/komodo/blob/main/config/periphery.config.toml 🦎 ## Specify the root directory used by Periphery agent. PERIPHERY_ROOT_DIRECTORY=/etc/komodo ## Periphery passkeys must include KOMODO_PASSKEY to authenticate. PERIPHERY_PASSKEYS=${KOMODO_PASSKEY} ## Specify whether to disable the terminals feature ## and disallow remote shell access (inside the Periphery container). PERIPHERY_DISABLE_TERMINALS=false ## Enable SSL using self signed certificates. ## Connect to Periphery at https://address:8120. PERIPHERY_SSL_ENABLED=true ## If the disk size is overreporting, can use one of these to ## whitelist / blacklist the disks to filter them, whichever is easier. ## Accepts comma separated list of paths. ## Usually whitelisting just /etc/hostname gives correct size. PERIPHERY_INCLUDE_DISK_MOUNTS=/etc/hostname # PERIPHERY_EXCLUDE_DISK_MOUNTS=/snap,/etc/repos ## Prettier logging with empty lines between logs PERIPHERY_LOGGING_PRETTY=false ## More human readable logging of startup config (multi-line) PERIPHERY_PRETTY_STARTUP_CONFIG=false ================================================ FILE: compose/ferretdb.compose.yaml ================================================ ################################### # 🦎 KOMODO COMPOSE - FERRETDB 🦎 # ################################### ## This compose file will deploy: ## 1. Postgres + FerretDB Mongo adapter (https://www.ferretdb.com) ## 2. Komodo Core ## 3. Komodo Periphery services: postgres: # 🚨 Pin to a specific version. Updates can be breaking. # https://github.com/FerretDB/documentdb/pkgs/container/postgres-documentdb image: ghcr.io/ferretdb/postgres-documentdb labels: komodo.skip: # Prevent Komodo from stopping with StopAllContainers restart: unless-stopped # ports: # - 5432:5432 volumes: - postgres-data:/var/lib/postgresql/data environment: POSTGRES_USER: ${KOMODO_DB_USERNAME} POSTGRES_PASSWORD: ${KOMODO_DB_PASSWORD} POSTGRES_DB: postgres ferretdb: # 🚨 Pin to a specific version. Updates can be breaking. # https://github.com/FerretDB/FerretDB/pkgs/container/ferretdb image: ghcr.io/ferretdb/ferretdb labels: komodo.skip: # Prevent Komodo from stopping with StopAllContainers restart: unless-stopped depends_on: - postgres # ports: # - 27017:27017 volumes: - ferretdb-state:/state environment: FERRETDB_POSTGRESQL_URL: postgres://${KOMODO_DB_USERNAME}:${KOMODO_DB_PASSWORD}@postgres:5432/postgres core: image: ghcr.io/moghtech/komodo-core:${COMPOSE_KOMODO_IMAGE_TAG:-latest} labels: komodo.skip: # Prevent Komodo from stopping with StopAllContainers restart: unless-stopped depends_on: - ferretdb ports: - 9120:9120 env_file: ./compose.env environment: KOMODO_DATABASE_ADDRESS: ferretdb:27017 KOMODO_DATABASE_USERNAME: ${KOMODO_DB_USERNAME} KOMODO_DATABASE_PASSWORD: ${KOMODO_DB_PASSWORD} volumes: ## Store dated backups of the database - https://komo.do/docs/setup/backup - ${COMPOSE_KOMODO_BACKUPS_PATH}:/backups ## Store sync files on server # - /path/to/syncs:/syncs ## Optionally mount a custom core.config.toml # - /path/to/core.config.toml:/config/config.toml ## Allows for systemd Periphery connection at ## "https://host.docker.internal:8120" # extra_hosts: # - host.docker.internal:host-gateway ## Deploy Periphery container using this block, ## or deploy the Periphery binary with systemd using ## https://github.com/moghtech/komodo/tree/main/scripts periphery: image: ghcr.io/moghtech/komodo-periphery:${COMPOSE_KOMODO_IMAGE_TAG:-latest} labels: komodo.skip: # Prevent Komodo from stopping with StopAllContainers restart: unless-stopped env_file: ./compose.env volumes: ## Mount external docker socket - /var/run/docker.sock:/var/run/docker.sock ## Allow Periphery to see processes outside of container - /proc:/proc ## Specify the Periphery agent root directory. ## Must be the same inside and outside the container, ## or docker will get confused. See https://github.com/moghtech/komodo/discussions/180. ## Default: /etc/komodo. - ${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo}:${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo} volumes: # Postgres postgres-data: # FerretDB ferretdb-state: ================================================ FILE: compose/mongo.compose.yaml ================================================ ################################ # 🦎 KOMODO COMPOSE - MONGO 🦎 # ################################ ## This compose file will deploy: ## 1. MongoDB ## 2. Komodo Core ## 3. Komodo Periphery services: mongo: image: mongo labels: komodo.skip: # Prevent Komodo from stopping with StopAllContainers command: --quiet --wiredTigerCacheSizeGB 0.25 restart: unless-stopped # ports: # - 27017:27017 volumes: - mongo-data:/data/db - mongo-config:/data/configdb environment: MONGO_INITDB_ROOT_USERNAME: ${KOMODO_DB_USERNAME} MONGO_INITDB_ROOT_PASSWORD: ${KOMODO_DB_PASSWORD} core: image: ghcr.io/moghtech/komodo-core:${COMPOSE_KOMODO_IMAGE_TAG:-latest} labels: komodo.skip: # Prevent Komodo from stopping with StopAllContainers restart: unless-stopped depends_on: - mongo ports: - 9120:9120 env_file: ./compose.env environment: KOMODO_DATABASE_ADDRESS: mongo:27017 KOMODO_DATABASE_USERNAME: ${KOMODO_DB_USERNAME} KOMODO_DATABASE_PASSWORD: ${KOMODO_DB_PASSWORD} volumes: ## Store dated backups of the database - https://komo.do/docs/setup/backup - ${COMPOSE_KOMODO_BACKUPS_PATH}:/backups ## Store sync files on server # - /path/to/syncs:/syncs ## Optionally mount a custom core.config.toml # - /path/to/core.config.toml:/config/config.toml ## Allows for systemd Periphery connection at ## "https://host.docker.internal:8120" # extra_hosts: # - host.docker.internal:host-gateway ## Deploy Periphery container using this block, ## or deploy the Periphery binary with systemd using ## https://github.com/moghtech/komodo/tree/main/scripts periphery: image: ghcr.io/moghtech/komodo-periphery:${COMPOSE_KOMODO_IMAGE_TAG:-latest} labels: komodo.skip: # Prevent Komodo from stopping with StopAllContainers restart: unless-stopped env_file: ./compose.env volumes: ## Mount external docker socket - /var/run/docker.sock:/var/run/docker.sock ## Allow Periphery to see processes outside of container - /proc:/proc ## Specify the Periphery agent root directory. ## Must be the same inside and outside the container, ## or docker will get confused. See https://github.com/moghtech/komodo/discussions/180. ## Default: /etc/komodo. - ${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo}:${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo} volumes: # Mongo mongo-data: mongo-config: ================================================ FILE: compose/periphery.compose.yaml ================================================ #################################### # 🦎 KOMODO COMPOSE - PERIPHERY 🦎 # #################################### ## This compose file will deploy: ## 1. Komodo Periphery services: periphery: image: ghcr.io/moghtech/komodo-periphery:${COMPOSE_KOMODO_IMAGE_TAG:-latest} labels: komodo.skip: # Prevent Komodo from stopping with StopAllContainers restart: unless-stopped ## https://komo.do/docs/connect-servers#configuration environment: PERIPHERY_ROOT_DIRECTORY: ${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo} ## Pass the same passkey as used by the Komodo Core connecting to this Periphery agent. PERIPHERY_PASSKEYS: abc123 ## Make server run over https PERIPHERY_SSL_ENABLED: true ## Specify whether to disable the terminals feature ## and disallow remote shell access (inside the Periphery container). PERIPHERY_DISABLE_TERMINALS: false ## If the disk size is overreporting, can use one of these to ## whitelist / blacklist the disks to filter them, whichever is easier. ## Accepts comma separated list of paths. ## Usually whitelisting just /etc/hostname gives correct size for single root disk. PERIPHERY_INCLUDE_DISK_MOUNTS: /etc/hostname # PERIPHERY_EXCLUDE_DISK_MOUNTS: /snap,/etc/repos volumes: ## Mount external docker socket - /var/run/docker.sock:/var/run/docker.sock ## Allow Periphery to see processes outside of container - /proc:/proc ## Specify the Periphery agent root directory. ## Must be the same inside and outside the container, ## or docker will get confused. See https://github.com/moghtech/komodo/discussions/180. ## Default: /etc/komodo. - ${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo}:${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo} ## If periphery is being run remote from the core server, ports need to be exposed # ports: # - 8120:8120 ## If you want to use a custom periphery config file, use command to pass it to periphery. # command: periphery --config-path ${PERIPHERY_ROOT_DIRECTORY:-/etc/komodo}/periphery.config.toml ================================================ FILE: config/core.config.toml ================================================ ########################### # 🦎 KOMODO CORE CONFIG 🦎 # ########################### ## This is the offical "Default" config file for Komodo Core. ## It serves as documentation for the meaning of the fields. ## It is located at `https://github.com/moghtech/komodo/blob/main/config/core.config.toml`. ## All fields with a "Default" provided are optional. If they are ## left out of the file, the "Default" value will be used. ## This file is bundled into the official image, `ghcr.io/moghtech/komodo-core`, ## as the default config at `/config/.default.config.toml`. ## Komodo Core can start with no external config file mounted. ## Most fields can also be configured using environment variables. ## Environment variables will override values set in this file. ## Can also use JSON or YAML if preferred. You can convert here: ## - YAML: https://it-tools.tech/toml-to-yaml ## - JSON: https://it-tools.tech/toml-to-json ## This will be the document title on the web page. ## Env: KOMODO_TITLE ## Default: 'Komodo' title = "Komodo" ## This should be the url used to access Komodo in browser, potentially behind DNS. ## Eg https://komodo.example.com or http://12.34.56.78:9120. This should match the address configured in your Oauth app. ## Env: KOMODO_HOST ## Required, no default. host = "https://komodo.example.com" ## The port the core system will run on. ## Env: KOMODO_PORT ## Default: 9120 port = 9120 ## The IP address the core server will bind to. ## The default will allow it to accept external IPv4 and IPv6 connections. ## Env: KOMODO_BIND_IP ## Default: [::] bind_ip = "[::]" ## This is the token used to authenticate core requests to periphery. ## Ensure this matches a passkey in the connected periphery configs. ## If the periphery servers don't have passkeys configured, this doesn't need to be changed. ## Env: KOMODO_PASSKEY or KOMODO_PASSKEY_FILE ## Required, no default passkey = "default-passkey-changeme" ## Ensure a server with this address exists on Core ## upon first startup. Example: `https://periphery:8120` ## Env: KOMODO_FIRST_SERVER ## Optional, no default. # first_server = "" ## Give the first server a custom name. ## Env: KOMODO_FIRST_SERVER_NAME ## Default: Local first_server_name = "Local" ## Disables write support on resources in the UI. ## This protects users that that would normally have write priviledges during their UI usage, ## when they intend to fully rely on ResourceSyncs to manage config. ## Env: KOMODO_UI_WRITE_DISABLED ## Default: false ui_write_disabled = false ## Disables the confirm dialogs on all actions. All buttons will now be double-click. ## Useful when only having http connection to core, as UI quick-copy button won't work. ## Env: KOMODO_DISABLE_CONFIRM_DIALOG ## Default: false disable_confirm_dialog = false ## Disables UI websocket automatic reconnection. ## Users will still be able to trigger reconnect by clicking the connection indicator. ## Env: KOMODO_DISABLE_WEBSOCKET_RECONNECT ## Default: false disable_websocket_reconnect = false ## Disable init system resource creation on fresh Komodo launch. ## These include the 'Backup Core Database' and 'Global Auto Update' procedures. ## Env: KOMODO_DISABLE_INIT_RESOURCES ## Default: false disable_init_resources = false ## Configure the directory for sync files (inside the container). ## There shouldn't be a need to change this, just mount a volume. ## Env: KOMODO_SYNC_DIRECTORY ## Default: /syncs sync_directory = "/syncs" ## Configure the repo directory (inside the container). ## There shouldn't be a need to change this, just mount a volume. ## Env: KOMODO_REPO_DIRECTORY ## Default: /repo-cache repo_directory = "/repo-cache" ## Configure the action directory (inside the container). ## There shouldn't be a need to change this, or even mount a volume. ## Env: KOMODO_ACTION_DIRECTORY ## Default: /action-cache action_directory = "/action-cache" ## Interface to use as default route in multi-NIC environments. ## Env: KOMODO_INTERNET_INTERFACE ## Example: "eth1" ## Optional, no default. internet_interface = "" ################ # AUTH / LOGIN # ################ ## Allow user login with a username / password. ## The password will be hashed and stored in the db for login comparison. ## ## NOTE: ## Komodo has no API to recover account logins, but if this happens you can doctor the database using Mongo Compass. ## Create a new Komodo user (Sign Up button), login to the database with Compass, note down your old users username and _id. ## Then delete the old user, and update the new user to have the same username and _id. ## Make sure to set `enabled: true` and maybe `admin: true` on the new user as well, while using Compass. ## ## Env: KOMODO_LOCAL_AUTH ## Default: false local_auth = false ## Initialize the first admin user when starting up Komodo for the first time. ## Env: KOMODO_INIT_ADMIN_USERNAME or KOMODO_INIT_ADMIN_USERNAME_FILE ## Default: None # init_admin_username = "admin" ## Set password for first admin user ## Env: KOMODO_INIT_ADMIN_PASSWORD or KOMODO_INIT_ADMIN_PASSWORD_FILE ## Default: changeme init_admin_password = "changeme" ## Normally new users will be registered, but not enabled until an Admin enables them. ## With `disable_user_registration = true`, only the first user to log in will registered as a user. ## Env: KOMODO_DISABLE_USER_REGISTRATION ## Default: false disable_user_registration = false ## New users will be automatically enabled when they sign up. ## Otherwise, new users will be disabled on first login. ## The first user to login will always be enabled on creation. ## Env: KOMODO_ENABLE_NEW_USERS ## Default: false enable_new_users = false ## Allows all users to have Read level access to all resources. ## Env: KOMODO_TRANSPARENT_MODE ## Default: false transparent_mode = false ## Normally all enabled users can create resources. ## If `disable_non_admin_create = true`, only admin users can create resources. ## Env: KOMODO_DISABLE_NON_ADMIN_CREATE ## Default: false disable_non_admin_create = false ## Normally users can update their username / password using the API. ## This will disable this ability for specific users or all users. ## Example: ## - `lock_login_credentials_for = []` will allow all users to update username / password. ## - `lock_login_credentials_for = ["demo"]` will block the demo user from doing so. ## - `lock_login_credentials_for = ["__ALL__"]` will block all users. ## Env: KOMODO_LOCK_LOGIN_CREDENTIALS_FOR ## Default: empty list lock_login_credentials_for = [] ## Optionally provide a specific jwt secret. ## Passing nothing or an empty string will cause one to be generated on every startup. ## This means users will have to log in again if Komodo restarts. ## Env: KOMODO_JWT_SECRET or KOMODO_JWT_SECRET_FILE ## Default: empty string, meaning a random secret will be generated at startup. jwt_secret = "" ## Specify how long a user can stay logged in before they have to log in again. ## All jwts are invalidated on application restart unless `jwt_secret` is set. ## Env: KOMODO_JWT_TTL ## Options: https://docs.rs/komodo_client/latest/komodo_client/entities/enum.Timelength.html ## Default: 1-day. jwt_ttl = "1-day" ############# # OIDC Auth # ############# ## Enable logins with configured OIDC provider. ## Env: KOMODO_OIDC_ENABLED ## Default: false oidc_enabled = false ## Give the provider address. ## ## The path, ie /application/o/komodo for Authentik, ## is provider and configuration specific. ## ## Note. this address must be reachable from Komodo Core container. ## ## Env: KOMODO_OIDC_PROVIDER ## Optional, no default. oidc_provider = "https://oidc.provider.internal/application/o/komodo" ## Configure OIDC user redirect host. ## ## This is the host address users are redirected to in their browser, ## and may be different from `oidc_provider` host depending on your networking. ## If not provided (or empty string ""), the `oidc_provider` will be used. ## ## Note. DO NOT include the `path` part of the URL. ## Example: `https://oidc.provider.external` ## ## Env: KOMODO_OIDC_REDIRECT_HOST ## Optional, no default. oidc_redirect_host = "" ## Set the OIDC Client ID. ## Env: KOMODO_OIDC_CLIENT_ID or KOMODO_OIDC_CLIENT_ID_FILE oidc_client_id = "" ## Set the OIDC Client Secret. ## If the OIDC provider supports PKCE-only flow, ## the client secret is not necessary and can be ommitted or left empty. ## Env: KOMODO_OIDC_CLIENT_SECRET or KOMODO_OIDC_CLIENT_SECRET_FILE oidc_client_secret = "" ## If true, use the full email for usernames. ## Otherwise, the @address will be stripped, ## making usernames more concise. ## Note. This does not work for all OIDC providers. ## Env: KOMODO_OIDC_USE_FULL_EMAIL ## Default: false. oidc_use_full_email = false ## Some providers attach other audiences in addition to the client_id. ## If you have this issue, `Invalid audiences: `...` is not a trusted audience"`, ## you can add the audience `...` to the list here (assuming it should be trusted). ## Env: KOMODO_OIDC_ADDITIONAL_AUDIENCES or KOMODO_OIDC_ADDITIONAL_AUDIENCES_FILE ## Default: empty oidc_additional_audiences = [] ######### # OAUTH # ######### ## Google ## Env: KOMODO_GOOGLE_OAUTH_ENABLED ## Default: false google_oauth.enabled = false ## Env: KOMODO_GOOGLE_OAUTH_ID or KOMODO_GOOGLE_OAUTH_ID_FILE ## Required if google_oauth is enabled. google_oauth.id = "" ## Env: KOMODO_GOOGLE_OAUTH_SECRET or KOMODO_GOOGLE_OAUTH_SECRET_FILE ## Required if google_oauth is enabled. google_oauth.secret = "" ## Github ## Env: KOMODO_GITHUB_OAUTH_ENABLED ## Default: false github_oauth.enabled = false ## Env: KOMODO_GITHUB_OAUTH_ID or KOMODO_GITHUB_OAUTH_ID_FILE ## Required if github_oauth is enabled. github_oauth.id = "" ## Env: KOMODO_GITHUB_OAUTH_SECRET or KOMODO_GITHUB_OAUTH_SECRET_FILE ## Required if github_oauth is enabled. github_oauth.secret = "" ################## # POLL INTERVALS # ################## ## Controls the rate at which servers are polled for health, system stats, and container status. ## This affects network usage, and the size of the stats stored in mongo. ## Env: KOMODO_MONITORING_INTERVAL ## Options: https://docs.rs/komodo_client/latest/komodo_client/entities/enum.Timelength.html ## Default: 15-sec monitoring_interval = "15-sec" ## Interval at which to poll Resources for any updates / automated actions. ## Env: KOMODO_RESOURCE_POLL_INTERVAL ## Options: https://docs.rs/komodo_client/latest/komodo_client/entities/enum.Timelength.html ## Default: 1-hr resource_poll_interval = "1-hr" ############ # Security # ############ ## Enable HTTPS server using the given key and cert. ## Env: KOMODO_SSL_ENABLED ## Default: false ssl_enabled = false ## Path to the ssl key. ## Env: KOMODO_SSL_KEY_FILE ## Default: /config/ssl/key.pem ssl_key_file = "/config/ssl/key.pem" ## Path to the ssl cert. ## Env: KOMODO_SSL_CERT_FILE ## Default: /config/ssl/cert.pem ssl_cert_file = "/config/ssl/cert.pem" ############ # DATABASE # ############ ## Configure the database connection in one of the following ways: ## Pass a full Mongo URI to the database. ## Example: mongodb://username:password@localhost:27017 ## Env: KOMODO_DATABASE_URI or KOMODO_DATABASE_URI_FILE ## Optional, can usually use `address`, `username`, `password` instead. database.uri = "" ## ==== * OR * ==== ## # Construct the address as mongodb://{username}:{password}@{address} ## Env: KOMODO_DATABASE_ADDRESS database.address = "localhost:27017" ## Env: KOMODO_DATABASE_USERNAME or KOMODO_DATABASE_USERNAME_FILE database.username = "" ## Env: KOMODO_DATABASE_PASSWORD or KOMODO_DATABASE_PASSWORD_FILE database.password = "" ## ==== other ==== ## Komodo will create its collections under this database name. ## The only reason to change this is if multiple Komodo Cores share the same db. ## Env: KOMODO_DATABASE_DB_NAME ## Default: komodo. database.db_name = "komodo" ## This is the assigned app_name of the mongo client. ## The only reason to change this is if multiple Komodo Cores share the same db. ## Env: KOMODO_DATABASE_APP_NAME ## Default: komodo_core. database.app_name = "komodo_core" ############ # WEBHOOKS # ############ ## This token must be given to git provider during repo webhook config. ## The secret configured on the git provider side must match the secret configured here. ## If not provided, ## Env: KOMODO_WEBHOOK_SECRET or KOMODO_WEBHOOK_SECRET_FILE ## Optional, no default. webhook_secret = "a_random_webhook_secret" ## An alternate base url that is used to recieve git webhook requests. ## If empty or not specified, will use 'host' address as base. ## This is useful if Komodo is on an internal network, but can have a ## proxy just allowing through the webhook listener api using NGINX. ## Env: KOMODO_WEBHOOK_BASE_URL ## Default: empty (none) webhook_base_url = "" ## Configure Github webhook app. Enables webhook management apis. ## ## Env: KOMODO_GITHUB_WEBHOOK_APP_APP_ID or KOMODO_GITHUB_WEBHOOK_APP_APP_ID_FILE # github_webhook_app.app_id = 1234455 # Find on the app page. ## Env: ## - KOMODO_GITHUB_WEBHOOK_APP_INSTALLATIONS_IDS or KOMODO_GITHUB_WEBHOOK_APP_INSTALLATIONS_IDS_FILE ## - KOMODO_GITHUB_WEBHOOK_APP_INSTALLATIONS_NAMESPACES # github_webhook_app.installations = [ # ## Find the id after installing the app to user / organization. "namespace" is the username / organization name. # { id = 1234, namespace = "mbecker20" } # ] ## The path to Github webhook app private key. ## This is defaulted to `/github/private-key.pem`, and doesn't need to be changed if running core in Docker. ## Just mount the private key pem file on the host to `/github/private-key.pem` in the container. ## Eg. `/your/path/to/key.pem : /github/private-key.pem` ## Env: KOMODO_GITHUB_WEBHOOK_APP_PK_PATH # github_webhook_app.pk_path = "/path/to/pk.pem" ########### # LOGGING # ########### ## Specify the logging verbosity ## Env: KOMODO_LOGGING_LEVEL ## Options: off, error, warn, info, debug, trace ## Default: info logging.level = "info" ## Specify the logging format. ## Env: KOMODO_LOGGING_STDIO ## Options: standard, json, none ## Default: standard logging.stdio = "standard" ## Optionally specify a opentelemetry otlp endpoint to send traces to. ## Example: http://localhost:4317 ## Env: KOMODO_LOGGING_OTLP_ENDPOINT logging.otlp_endpoint = "" ## Set the opentelemetry service name. ## This will be attached to the telemetry Komodo will send. ## Env: KOMODO_LOGGING_OPENTELEMETRY_SERVICE_NAME ## Default: "Komodo" logging.opentelemetry_service_name = "Komodo" ## Specify whether logging is more human readable. ## Note. Single logs will span multiple lines. ## Env: KOMODO_LOGGING_PRETTY ## Default: false logging.pretty = false ## Specify whether startup config log ## is more human readable (multi-line) ## Env: KOMODO_PRETTY_STARTUP_CONFIG ## Default: false pretty_startup_config = false ########### # PRUNING # ########### ## The number of days to keep historical system stats around, or 0 to disable pruning. ## Stats older that are than this number of days are deleted on a daily cycle. ## Env: KOMODO_KEEP_STATS_FOR_DAYS ## Default: 14 keep_stats_for_days = 14 ## The number of days to keep alerts around, or 0 to disable pruning. ## Alerts older that are than this number of days are deleted on a daily cycle. ## Env: KOMODO_KEEP_ALERTS_FOR_DAYS ## Default: 14 keep_alerts_for_days = 14 ################### # CLOUD PROVIDERS # ################### ## Komodo can build images by deploying AWS EC2 instances, ## running the build, and afterwards destroying the instance. ## Provide AWS api keys for ephemeral builders ## Env: KOMODO_AWS_ACCESS_KEY_ID or KOMODO_AWS_ACCESS_KEY_ID_FILE aws.access_key_id = "" ## Env: KOMODO_AWS_SECRET_ACCESS_KEY or KOMODO_AWS_SECRET_ACCESS_KEY_FILE aws.secret_access_key = "" ################# # GIT PROVIDERS # ################# ## These will be available to attach to Builds, Repos, Stacks, and Syncs. ## They allow these Resources to clone private repositories. ## They cannot be configured on the environment. ## configure git providers # [[git_provider]] # domain = "github.com" # accounts = [ # { username = "mbecker20", token = "access_token_for_account" }, # { username = "moghtech", token = "access_token_for_other_account" }, # ] # [[git_provider]] # domain = "git.mogh.tech" # use a custom provider, like self-hosted gitea # accounts = [ # { username = "mbecker20", token = "access_token_for_account" }, # ] # [[git_provider]] # domain = "localhost:8000" # use a custom provider, like self-hosted gitea # https = false # use http://localhost:8000 as base-url for clone # accounts = [ # { username = "mbecker20", token = "access_token_for_account" }, # ] ###################### # REGISTRY PROVIDERS # ###################### ## These will be available to attach to Builds and Stacks. ## They allow these Resources to pull private images. ## They cannot be configured on the environment. ## configure docker registries # [[docker_registry]] # domain = "docker.io" # accounts = [ # { username = "mbecker2020", token = "access_token_for_account" } # ] # organizations = ["DockerhubOrganization"] # [[docker_registry]] # domain = "git.mogh.tech" # use a custom provider, like self-hosted gitea # accounts = [ # { username = "mbecker20", token = "access_token_for_account" }, # ] # organizations = ["Mogh"] # These become available in the UI ########### # SECRETS # ########### ## Provide Core based secrets. ## These will be available to interpolate into your Deployment / Stack environments, ## and will be hidden in the UI and logs. ## These are available to use on any Periphery (Server), ## but you can also limit access more by placing them in a single Periphery's config file instead. ## These cannot be configured in the Komodo Core environment, they must be passed in the file. # [secrets] # SECRET_1 = "value_1" # SECRET_2 = "value_2" ================================================ FILE: config/komodo.cli.toml ================================================ ########################## # 🦎 KOMODO CLI CONFIG 🦎 # ########################## ## This is the offical "Default" config file for the Komodo CLI. ## It serves as documentation for the meaning of the fields. ## It is located at `https://github.com/moghtech/komodo/blob/main/config/komodo.cli.toml`. ## Most fields can also be configured using cli arguments and environment variables. ## These will will override values set in this file. (cli args > env > config files). ## You can also use JSON or YAML if preferred. You can convert here: ## - YAML: https://it-tools.tech/toml-to-yaml ## - JSON: https://it-tools.tech/toml-to-json # Choose default profile to use with `km ...` default_profile = "Default" # default_profile = "Alt" # Set base values if they aren't defined in profile # Default: 14 max_backups = 7 # Options: HorizontalOnly, VeriticalOnly, OutsideOnly, InsideOnly, AllBorders # Default: HorizontalOnly table_format = "HorizontalOnly" [[profile]] name = "Default" aliases = ["d"] # Use `km -p d ...` # # Env: KOMODO_CLI_HOST > KOMODO_HOST host = "https://komodo.example.com" # Env: KOMODO_CLI_KEY key = "K-..." # Env: KOMODO_CLI_SECRET secret = "S-..." # # Env: KOMODO_CLI_BACKUPS_FOLDER backups_folder = "/backups" # Env: KOMODO_CLI_MAX_BACKUPS max_backups = 14 # # DATABASE USED TO BACKUP / COPY FROM # ## Env: KOMODO_DATABASE_URI or KOMODO_DATABASE_URI_FILE database.uri = "" ## ==== * OR * ==== ## # Construct the address as mongodb://{username}:{password}@{address} ## Env: KOMODO_DATABASE_URI or KOMODO_DATABASE_URI_FILE database.address = "localhost:27017" ## Env: KOMODO_DATABASE_USERNAME or KOMODO_DATABASE_USERNAME_FILE database.username = "" ## Env: KOMODO_DATABASE_PASSWORD or KOMODO_DATABASE_PASSWORD_FILE database.password = "" ## Env: KOMODO_DATABASE_DB_NAME ## Default: komodo. database.db_name = "komodo" # # DATABASE USED TO RESTORE / COPY TO # ## Env: KOMODO_CLI_DATABASE_TARGET_URI database_target.uri = "" ## ==== * OR * ==== ## # Construct the address as mongodb://{username}:{password}@{address} ## Env: KOMODO_CLI_DATABASE_TARGET_URI database_target.address = "localhost:27017" ## Env: KOMODO_CLI_DATABASE_TARGET_USERNAME database_target.username = "" ## Env: KOMODO_CLI_DATABASE_TARGET_PASSWORD database_target.password = "" ## Env: KOMODO_CLI_DATABASE_TARGET_DB_NAME ## Default: komodo. database_target.db_name = "komodo" [[profile]] name = "Alt" # ... Configure same as above ================================================ FILE: config/periphery.config.toml ================================================ ################################ # 🦎 KOMODO PERIPHERY CONFIG 🦎 # ################################ ## This is the offical "Default" config file for Komodo Periphery. ## It serves as documentation for the meaning of the fields. ## It is located at `https://github.com/moghtech/komodo/blob/main/config/periphery.config.toml`. ## All fields with a "Default" provided are optional. If they are ## left out of the file, the "Default" value will be used. ## If Periphery was installed on the host (systemd install script), this ## file will be located either in `/etc/komodo/periphery.config.toml`, ## or for user installs, `$HOME/.config/komodo/periphery.config.toml`. ## Most fields can also be configured using environment variables. ## Environment variables will override values set in this file. ## You can also use JSON or YAML if preferred. You can convert here: ## - YAML: https://it-tools.tech/toml-to-yaml ## - JSON: https://it-tools.tech/toml-to-json ## Optional. The port the server runs on. ## Env: PERIPHERY_PORT ## Default: 8120 port = 8120 ## The IP address the periphery server will bind to. ## The default will allow it to accept external IPv4 and IPv6 connections. ## Env: PERIPHERY_BIND_IP ## Default: [::] bind_ip = "[::]" ## The directory periphery will use as the default base for the directories it uses. ## The periphery user must have write access to this directory. ## Each specific directory (like stack_dir) can be overridden below. ## Env: PERIPHERY_ROOT_DIRECTORY ## Default: /etc/komodo root_directory = "/etc/komodo" ## Optional. Override the directory periphery will use to manage repos. ## The periphery user must have write access to this directory. ## Env: PERIPHERY_REPO_DIR ## Default: ${root_directory}/repos # repo_dir = "/etc/komodo/repos" ## Optional. Override the directory periphery will use to manage stacks. ## The periphery user must have write access to this directory. ## Env: PERIPHERY_STACK_DIR ## Default: ${root_directory}/stacks # stack_dir = "/etc/komodo/stacks" ## Optional. Override the directory periphery will use to manage builds. ## The periphery user must have write access to this directory. ## Env: PERIPHERY_BUILD_DIR ## Default: ${root_directory}/builds # build_dir = "/etc/komodo/builds" ## Disable the terminal APIs and disallow remote shell access through Periphery. ## Env: PERIPHERY_DISABLE_TERMINALS ## Default: false disable_terminals = false ## Disable the container exec APIs and disallow remote container shell access through Periphery. ## This can be left enabled while general terminal access is disabled. ## Env: PERIPHERY_DISABLE_CONTAINER_EXEC ## Default: false disable_container_exec = false ## How often Periphery polls the host for system stats, like CPU / memory usage. ## To effectively disable polling, set this to something like 1-hr. ## Env: PERIPHERY_STATS_POLLING_RATE ## Options: https://docs.rs/komodo_client/latest/komodo_client/entities/enum.Timelength.html ## Default: 5-sec stats_polling_rate = "5-sec" ## How often Periphery polls the host for container stats, ## Env: PERIPHERY_CONTAINER_STATS_POLLING_RATE ## Options: https://docs.rs/komodo_client/latest/komodo_client/entities/enum.Timelength.html ## Default: 30-sec container_stats_polling_rate = "30-sec" ## Whether stack actions should use `docker-compose ...` ## instead of `docker compose ...`. ## Env: PERIPHERY_LEGACY_COMPOSE_CLI ## Default: false legacy_compose_cli = false ## Optional. Only include mounts at specific paths in the disk report. ## Example: include_disk_mounts = ["/mnt/include/1", "/mnt/include/2"] ## Env: PERIPHERY_INCLUDE_DISK_MOUNTS ## Default: empty, which won't filter down the disks. include_disk_mounts = [] ## Optional. Don't include these mounts in the disk report. ## Example: exclude_disk_mounts = ["/mnt/exclude/1", "/mnt/exclude/2"] ## Env: PERIPHERY_EXCLUDE_DISK_MOUNTS ## Default: empty, which won't exclude any disks. exclude_disk_mounts = [] ######## # AUTH # ######## ## Optional. Limit the ip addresses which can call the periphery api. ## Supports Ipv4 / Ipv6 addresses and subnets. ## Examples: allowed_ips = ["::ffff:12.34.56.78", "10.0.10.0/24"] ## Env: PERIPHERY_ALLOWED_IPS ## Default: empty, which will not block any request by ip. allowed_ips = [] ## Optional. Require callers to provide on of the provided passkeys to access the periphery api. ## Example: passkeys = ["your-passkey"] ## Env: PERIPHERY_PASSKEYS or PERIPHERY_PASSKEYS_FILE ## Default: empty, which will not require any passkey to be passed by core. passkeys = [] ############ # Security # ############ ## Enable HTTPS server using the given key and cert. ## If true and a key / cert at the given paths are not found, ## self signed keys will be generated using openssl. ## Env: PERIPHERY_SSL_ENABLED ## Default: true ssl_enabled = true ## Path to the ssl key. ## Env: PERIPHERY_SSL_KEY_FILE ## Default: ${root_directory}/ssl/key.pem # ssl_key_file = "/etc/komodo/ssl/key.pem" ## Path to the ssl cert. ## Env: PERIPHERY_SSL_CERT_FILE ## Default: ${root_directory}/ssl/cert.pem # ssl_cert_file = "/etc/komodo/ssl/cert.pem" ########### # LOGGING # ########### ## Specify the logging verbosity ## Options: off, error, warn, info, debug, trace ## Default: info ## Env: PERIPHERY_LOGGING_LEVEL logging.level = "info" ## Specify the logging format for stdout / stderr. ## Env: PERIPHERY_LOGGING_STDIO ## Options: standard, json, none ## Default: standard logging.stdio = "standard" ## Specify a opentelemetry otlp endpoint to send traces to. ## Example: http://localhost:4317. ## Env: PERIPHERY_LOGGING_OTLP_ENDPOINT ## Optional, no default logging.otlp_endpoint = "" ## Set the opentelemetry service name attached to the telemetry Periphery will send. ## Env: PERIPHERY_LOGGING_OPENTELEMETRY_SERVICE_NAME ## Default: "Komodo" logging.opentelemetry_service_name = "Periphery" ## Specify whether logging is more human readable. ## Note. Single logs will span multiple lines. ## Env: PERIPHERY_LOGGING_PRETTY ## Default: false logging.pretty = false ## Specify whether startup config log ## is more human readable (multi-line) ## Env: PERIPHERY_PRETTY_STARTUP_CONFIG ## Default: false pretty_startup_config = false ################# # GIT PROVIDERS # ################# ## configure Periphery based git providers # [[git_provider]] # domain = "github.com" # accounts = [ # { username = "mbecker20", token = "access_token_for_account" }, # { username = "moghtech", token = "access_token_for_other_account" }, # ] # [[git_provider]] # domain = "git.mogh.tech" # use a custom provider, like self-hosted gitea # accounts = [ # { username = "mbecker20", token = "access_token_for_account" }, # ] # [[git_provider]] # domain = "localhost:8000" # use a custom provider, like self-hosted gitea # https = false # use http://localhost:8000 as base-url for clone # accounts = [ # { username = "mbecker20", token = "access_token_for_account" }, # ] ###################### # REGISTRY PROVIDERS # ###################### ## Configure Periphery based docker registries # [[docker_registry]] # domain = "docker.io" # accounts = [ # { username = "mbecker2020", token = "access_token_for_account" } # ] # organizations = ["DockerhubOrganization"] # [[docker_registry]] # domain = "git.mogh.tech" # use a custom provider, like self-hosted gitea # accounts = [ # { username = "mbecker20", token = "access_token_for_account" }, # ] # organizations = ["Mogh"] # These become available in the UI ########### # SECRETS # ########### ## Provide periphery-based secrets # [secrets] # SECRET_1 = "value_1" # SECRET_2 = "value_2" ================================================ FILE: deploy/deno.json ================================================ {} ================================================ FILE: deploy/komodo.ts ================================================ import * as TOML from "jsr:@std/toml"; const branch = await new Deno.Command("bash", { args: ["-c", "git rev-parse --abbrev-ref HEAD"], }) .output() .then((r) => new TextDecoder("utf-8").decode(r.stdout).trim()); const cargo_toml_str = await Deno.readTextFile("Cargo.toml"); const prev_version = ( TOML.parse(cargo_toml_str) as { workspace: { package: { version: string } }; } ).workspace.package.version; const [version, tag, count] = prev_version.split("-"); const next_count = Number(count) + 1; const next_version = `${version}-${tag}-${next_count}`; await Deno.writeTextFile( "Cargo.toml", cargo_toml_str.replace( `version = "${prev_version}"`, `version = "${next_version}"` ) ); // Cargo check first here to make sure lock file is updated before commit. const cmd = ` cargo check echo "" git add --all git commit --all --message "deploy ${version}-${tag}-${next_count}" echo "" git push echo "" km run -y action deploy-komodo "KOMODO_BRANCH=${branch}&KOMODO_VERSION=${version}&KOMODO_TAG=${tag}-${next_count}" ` .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith("//")) .join(" && "); new Deno.Command("bash", { args: ["-c", cmd], }).spawn(); ================================================ FILE: dev.compose.yaml ================================================ services: core: build: context: . dockerfile: bin/core/aio.Dockerfile restart: unless-stopped logging: driver: local networks: - default environment: KOMODO_FIRST_SERVER: https://periphery:8120 KOMODO_DATABASE_ADDRESS: ferretdb KOMODO_ENABLE_NEW_USERS: true KOMODO_LOCAL_AUTH: true KOMODO_JWT_SECRET: a_random_secret volumes: - repo-cache:/repo-cache periphery: build: context: . dockerfile: bin/periphery/aio.Dockerfile restart: unless-stopped logging: driver: local networks: - default volumes: - /var/run/docker.sock:/var/run/docker.sock - /proc:/proc - repos:/etc/komodo/repos - stacks:/etc/komodo/stacks environment: PERIPHERY_INCLUDE_DISK_MOUNTS: /etc/hostname ferretdb: image: ghcr.io/ferretdb/ferretdb:1 restart: unless-stopped logging: driver: local networks: - default environment: - FERRETDB_HANDLER=sqlite volumes: - data:/state networks: default: {} volumes: data: repo-cache: repos: stacks: ================================================ FILE: docsite/.gitignore ================================================ # Dependencies /node_modules # Production /build # Generated files .docusaurus .cache-loader # Misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* ================================================ FILE: docsite/README.md ================================================ # Website This website is built using [Docusaurus](https://docusaurus.io/), a modern static website generator. ### Installation ``` $ yarn ``` ### Local Development ``` $ yarn start ``` This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. ### Build ``` $ yarn build ``` This command generates static content into the `build` directory and can be served using any static contents hosting service. ### Deployment Using SSH: ``` $ USE_SSH=true yarn deploy ``` Not using SSH: ``` $ GIT_USER= yarn deploy ``` If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. ================================================ FILE: docsite/babel.config.js ================================================ module.exports = { presets: [require.resolve('@docusaurus/core/lib/babel/preset')], }; ================================================ FILE: docsite/docs/ecosystem/api.md ================================================ # API and Clients Komodo Core exposes an RPC-like HTTP API to read data, write configuration, and execute actions. There are typesafe clients available in [**Rust**](/docs/ecosystem/api#rust-client) and [**Typescript**](/docs/ecosystem/api#typescript-client). The full API documentation is [**available here**](https://docs.rs/komodo_client/latest/komodo_client/api/index.html). ## Rust Client The Rust client is published to crates.io at [komodo_client](https://crates.io/crates/komodo_client). ```rust let komodo = KomodoClient::new("https://demo.komo.do", "your_key", "your_secret") .with_healthcheck() .await?; let stacks = komodo.read(ListStacks::default()).await?; let update = komodo .execute(DeployStack { stack: stacks[0].name.clone(), stop_time: None }) .await?; ``` ## Typescript Client The Typescript client is published to NPM at [komodo_client](https://www.npmjs.com/package/komodo_client). ```ts import { KomodoClient, Types } from "komodo_client"; const komodo = KomodoClient("https://demo.komo.do", { type: "api-key", params: { key: "your_key", secret: "your secret", }, }); // Inferred as Types.StackListItem[] const stacks = await komodo.read("ListStacks", {}); // Inferred as Types.Update const update = await komodo.execute("DeployStack", { stack: stacks[0].name, }); ``` ================================================ FILE: docsite/docs/ecosystem/cli.mdx ================================================ # Komodo CLI The Komodo CLI, `km`, can be used to: - Quickly **run executions** and update **resources** and **variables**. - **Reset user passwords** and elevate users to **Super Admin**. - Perform Database **backup**, **restore**, and **copy**. The Komodo Core image comes packaged with the Komodo CLI, and is available for usage inside running container with `docker exec -it komodo-core km ...`. This way, it inherits the Core database config in order to easily perform backups with `km db backup -y`. ### Examples - `km --help` - `km deploy stack my-stack` - `km run action my-action -y` - `km database backup` - `km db restore` - `km set var MY_VAR my_value -y` - `km update build my-build "version=1.19.0&branch=release"` - `km x commit my-sync` - `km set user mbecks super-admin true` - `km set user mbecks password "temp-password"` ### Install There are binaries available for **Linux** (x86_64 / aarch64), **MacOS** (apple silicon), as well as a distroless image: **`ghcr.io/moghtech/komodo-cli`**. #### Linux You can install the binary using the following command: System-wide, as root, to `/usr/local/bin/km`: ```bash curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/install-cli.py | python3 ``` Or as non-root, to `${HOME}/.local/bin/km`: ```bash curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/install-cli.py | python3 - --user ``` #### MacOS (Homebrew) Add the `moghtech/komodo` tap, then install `km`: ```bash brew tap moghtech/komodo && \ brew install km ``` #### Container You can alias a docker run command: ```bash alias km='docker run --rm -v $HOME/.config/komodo:/config ghcr.io/moghtech/komodo-cli km' km config ``` ### Configure The CLI uses a configuration file to pass the Komodo host / api keys, database address and credentials, and configure some other behaviors. Additionally, all configuration fields can be individually overridden using **CLI arguments** or **environment variables**, with CLI arguments having top priority. Whenever you want to check how config will be loaded, you can use the `km config` command to print it out. #### File detection When run, CLI will scan the **current working directory** in addition to `${HOME}/.config/komodo` for any files matching the wildcard pattern **`*komodo.cli*.*`**, parse them into a general representation, and then merge them together. Files which are detected later are merged later, meaning they will override on conflicting fields. By default, files in `${HOME}/.config/komodo` come first in the merge ordering, meaning they are **lower priority** than those detected in the current working directory. You can also override these default paths by passing `km -c /path/to/1/base.config.yaml -c ./overrides ...`. If you want `km` to find configuration files in another directory, you can make a `.kminclude` file inside one of the configured directories. ``` # Supports comments ./.komodo # relative to directory containing `.kminclude` /etc/komodo/komodo.cli.toml # also supports absolute path ``` Note that wildcards in these paths are **not supported**. #### Profiles In the files, you can configure multiple profiles, each with a name / aliases. Then you choose which config profile to use with `km -p ...`. This allows you to easily switch between multiple Cores you want to connect to, or different database backup / restore options. In order to avoid passing `-p ` every time, you can set a `default_profile` at the top level of the configuration file. Additionally, any fields you would like to be the "default" across all profiles can be set at the top level of the file. #### Example File The configuration can also be passed as **YAML** or **JSON**. You can use the it-tools to convert this TOML file to your preferred format: - YAML: https://it-tools.tech/toml-to-yaml - JSON: https://it-tools.tech/toml-to-json Quick download to `./komodo/komodo.cli.toml`: ```bash wget -P komodo https://raw.githubusercontent.com/moghtech/komodo/main/config/komodo.cli.toml ``` ```mdx-code-block import RemoteCodeFile from "@site/src/components/RemoteCodeFile"; ``` ================================================ FILE: docsite/docs/ecosystem/community.md ================================================ # Community ### 3rd party tools - [Ansible Role Komodo](https://github.com/bpbradley/ansible-role-komodo) by [bpbradley](https://github.com/bpbradley) - [Komodo Import](https://foxxmd.github.io/komodo-import/docs/quickstart/) by [FoxxMD](https://github.com/FoxxMD) ### Posts and Guides - [Migrating to Komodo](https://blog.foxxmd.dev/posts/migrating-to-komodo) by [FoxxMD](https://github.com/FoxxMD) - [FAQ, Tips, and Tricks](https://blog.foxxmd.dev/posts/komodo-tips-tricks) by [FoxxMD](https://github.com/FoxxMD) - [Compose Environments Explained](https://blog.foxxmd.dev/posts/compose-envs-explained) by [FoxxMD](https://github.com/FoxxMD) - [How To: Automate version updates for your self-hosted Docker containers with Gitea, Renovate, and Komodo](https://nickcunningh.am/blog/how-to-automate-version-updates-for-your-self-hosted-docker-containers-with-gitea-renovate-and-komodo) by [TheNickOfTime](https://github.com/TheNickOfTime) - [Setting up Komodo, comparison to Portainer, and FAQ](https://skyblog.one/komodo-the-better-alternative-to-portainer-for-container-management) by [Skyfay](https://skyblog.one/authors/) ### Community Alerters These provide alerting implementations which can be used with the `Custom` Alerter type. - [Discord](https://github.com/FoxxMD/deploy-discord-alerter) by [FoxxMD](https://github.com/FoxxMD) - [Telegram](https://github.com/mattsmallman/komodo-alert-to-telgram) by [mattsmallman](https://github.com/mattsmallman) - [Ntfy](https://github.com/FoxxMD/deploy-ntfy-alerter) by [FoxxMD](https://github.com/FoxxMD) - [Gotify](https://github.com/FoxxMD/deploy-gotify-alerter) by [FoxxMD](https://github.com/FoxxMD) - [Apprise](https://github.com/FoxxMD/deploy-apprise-alerter) by [FoxxMD](https://github.com/FoxxMD) - [Email](https://github.com/gutenye/email-notification/blob/main/src/templates/Komodo/Komodo.md) by [Guten Ye](https://github.com/gutenye) ================================================ FILE: docsite/docs/ecosystem/development.md ================================================ # Development If you are looking to contribute to Komodo, this page is a launching point for setting up your Komodo development environment. ## Dependencies Running Komodo from [source](https://github.com/moghtech/komodo) requires either [Docker](https://www.docker.com/) (and can use the included [devcontainer](https://code.visualstudio.com/docs/devcontainers/containers)), or can have the development dependencies installed locally: * Backend (Core / Periphery APIs) * [Rust](https://www.rust-lang.org/) stable via [rustup installer](https://rustup.rs/) * [MongoDB](https://www.mongodb.com/) or [FerretDB](https://www.ferretdb.com/) available locally. * On Debian/Ubuntu: `apt install build-essential pkg-config libssl-dev` required to build the rust source. * Frontend (Web UI) * [Node](https://nodejs.org/en) >= 18.18 + NPM * [Yarn](https://yarnpkg.com/) - (Tip: use `corepack enable` after installing `node` to use `yarn`) * [typeshare](https://github.com/1password/typeshare) * [Deno](https://deno.com/) >= 2.0.2 ### runnables-cli [mbecker20/runnables-cli](https://github.com/mbecker20/runnables-cli) can be used as a convience CLI for running common project tasks found in `runfile.toml`. Otherwise, you can create your own project tasks by references the `cmd`s found in `runfile.toml`. All instructions below will use runnables-cli v1.3.7+. ## Docker After making changes to the project, run `run dev-compose-build` to rebuild Komodo and then `run dev-compose-exposed` to start a Komodo container with the UI accessible at `localhost:9120`. Any changes made to source files will require re-running the `dev-compose-build` and `dev-compose-exposed` commands. ## Devcontainer Use the included `.devcontainer.json` with VSCode or other compatible IDE to stand-up a full environment, including database, with one click. [VSCode Tasks](https://code.visualstudio.com/Docs/editor/tasks) are provided for building and running Komodo. After opening the repository with the devcontainer run the task `Init` to build the frontend/backend. Then, the task `Run Komodo` can be used to run frontend/backend. Other tasks for rebuilding/running just one component of the stack (Core API, Periphery API, Frontend) are also provided. ## Local To run a full Komodo instance from a non-container environment run commands in this order: * Ensure dependencies are up to date * `rustup update` -- ensure rust toolchain is up to date * Build and Run backend * `run dev-core` -- Build and run Core API * `run dev-periphery` -- Build and run Periphery API * Build Frontend * Install **typeshare-cli**: `cargo install typeshare-cli` * **Run this once** -- `run link-client` -- generates TS client and links to the frontend * After running the above once: * `run gen-client` -- Rebuild client * `run dev-frontend` -- Start in dev (watch) mode * `run build-frontend` -- Typecheck and build ## Docsite Development Use `run dev-docsite` to start the [Docusaurus](https://docusaurus.io/) Komodo docs site in development mode. Changes made to files in `./docsite` will be automatically reloaded by the server. ================================================ FILE: docsite/docs/ecosystem/index.mdx ================================================ --- slug: /ecosystem --- # Ecosystem ```mdx-code-block import DocCardList from '@theme/DocCardList'; ``` ================================================ FILE: docsite/docs/intro.md ================================================ --- slug: /intro --- # What is Komodo? Komodo is a web app to provide structure for managing your servers, builds, deployments, and automated procedures. With Komodo you can: - Connect all of your servers, alert on CPU usage, memory usage, and disk usage, and connect to shell sessions. - Create, start, stop, and restart Docker containers on the connected servers, view their status and logs, and connect to container shell. - Deploy docker compose stacks. The file can be defined in UI, or in a git repo, with auto deploy on git push. - Build application source into auto-versioned Docker images, auto built on webhook. Deploy single-use AWS instances for infinite capacity. - Manage repositories on connected servers, which can perform automation via scripting / webhooks. - Manage all your configuration / environment variables, with shared global variable and secret interpolation. - Keep a record of all the actions that are performed and by whom. There is no limit to the number of servers you can connect, and there will never be. There is no limit to what API you can use for automation, and there never will be. No "business edition" here. ## Docker Komodo is opinionated by design, and uses [docker](https://docs.docker.com/) as the container engine for building and deploying. :::info Komodo also supports [**podman**](https://podman.io/) instead of docker by utilizing the `podman` -> `docker` alias. For Stack / docker compose support with podman, check out [**podman-compose**](https://github.com/containers/podman-compose). Thanks to `u/pup_kit` for checking this. ::: ## Architecture and Components Komodo is composed of a single core and any amount of connected servers running the periphery application. ### Core Komodo Core is a web server hosting the Core API and browser UI. All user interaction with the connected servers flow through the Core. ### Periphery Komodo Periphery is a small stateless web server that runs on all connected servers. It exposes an API called by Komodo Core to perform actions on the server, get system usage, and container status / logs. It is only intended to be reached from the core, and has an address whitelist to limit the IPs allowed to call this API. ## Core API Komodo exposes powerful functionality over the Core's REST and Websocket API, enabling infrastructure engineers to manage their infrastructure programmatically. There is a [rust crate](https://crates.io/crates/komodo_client) and [npm package](https://www.npmjs.com/package/komodo_client) to simplify programmatic interaction with the API, but in general this can be accomplished using any programming language that can make REST requests. ## Permissioning Komodo is a system designed to be used by many users, whether they are developers, operations personnel, or administrators. The ability to affect an applications state is very powerful, so Komodo has a granular permissioning system to only provide this functionality to the intended users. The permissioning system is explained in detail in the [permissioning](/docs/resources/permissioning) section. User sign-on is possible using username / password, or with Oauth (Github and Google). See [Core Setup](./setup/index.mdx). ================================================ FILE: docsite/docs/resources/auto-update.md ================================================ # Automatic Updates Starting from **v1.19.0**, new Komodo installs will automatically create the **Global Auto Update** [Procedure](../resources/procedures#procedures), scheduled daily. If you don't have it, this is the Toml: ```toml [[procedure]] name = "Global Auto Update" description = "Pulls and auto updates Stacks and Deployments using 'poll_for_updates' or 'auto_update'." tags = ["system"] config.schedule = "Every day at 03:00" [[procedure.config.stage]] name = "Stage 1" enabled = true executions = [ { execution.type = "GlobalAutoUpdate", execution.params = {}, enabled = true } ] ``` :::info You are also able to integrate `GlobalAutoUpdate` into other Procedures to coordinate the timing with other processes, such as backup. There is nothing special about this Procedure, it's just created by default for guidance / convenience. ::: ### How does it work? Both Stacks and Deployments allow you to configure **Poll for Updates** or **Auto Update**. When [**GlobalAutoUpdate**](https://docs.rs/komodo_client/latest/komodo_client/api/execute/struct.GlobalAutoUpdate.html) is run, Komodo will loop through all the resources with either of these options enabled, and run [**PullStack**](https://docs.rs/komodo_client/latest/komodo_client/api/execute/struct.PullStack.html) / [**PullDeployment**](https://docs.rs/komodo_client/latest/komodo_client/api/execute/struct.PullDeployment.html) in order to pick up any newer images **at the same tag**. Note that in order to work, it requires use of a "Rolling" image tag, such as `:latest`. :::info If you use git sources Stacks and want to automatically update image tags, check out [Renovate](https://github.com/renovatebot/renovate?tab=readme-ov-file#what-is-the-mend-renovate-cli) ::: For resources with **Poll for Updates** enabled and an Alerter configured, it will send an alert that a newer image is available, and display the update available indicator in the UI For resource with **Auto Update** enabled, it will go ahead and Redeploy *just the services* with newer images (by default). If an Alerter is configured, it will also send an alert that this occured. ================================================ FILE: docsite/docs/resources/build-images/builders.md ================================================ # Builders A builder is a machine running the Komodo Periphery agent (and usually docker), which is able to handle a RunBuild / BuildRepo command from Komodo core. Any server connected to Komodo can be chosen as the builder for a build. Building on a machine running production software is usually not a great idea, as this process can use a lot of system resources. It is better to start up a temporary cloud machine dedicated for the build, then shut it down when the build is finished. Komodo supports AWS EC2 for this task. ## AWS builder Builders are now Komodo resources, and are managed via the core API / can be updated using the UI. To use this feature, you need an AWS EC2 AMI with docker and Komodo Periphery configured to run on system start. Once you create your builder and add the necessary configuration, it will be available to attach to builds. ### Setup the instance Create an EC2 instance, and install Docker and Periphery on it. The following script is an example of installing Docker and Periphery onto a Ubuntu/Debian instance: ```sh #!/bin/bash apt update apt upgrade -y curl -fsSL https://get.docker.com | sh systemctl enable docker.service systemctl enable containerd.service curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py | HOME=/root python3 systemctl enable periphery.service ``` :::note AWS provides a "user data" feature, which will run a provided script as root. The above can be used with AWS user data to provide a hands free setup. ::: ### Make an AMI from the instance Once the instance is up and running, ssh in and confirm Periphery is running using: ```sh sudo systemctl status periphery.service ``` If it is not, the install hasn't finished and you should wait a bit. It may take 5 minutes or more (all in updating / installing Docker, Periphery is just a 12 MB binary to download). Once Periphery is running, you can navigate to the instance in the AWS UI and choose `Actions` -> `Image and templates` -> `Create image`. Just name the image and hit create. The AMI will provide a unique id starting with `ami-`, use this with the builder configuration. ### Configure security groups / firewall The builders will need inbound access on port 8120 from Komodo Core, be sure to add a security group with this rule to the Builder configuration. ## Multi-Platform Builds with Docker Buildx If you need to build Docker images for multiple platforms (such as ARM and x86), Docker Buildx provides an easy way to do this. Multi-platform builds can take significantly longer than single-platform builds. When emulating a different architecture (e.g., building ARM images on an x86 host), expect additional time due to QEMU-based emulation. ### 1. Create and use a Buildx builder instance ```sh docker buildx create --name builder --use --bootstrap ``` This command creates a new builder named `builder` and sets it as the active builder for the current Docker context. --- ### 2. Make Buildx the default for `docker build` ```sh docker buildx install ``` This replaces the default `docker build` command with Buildx, so all builds automatically use the current builder instance. --- ### 3. (Optional) View available builders ```sh docker buildx ls ``` Use this to list all builder instances and check which one is active. --- After these steps, any `docker build` command will use Buildx by default, making it straightforward to create multi-platform images. --- ### Platform selection in Komodo When building inside **Komodo**, you can specify the target platforms (e.g., `linux/amd64`, `linux/arm64`) directly in the Komodo UI during build configuration in the build "Extra Args" field. **Example platform string for Extra Args:** ``` --platform linux/amd64,linux/arm64 ``` ================================================ FILE: docsite/docs/resources/build-images/configuration.md ================================================ # Configuration Komodo just needs a bit of information in order to build your image. ### Provider configuration Komodo supports cloning repos over http/s, from any provider that supports cloning private repos using `git clone https://@git-provider.net//`. Accounts / access tokens can be configured in either the [core config](../../setup/advanced.mdx#mount-a-config-file) or in the [periphery config](../../setup/connect-servers.mdx#manual-install-steps---binaries). ### Repo configuration To specify the git repo to build, just give it the name of the repo and the branch under *repo config*. The name is given like `moghtech/komodo`, it includes the username / organization that owns the repo. Many repos are private, in this case an access token is needed by the building server. It can either come from a provider defined in the core configuration, or in the periphery configuration of the building server. ### Docker build configuration In order to docker build, Komodo just needs to know the build directory and the path of the Dockerfile relative to the repo, you can configure these in the *build config* section. If the build directory is the root of the repository, you pass the build path as `.`. If the build directory is some folder of the repo, just pass the name of the the folder. Do not pass the preceding "/". for example `build/directory` The dockerfile's path is given relative to the build directory. So if your build directory is `build/directory` and the dockerfile is in `build/directory/Dockerfile.example`, you give the dockerfile path simply as `Dockerfile.example`. ### Image registry Komodo supports pushing to any docker registry. Any of the accounts that are specified in config for the specific registry, between the core config and builder, will be available to use for authentication against the registry. Additionally, allowed organizations on the docker registry can be specified on the core config and attached to builds. Doing so will cause the images to be published under the organization's namespace rather than the account's. When connecting a build to a deployments, the default behavior is for the deployment to inherit the registry configuration from the build. In cases where that account isn't available to the deployment, another account can be chosen in the deployment config. :::note In order to publish to the Github Container Registry, your Github access token must be given the `write:packages` permission. See the Github docs [here](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-with-a-personal-access-token-classic). ::: ### Adding build args The Dockerfile may make use of [build args](https://docs.docker.com/engine/reference/builder/#arg). Build args can be passed using the gui by navigating to the `Build Args` tab in the config. They are passed in the menu just like in the would in a .env file: ``` BUILD_ARG1=some_value BUILD_ARG2=some_other_value ``` Note that these values are visible in the final image using `docker history`, so shouldn't be used to pass build time secrets. Use [secret mounts](https://docs.docker.com/engine/reference/builder/#run---mounttypesecret) for this instead. ### Adding build secrets The Dockerfile may also make use of [build secrets](https://docs.docker.com/build/building/secrets). They are configured in the GUI the same way as build args. The values passed here can be used in RUN commands in the Dockerfile: ``` RUN --mount=type=secret,id=SECRET_KEY \ SECRET_KEY=$(cat /run/secrets/SECRET_KEY) ... ``` These values will not be visible with `docker history` command. ================================================ FILE: docsite/docs/resources/build-images/index.mdx ================================================ --- slug: /build-images --- # Building Images Komodo builds docker images by cloning the source repository from the configured git provider, running `docker build`, and pushing the resulting image to the configured docker registry. Any repo containing a `Dockerfile` is buildable using this method. ```mdx-code-block import DocCardList from '@theme/DocCardList'; ``` ================================================ FILE: docsite/docs/resources/build-images/pre-build.md ================================================ # Pre-build command Sometimes a command needs to be run before running ```docker build```, you can configure this in the *pre build* section. There are two fields to pass for *pre build*. the first is *path*, which changes the working directory. To run the command in the root of the repo, just pass ```.```. The second field is *command*, this is the shell command to be executed after the repo is cloned. For example, say your repo had a folder in it called ```scripts``` with a shell script ```on-clone.sh```. You would give *path* as ```scripts``` and command as ```sh on-clone.sh```. Or you could make *path* just ```.``` and then the command would be ```sh scripts/on-clone.sh```. Either way works fine. ================================================ FILE: docsite/docs/resources/build-images/versioning.md ================================================ # Image Versioning Komodo uses a major.minor.patch versioning scheme to Build versioning. By default, every RunBuild will auto increment the Build's version patch number, and push the image to docker hub with the version tag, as well as the `latest` tag. A tag containing the latest short commit hash at the time the repo was cloned will also be created. You can also turn off the auto incrementing feature, and manage the version yourself. In addition, you can configure a "version tag" on the build. This will postfix the version tag / commit hash tag with a custom label. For example, an image tag of `dev` will produce tags like `image_name:1.1.1-dev` and `image_name:h3c87c-dev`. ================================================ FILE: docsite/docs/resources/deploy-containers/configuration.md ================================================ # Configuration ## Choose the docker image There are two options to configure the docker image to deploy. ### Attaching a Komodo build If the software you want to deploy is built by Komodo, you can attach the build directly to the deployment. By default, Komodo will deploy the latest available version of the build, or you can specify a specific version using the version dropdown. Also by default, Komodo will use the same docker account that is attached to the build in order to pull the image on the periphery server. If that account is not available on the server, you can specify another available account to use instead, this account just needs to have read access to the docker repository. ### Using a custom image You can also manually specify an image name, like `mongo` or `ghcr.io/mbecker20/random_image:0.1.1`. If the image repository is private, you can still select an available docker account to use to pull the image. ## Configuring the network One feature of docker is that it allows for the creation of [virtual networks between containers](https://docs.docker.com/network/). Komodo allows you to specify a docker virtual network to connect the container to, or to use the host system networking to bypass the docker virtual network. The default selection is `host`, which bypasses the docker virtual network layer. If you do select select a network other than host, you can specify port bindings with the GUI. For example, if you are running mongo (which defaults to port 27017), you could use the mapping: ``` 27018 : 27017 ``` In this case, you would access mongo from outside of the container on port `27018`. Note that this is not the only effect of using a network other than `host`. For example, containers running on different networks can not communicate, and ones on the same network can not reach other containers on `localhost` even when they are running on the same system. This behavior can be a bit confusing if you are not familiar with it, and it can be bypassed entirely by just using `host` network. ## Configuring restart behavior Docker, like systemd, has a couple options for handling when a container exits. See [docker restart policies](https://docs.docker.com/config/containers/start-containers-automatically/). Komodo allows you to select the appropriate restart behavior from these options. ## Configuring environment variables Komodo enables you to easily manage environment variables passed to the container. In the GUI, navigate to the environment tab of the configuration on the deployment page. You pass environment variables just as you would with a ```.env``` file: ``` ENV_VAR_1=some_value ENV_VAR_2=some_other_value ``` ## Configuring volumes A docker container's filesystem is segregated from that of the host. However, it is still possible for a container to access system files and directories, this is accomplished by using [bind mounts](https://docs.docker.com/storage/bind-mounts/). Say your container needs to read a config file located on the system at ```/home/ubuntu/config.toml```. You can specify the bind mount to be: ``` /home/ubuntu/config.toml : /config/config.toml ``` The first path is the one on the system, the second is the path in the container. Your application would then read the file at ```/config/config.toml``` in order to load its contents. These can be configured easily with the GUI in the 'volumes' card. You can configure as many bind mounts as you need. ## Extra args Not all features of docker are mapped directly by Komodo, only the most common. You can still specify any custom flags for Komodo to include in the `docker run` command by utilizing 'extra args'. For example, you can enable log rotation using these two extra args: ``` --log-opt max-size=10M ``` ``` --log-opt max-file=3 ``` ## Command Sometimes you need to override the default command in the image, or specify some flags to be passed directly to the application. What is put here is inserted into the docker run command after the image. For example, to pass the `--quiet` flag to MongoDB, the docker run command would be: ``` docker run -d --name mongo-db mongo:6.0.3 --quiet ``` In order to achieve this with Komodo, just pass `--quiet` to 'command'. ================================================ FILE: docsite/docs/resources/deploy-containers/index.mdx ================================================ # Deploy Containers Komodo can deploy any docker images that it can access with the configured docker accounts. It works by parsing the deployment configuration into a `docker run` command, which is then run on the target system. The configuration is stored on MongoDB, and records of all actions (update config, deploy, stop, etc.) are stored as well. ```mdx-code-block import DocCardList from '@theme/DocCardList'; ``` ================================================ FILE: docsite/docs/resources/deploy-containers/lifetime-management.md ================================================ # Container Management The lifetime of a docker container is more like a virtual machine. They can be created, started, stopped, and destroyed. Komodo will display the state of the container and provides an API to manage all your container's lifetimes. This is achieved internally by running the appropriate docker command for the requested action (docker stop, docker start, etc). ### Stopping a Container Sometimes you want to stop a running application but preserve its logs and configuration, either to be restarted later or to view the logs at a later time. It is more like *pausing* the application with its current config, as no configuration (like environment variable, volume mounts, etc.) will be changed when the container is started again. Note that in order to restart an application with updated configuration, it must be *redeployed*. stopping and starting a container again will keep all configuration as it was when the container was initially created. ### Container Redeploy Redeploying is the action of destroying a container and recreating it. If you update deployment config, these changes will not take effect until the container is redeployed. Just note this will destroy the previous containers logs along with the container itself. ================================================ FILE: docsite/docs/resources/docker-compose.md ================================================ # Docker Compose Komodo can deploy docker compose projects through the `Stack` resource. ## Define the compose file/s Komodo supports 3 ways of defining the compose files: 1. **Write them in the UI**, and Komodo will write them to your host at deploy-time. 2. **Store the files anywhere on the host**, and Komodo will just run the compose commands on the existing files. 3. **Store them in a git repo**, and have Komodo clone it on the host to deploy. If you manage your compose files in git repos: - All your files, across all servers, are available locally to edit in your favorite text editor. - All of your changes are tracked, and can be reverted. - You can use the git webhooks to do other automations when you change the compose file contents. Redeploying will be as easy as `git push`. :::info Many Komodo resources need access to git repos. There is an in-built token management system (managed in UI or in config file) to give resources access to credentials. All resources which depend on git repos are able to use these credentials to access private repos. ::: ## Importing Existing Compose projects First create the Stack in Komodo, and ensure it has access to the compose files using one of the three methods above. Make sure to attach the server you wish to deploy on. In order for Komodo to pick up a running project, it has to know the compose "project name". You can find the project name by running `docker compose ls` on the host. By default, Komodo will assume the Stack name is the compose project name. If this is different than the project name on the host, you can configure a custom "Project Name" in the config. ## Pass Environment Variables Komodo is able to pass custom environment variables to the docker compose process. This works by: 1. Write the variables to a ".env" file on the host at deploy-time. 2. Pass the file to docker compose using the `--env-file` flag. :::info Just like all other resources with Environments (Deployments, Repos, Builds), Stack Environments support **Variable and Secret interpolation**. Define global variables in the UI and share the values across environments. ::: ================================================ FILE: docsite/docs/resources/index.md ================================================ # Resources Komodo is extendible through the **Resource** abstraction. Entities like `Server`, `Deployment`, and `Stack` are all **Komodo Resources**. All resources have common traits, such as a unique `name` and `id` amongst all other resources of the same resource type. All resources can be assigned `tags`, which can be used to group related resources. :::note Many resources need access to git repos / docker registries. There is an in-built token management system (managed in UI or in config file) to give resources access to credentials. All resources which depend on git repos / docker registries are able to use these credentials to access private repos. ::: ## [Server](setup/connect-servers) - Configure the connection to periphery agents. - Set alerting thresholds. - Can be attached to by **Deployments**, **Stacks**, **Repos**, and **Builders**. ## [Deployment](resources/deploy-containers/index.mdx) - Deploy a docker container on the attached Server. - Manage services at the container level, perform orchestration using **Procedures** and **ResourceSyncs**. ## [Stack](resources/docker-compose) - Deploy with docker compose. - Provide the compose file in UI, or move the files to a git repo and use a webhook for auto redeploy on push. - Supports composing multiple compose files using `docker compose -f ... -f ...`. - Pass environment variables usable within the compose file. Interpolate in app-wide variables / secrets. ## Repo - Put scripts in git repos, and run them on a Server, or using a Builder. - Can build binaries, perform automation, really whatever you can think of. ## [Build](resources/build-images/index.mdx) - Build application source into docker images, and push them to the configured registry. - The source can be any git repo containing a Dockerfile. ## [Builder](resources/build-images/builders) - Either points to a connected server, or holds configuration to launch a single-use AWS instance to build the image. - Can be attached to **Builds** and **Repos**. ## [Procedure](resources/procedures#procedures) - Compose many actions on other resource type, like `RunBuild` or `DeployStack`, and run it on button push (or with a webhook). - Can run one or more actions in parallel "stages", and compose a series of parallel stages to run sequentially. ## [Action](resources/procedures#actions) - Write scripts calling the Komodo API in Typescript - Use a pre-initialized Komodo client within the script, no api keys necessary. - Type aware in UI editor. Get suggestions and see in depth docs as you type. - The Typescript client is also [published on NPM](https://www.npmjs.com/package/komodo_client). ## [Resource Sync](resources/sync-resources) - Orchestrate all your configuration declaratively by defining it in `toml` files, which are checked into a git repo. - Can deploy **Deployments** and **Stacks** if changes are suggested. - Specify deploy ordering with `after` array. (like docker compose `depends_on` but can span across servers.). ## Alerter - Route alerts to various endpoints. - Can configure rules on each Alerter, such as resource whitelist, blacklist, or alert type filter. ================================================ FILE: docsite/docs/resources/permissioning.md ================================================ # Permissioning Komodo has a granular, layer-based permissioning system to provide non-admin users access only to intended Resources. ## User Groups While Komodo can assign permissions to specific users directly, it is recommended to instead **create User Groups and assign permissions to them**, as if they were a user. Users can then be **added to multiple User Groups** and they **inherit the group's permissions**, similar to linux permissions. There is also an `Everyone` mode for User Groups, if this is enabled then **all users implicitly gain the groups permissions**. For permissioning at scale, users can define [**User Groups in Resource Syncs**](/docs/resources/sync-resources#user-group). ## Permission Levels There are 4 permission levels a user / group can be given on a Resource: 1. **None**. The user will not have any access to the resource. The user **will not see it in the GUI, and it will not show up if the user queries the Komodo API directly**. All attempts to view or update the resource will be blocked. This is the default for non-admins, unless using `KOMODO_TRANSPARENT_MODE=true`. 2. **Read**. This is the first permission level that grants any access. It will enable the user to **see the resource in the GUI and read the configuration**. Any attempts to update configuration or trigger any action **will be blocked**. Using `KOMODO_TRANSPARENT_MODE=true` will make this level the base level on all resources, for all users. 3. **Execute**. This level will allow the user to execute actions on the resource, **like send a build command** or **trigger a redeploy**. The user will still be blocked from updating configuration on the resource. 4. **Write**. The user has full config write access to the resource, **they can execute any actions, update the configuration, and delete the resource**. ## Specific Permissions Permission levels alone are not quite enough to provide granular access control. Some features are additionally gated behind a specific permission for that feature. - **`Logs`**: User can retrieve docker / docker compose logs on the associated resource. - Valid on `Server`, `Stack`, `Deployment`. - For admins wanting this permission by default for all users with read permissions, see below on default user groups. - **`Inspect`**: User can "inspect" docker containers. - Valid on `Server`, `Stack`, `Deployment`. - **On Servers**: Access to this api will expose all container environments on the given server, and can easily lead to secrets being leaked to unintended users if not protected. - **`Terminal`**: User can access the associated resource's terminal. - If given on a `Server`, this allows server level terminal access, and all container exec priviledges (Including attached `Stacks` / `Deployments`). - If given on a `Stack` or `Deployment`, this allows container exec terminal (even without `Terminal` on `Server`). - **`Attach`**: User can "attach" *other resources* to the resource. - If given on a `Server`, allows users to attach `Stacks`, `Deployments`, `Repos`, and `Builders`. - If given on a `Builder`, allows users to attach `Builds` and `Repos`. - If given on a `Build`, allows users to attach it to `Deployments`. - If given on a `Repo`, allows users to attach it to `Stacks`, `Builds`, and `Resource Syncs`. - **`Processes`**: User can retrieve the full running process list on the `Server`. ## Permissioning by Resource Type Users or User Groups can be given a base permission level on all Resources of a particular type, such as Stack. In TOML form, this looks like: ```toml [[user_group]] name = "groupo" users = ["mbecker20", "karamvirsingh98"] all.Build = "Execute" # <- Group members can run all builds (but not update config), all.Stack = { level = "Read", specific = ["Logs"] } # <- And see all Stacks / logs (no deploy / update, inspect, or terminal access). ``` A user / group can still be given a greater permission level on select resources: ```toml permissions = [ # Grant addition specific permission (Logs are already granted above) { target.type = "Stack", target.id = "my-stack", level = "Execute", specific = ["Inspect", "Terminal"] }, # Use regex to match multiple resources, for example give john execute on all of their Stacks { target.type = "Stack", target.id = "\\^john-(.+)$\\", level = "Execute" }, ] ``` ## Administration Users can be given Admin privileges by a `Super Admin` (only the first user is given this status, set with `super_admin: true` on a User document in database). Super admins will see the "Make Admin" button when on a User page `/users/${user_id}`. These users have unrestricted access to all Komodo Resources. Additionally, these users can update other (non-admin) user's permissions on resources. Komodo admins are responsible for managing user accounts as well. When a user logs into Komodo for the first time, they will not immediately be granted access (this can changed with `KOMODO_ENABLE_NEW_USERS=true`). An admin must first **enable** the user, which can be done from the `Users` tab on `Settings` page. Users can also be **disabled** by an admin at any time, which blocks all their access to the GUI and API. Users also have some configurable global permissions, these are: - create server permission - create build permission Only users with these permissions (as well as admins) can add additional servers to Komodo, and can create additional builds, respectively. ================================================ FILE: docsite/docs/resources/procedures.md ================================================ # Procedures and Actions For orchestrations involving multiple resources and executions, Komodo offers the `Procedure` and `Action` resource types. ## Procedures `Procedures` are compositions of many executions, such as `RunBuild` and `DeployStack`. The executions are grouped into a series of `Stages`, where each `Stage` contains one or more executions to run **_all at once_**. The Procedure will wait until all of the executions in a `Stage` are complete before moving on to the next stage. In short, the executions in a `Stage` are run **_in parallel_**, and the stages themselves are executed **_sequentially_**. ### Batch Executions Many executions have a `Batch` version you can select, for example [**BatchDeployStackIfChanged**](https://docs.rs/komodo_client/latest/komodo_client/api/execute/struct.BatchDeployStackIfChanged.html). With this, you can match multiple Stacks by name using [**wildcard syntax**](https://docs.rs/wildcard/latest/wildcard) and [**regex**](https://docs.rs/regex/latest/regex). ### TOML Example Like all Resources, `Procedures` have a TOML representation, and can be managed in `ResourceSyncs`. ```toml [[procedure]] name = "pull-deploy" description = "Pulls stack-repo, deploys stacks" [[procedure.config.stage]] name = "Pull Repo" executions = [ { execution.type = "PullRepo", execution.params.pattern = "stack-repo" }, ] [[procedure.config.stage]] name = "Deploy if changed" executions = [ # Uses the Batch version, witch matches many stacks by pattern # This one matches all stacks prefixed with `foo-` (wildcard) and `bar-` (regex). { execution.type = "BatchDeployStackIfChanged", execution.params.pattern = "foo-* , \\^bar-.*$\\" }, ] ``` ## Actions `Actions` give users the power of Typescript to write calls to the Komodo API. For example, an `Action` script like this will align the versions and branches of many `Builds`. ```ts const VERSION = "1.16.5"; const BRANCH = "dev/" + VERSION; const APPS = ["core", "periphery"]; const ARCHS = ["x86", "aarch64"]; await komodo.write("UpdateVariableValue", { name: "KOMODO_DEV_VERSION", value: VERSION, }); console.log("Updated KOMODO_DEV_VERSION to " + VERSION); for (const app of APPS) { for (const arch of ARCHS) { const name = `komodo-${app}-${arch}-dev`; await komodo.write("UpdateBuild", { id: name, config: { version: VERSION as any, branch: BRANCH, }, }); console.log( `Updated Build ${name} to version ${VERSION} and branch ${BRANCH}`, ); } } for (const arch of ARCHS) { const name = `periphery-bin-${arch}-dev`; await komodo.write("UpdateRepo", { id: name, config: { branch: BRANCH, }, }); console.log(`Updated Repo ${name} to branch ${BRANCH}`); } ``` ================================================ FILE: docsite/docs/resources/sync-resources.md ================================================ # Sync Resources Komodo is able to create, update, delete, and deploy resources declared in TOML files by diffing them against the existing resources, and apply updates based on the diffs. Similar to Stacks, the files can be configured in UI, in a local file, or in files pushed to a remote git repo. The Komodo Core backend will poll the files for for any updates, and alert about pending changes when diffs are detected. You can spread out your resource declarations across any number of files and use any nesting of folders to organize resources inside a root folder. Additionally, you can create multiple `ResourceSyncs` and configure `Match Tags` to filter down which resources are synced, and each sync will be handled independently. This allows different syncs to manage resources on a "per-project" basis. The UI will display the computed sync actions and only execute them upon manual confirmation. Or the sync execution git webhook may be configured on the git repo to automatically execute syncs upon pushes to the configured branch. ## Commit to Syncs If the Sync is pointing to just a single file, you can enable "Managed Mode" to allow Core to write the updates you made in UI _back to the file_. This works no matter where the files are located, and will create a commit to your git repository for repo based files. ## Example Declarations ### Server - [Server config schema](https://docs.rs/komodo_client/latest/komodo_client/entities/server/struct.ServerConfig.html) ```toml [[server]] # Declare a new server name = "server-prod" description = "the prod server" tags = ["prod"] [server.config] address = "http://localhost:8120" region = "AshburnDc1" enabled = true # default: false ``` ### Builder and build - [Builder config schema](https://docs.rs/komodo_client/latest/komodo_client/entities/builder/enum.BuilderConfig.html) - [Build config schema](https://docs.rs/komodo_client/latest/komodo_client/entities/build/struct.BuildConfig.html) ```toml [[builder]] # Declare a builder name = "builder-01" tags = [] config.type = "Aws" [builder.config.params] region = "us-east-2" ami_id = "ami-0e9bd154667944680" # These things come from your specific setup subnet_id = "subnet-xxxxxxxxxxxxxxxxxx" key_pair_name = "xxxxxxxx" assign_public_ip = true use_public_ip = true security_group_ids = [ "sg-xxxxxxxxxxxxxxxxxx", "sg-xxxxxxxxxxxxxxxxxx" ] ## [[build]] name = "test_logger" description = "Logs randomly at INFO, WARN, ERROR levels to test logging setups" tags = ["test"] [build.config] builder_id = "builder-01" repo = "mbecker20/test_logger" branch = "master" git_account = "mbecker20" image_registry.type = "Standard" image_registry.params.domain = "github.com" # or your custom domain image_registry.params.account = "your_username" image_registry.params.organization = "your_organization" # optinoal # Set docker labels labels = """ org.opencontainers.image.source = https://github.com/mbecker20/test_logger org.opencontainers.image.description = Logs randomly at INFO, WARN, ERROR levels to test logging setups org.opencontainers.image.licenses = GPL-3.0 """ ``` ### Deployments - [Deployment config schema](https://docs.rs/komodo_client/latest/komodo_client/entities/deployment/struct.DeploymentConfig.html) ```toml # Declare variables [[variable]] name = "OTLP_ENDPOINT" value = "http://localhost:4317" ## [[deployment]] # Declare a deployment name = "test-logger-01" description = "test logger deployment 1" tags = ["test"] # sync will deploy the container: # - if it is not running. # - has relevant config updates. # - the attached build has new version. deploy = true [deployment.config] server_id = "server-01" image.type = "Build" image.params.build = "test_logger" # set the volumes / bind mounts volumes = """ # Supports comments /data/logs = /etc/logs # And other formats (eg yaml list) - "/data/config:/etc/config" """ # Set the environment variables environment = """ # Comments supported OTLP_ENDPOINT = [[OTLP_ENDPOINT]] # interpolate variables into the envs. VARIABLE_1 = value_1 VARIABLE_2 = value_2 """ # Set Docker labels labels = "deployment.type = logger" ## [[deployment]] name = "test-logger-02" description = "test logger deployment 2" tags = ["test"] deploy = true # Create a dependency on test-logger-01. This deployment will only be deployed after test-logger-01 is deployed. # Additionally, any sync deploy of test-logger-01 will also trigger sync deploy of this deployment. after = ["test-logger-01"] [deployment.config] server_id = "server-01" image.type = "Build" image.params.build = "test_logger" volumes = """ /data/logs = /etc/logs /data/config = /etc/config""" environment = """ VARIABLE_1 = value_1 VARIABLE_2 = value_2 """ # Set Docker labels labels = "deployment.type = logger" ``` ### Stack - [Stack config schema](https://docs.rs/komodo_client/latest/komodo_client/entities/stack/struct.StackConfig.html) ```toml [[stack]] name = "test-stack" description = "stack test" deploy = true after = ["test-logger-01"] # Stacks can depend on deployments, and vice versa. tags = ["test"] [stack.config] server_id = "server-prod" file_paths = ["mongo.yaml", "redis.yaml"] git_provider = "git.mogh.tech" git_account = "mbecker20" # clone private repo by specifying account repo = "mbecker20/stack_test" ``` ### Procedure - [Procedure config schema](https://docs.rs/komodo_client/latest/komodo_client/entities/procedure/struct.ProcedureConfig.html) ```toml [[procedure]] name = "test-procedure" description = "Do some things in a specific order" tags = ["test"] [[procedure.config.stage]] name = "Build stuff" executions = [ { execution.type = "RunBuild", execution.params.build = "test_logger" }, # Uses the Batch version, witch matches many builds by pattern # This one matches all builds prefixed with `foo-` (wildcard) and `bar-` (regex). { execution.type = "BatchRunBuild", execution.params.pattern = "foo-* , \\^bar-.*$\\" }, { execution.type = "PullRepo", execution.params.repo = "komodo-periphery" }, ] [[procedure.config.stage]] name = "Deploy test logger 1" executions = [ { execution.type = "Deploy", execution.params.deployment = "test-logger-01" }, { execution.type = "Deploy", execution.params.deployment = "test-logger-03", enabled = false }, ] [[procedure.config.stage]] name = "Deploy test logger 2" enabled = false executions = [ { execution.type = "Deploy", execution.params.deployment = "test-logger-02" } ] ``` ### Repo - [Repo config schema](https://docs.rs/komodo_client/latest/komodo_client/entities/repo/struct.RepoConfig.html) ```toml [[repo]] name = "komodo-periphery" description = "Builds new versions of the periphery binary. Requires Rust installed on the host." tags = ["komodo"] [repo.config] server_id = "server-01" git_provider = "git.mogh.tech" # use an alternate git provider (default is github.com) git_account = "mbecker20" repo = "moghtech/komodo" # Run an action after the repo is pulled on_pull.path = "." on_pull.command = """ # Supports comments /root/.cargo/bin/cargo build -p komodo_periphery --release # Multiple lines will be combined together using '&&' cp ./target/release/periphery /root/periphery """ ``` ### Resource sync - [Resource sync config schema](https://docs.rs/komodo_client/latest/komodo_client/entities/sync/type.ResourceSync.html) ```toml [[resource_sync]] name = "resource-sync" [resource_sync.config] git_provider = "git.mogh.tech" # use an alternate git provider (default is github.com) git_account = "mbecker20" repo = "moghtech/komodo" resource_path = ["stacks.toml", "repos.toml"] ``` ### User Group: - [UserGroup schema](https://docs.rs/komodo_client/latest/komodo_client/entities/toml/struct.UserGroupToml.html) ```toml [[user_group]] name = "groupo" everyone = false # Set to true to give these permission to all users. users = ["mbecker20", "karamvirsingh98"] # Configure write access with all specific permissions all.Server = { level = "Write", specific = ["Attach", "Logs", "Inspect", "Terminal", "Processes"] } # Attach base level of Execute on all builds all.Build = "Execute" # Allow users to see all Builders, and attach builds to them. all.Builder = { level = "Read", specific = ["Attach"] } permissions = [ # Attach permissions to specific resources by name { target.type = "Repo", target.id = "komodo-periphery", level = "Execute" }, # Attach permissions to many resources with name matching regex (this uses '^(.+)-(.+)$' as regex expression) { target.type = "Server", target.id = "\\^(.+)-(.+)$\\", level = "Read" }, { target.type = "Deployment", target.id = "\\^immich\\", level = "Execute" }, ] ``` ================================================ FILE: docsite/docs/resources/variables.md ================================================ # Variables and Secrets A variable / secret in Komodo is just a key-value pair. ``` KEY_1 = "value_1" ``` You can interpolate the value into any resource Environment (and most other user configurable inputs, such as Repo `On Clone` and `On Pull`, or Stack `Extra Args`) using double brackets around the key to trigger interpolation: ```toml # Before interpolation SOME_ENV_VAR = [[KEY_1]] # <- wrap the key in double brackets '[[]]' # After iterpolation: SOME_ENV_VAR = value_1 ``` ## Defining Variables and Secrets - **In the UI**, you can go to `Settings` page, `Variables` tab. Here, you can create some Variables to store in the Komodo database. - There is a "secret" option you can check, this will **prevent the value from exposure in any updates / logs**, as well as prevent access to the value to any **non-admin** Komodo users. - Variables can also be managed in ResourceSyncs (see [example](/docs/resources/sync-resources#deployments)) but should only be done for non-secret variables, to avoid committing sensitive data. You should manage secrets using one of the following options. - **Mount a config file to Core**: https://komo.do/docs/setup/advanced#mount-a-config-file - In the Komodo Core config file, you can configure `secrets` using a block like: ```toml # in core.config.toml [secrets] KEY_1 = "value_1" KEY_2 = "value_2" ``` - `KEY_1` and `KEY_2` will be available for interpolation on all your resources, as if they were Variables set up in the UI. - They keys are queryable and show up on the variable page (so you know they are available for use), but **the values are not exposed by API for ANY user**. - **Mount a config file to Periphery agent**: - In the Komodo Periphery config file, you can also configure `secrets` using the same syntax as the Core config file. - The variable **WILL NOT be available globally to all Komodo resources**, it will only be available to the resources on the associated Server resource on which that single Periphery agent is running. - This effectively distributes your secret locations, can be good or bad depending on your security requirements. It does avoid the need to send the secret over network from Core to Periphery, Periphery based secrets are never exposed to the network. - **Use a dedicated secret management tool** such as Hashicorp Vault, alongside Komodo - Ultimately Komodo variable / secret features **may not fill enterprise level secret management requirements**, organizations of this level should use still a dedicated secret management solution. At this point Komodo is not intended as an enterprise level secret management solution. - These solutions do require application level integrations, your applications should only receive credentials to access the secret management API. **Your applications will pull the actual secret values from the dedicated secret management tool, they stay out of Komodo entirely**. ================================================ FILE: docsite/docs/resources/webhooks.md ================================================ # Configuring Webhooks Multiple Komodo resources can take advantage of webhooks from your git provider. Komodo supports incoming webhooks using either the Github or Gitlab webhook authentication type, which is also supported by other providers like Gitea. :::note On Gitea, the default "Gitea" webhook type works with the Github authentication type 👍 ::: ## Copy the Webhook URL Find the resource in UI, like a `Build`, `Repo`, or `Stack`. Go to the `Config` section, find "Webhooks", and copy the webhook for the action you want. The webhook URL is constructed as follows: ```shell https://${HOST}/listener/${AUTH_TYPE}/${RESOURCE_TYPE}/${ID_OR_NAME}/${EXECUTION} ``` - **`HOST`**: Your Komodo endpoint to recieve webhooks. - If your Komodo sits in a private network, you will need a public proxy setup to forward `/listener` requests to Komodo. - **`AUTH_TYPE`**: - options: `github` | `gitlab` - `github`: Validates the signature attached with `X-Hub-Signature-256`. [reference](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries) - `gitlab`: Checks that the secret attached to `X-Gitlab-Token` is valid. [reference](https://docs.gitlab.com/ee/user/project/integrations/webhooks.html#create-a-webhook) - **`RESOURCE_TYPE`**: - options: `build` | `repo` | `stack` | `sync` | `procedure` | `action` - **`ID_OR_NAME`**: - Reference the specific resource by id or name. If the name may change, it is better to use id. - **`EXECUTION`**: - Which executions are available depends on the `RESOURCE_TYPE`. Builds only have the `/build` action. Repos can select between `/pull`, `/clone`, or `/build`. Stacks have `/deploy` and `/refresh`, and Resource Syncs have `/sync` and `/refresh`. - For **Procedures and Actions**, this will be the **branch to listen to for pushes**, or `__ANY__` to trigger on pushes to any branch. ## Create the webhook on the Git Provider Navigate to the repo page on your git provider, and go to the settings for the Repo. Find Webhook settings, and click to create a new webhook. You will have to input some information. 1. The `Payload URL` is the link that you copied in the step above, `Copy the Resource Payload URL`. 2. For Content-type, choose `application/json` 3. For Secret, input the secret you configured in the Komodo Core config (`KOMODO_WEBHOOK_SECRET`). 4. Enable SSL Verification, if you have proper TLS setup to your git provider (recommended). 5. For "events that trigger the webhook", just the push request is what most people want. 6. Of course, make sure the webhook is "Active" and hit create. ## When does it trigger? Your git provider will now push this webhook to Komodo on *every* push to *any* branch. However, your `Build`, `Repo`, etc. only cares about a specific branch of the repo. Because of this, the webhook will trigger the action **only on pushes to the branch configured on the resource**. For example, if I make a build, I may point the build to the `release` branch of a particular repo. If I set up a webhook, and push to the `main` branch, the action will *not trigger*. It will only trigger when the push is to the `release` branch. ================================================ FILE: docsite/docs/setup/advanced.mdx ================================================ # Advanced Configuration ### OIDC / Oauth2 To enable OAuth2 login, you must create a client on the respective OAuth provider, for example [Github](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) or [Google](https://developers.google.com/identity/protocols/oauth2). Komodo also supports self hosted Oauth2 providers like [Authentik](https://docs.goauthentik.io/docs/providers/oauth2/), [Gitea](https://docs.gitea.com/development/oauth2-provider) and [Keycloak](https://www.keycloak.org). - Komodo uses the `web application` login flow. - The redirect uri is: - `/auth/github/callback` for Github. - `/auth/google/callback` for Google. - `/auth/oidc/callback` for OIDC. ### Authentik Check out the [Authentik official support documentation](https://integrations.goauthentik.io/infrastructure/komodo/). ### Keycloak - Create an [OIDC client](https://www.keycloak.org/docs/latest/server_admin/index.html#proc-creating-oidc-client_server_administration_guide) in Keycloak. - Note down the `Client ID` that you enter (e.g.: "komodo"), you will need it for Komodo configuration - `Valid Redirect URIs`: use `/auth/oidc/callback` and substitute `` with your Komodo url. - Turn `Client authentication` to `On`. - After you finished creating the client, open it and go to `Credentials` tab and copy the `Client Secret` - Edit your environment variables for komodo core docker container and set the following: - `KOMODO_OIDC_ENABLED=true` - `KOMODO_OIDC_PROVIDER=https:///realms/master` or replace `master` with another realm if you don't want to use the default one - `KOMODO_OIDC_CLIENT_ID=...` what you specified as `Client ID` - `KOMODO_OIDC_CLIENT_SECRET=...` that you copied from Keycloak ### Mount a config file If you prefer to keep sensitive information out of environment variables, you can optionally write a config file on your host, and mount it to `/config/config.toml` in the Komodo core container. The configuration can also be passed as **YAML** or **JSON**. You can use the it-tools to convert this TOML file to your preferred format: - YAML: https://it-tools.tech/toml-to-yaml - JSON: https://it-tools.tech/toml-to-json :::info Configuration can still be passed in environment variables, and will take precedent over what is passed in the file. ::: Quick download to `./komodo/core.config.toml`: ```bash wget -P komodo https://raw.githubusercontent.com/moghtech/komodo/main/config/core.config.toml ``` ```mdx-code-block import RemoteCodeFile from "@site/src/components/RemoteCodeFile"; ``` ================================================ FILE: docsite/docs/setup/backup.md ================================================ # Backup and Restore :::info Database backup and restore is actually a function of the [Komodo CLI](../ecosystem/cli), which is packaged in with the Komodo Core image for convenience. ::: Starting from **v1.19.0**, new Komodo installs will automatically create the **Backup Core Database** [Procedure](../resources/procedures#procedures), scheduled daily. If you don't have it, this is the Toml: ```toml [[procedure]] name = "Backup Core Database" description = "Triggers the Core database backup at the scheduled time." tags = ["system"] config.schedule = "Every day at 01:00" [[procedure.config.stage]] name = "Stage 1" enabled = true executions = [ { execution.type = "BackupCoreDatabase", execution.params = {}, enabled = true } ] ``` :::info You are also able to integrate `BackupCoreDatabase` into other Procedures, for example to trigger this process before launching a backup container. There is nothing special about this Procedure, it's just created by default for guidance / convenience. ::: ## Backups When Komodo takes a database backup, it creates a **folder named for the time the backup was taken**, and dumps the gzip-compressed documents to files in this folder. In order to store the backups to disk, **mount a host path to `/backups`** in the Komodo Core container. Due to its larger size and relative unimportance, the `Stats` collection (containing historical server cpu / mem / disk usage) is not included in dated backups. Just latest Stats are maintained at the top level of the backup folder. In order to prevent unbounded growth, the backup process implements a pruning feature which will ensure only the most recent 14 backup folders are kept. To change this number, set `max_backups` (`KOMODO_CLI_MAX_BACKUPS`) in `core.config.toml`, `komodo.cli.toml`, or in the Core container environment. ``` # Folder structure /backups | 2025-08-12_03-00-01 | | Action.gz | | Alerter.gz | | ... | 2025-08-13_03-00-01 | 2025-08-14_03-00-01 | ... | Stats.gz ``` :::warning Currently no encryption is supported, so you may want to encrypt the files before backing up remotely if your backup solution doesn't support that natively. ::: ## Remote Backups Since database backup is actually a function of the [Komodo CLI](../ecosystem/cli), you can also backup directly to a remote server using the `ghcr.io/moghtech/komodo-cli` image. This service will backup once and then exit, so the scheduled deployment should still happen using a Procedure or Action: ```yaml services: cli: image: ghcr.io/moghtech/komodo-cli command: km database backup -y volumes: - /path/to/komodo/backups:/backups environment: ## Database port must be reachable. KOMODO_DATABASE_ADDRESS: komodo.example.com:27017 KOMODO_DATABASE_USERNAME: KOMODO_DATABASE_PASSWORD: KOMODO_DATABASE_DB_NAME: komodo KOMODO_CLI_MAX_BACKUPS: 30 # set to your preference ``` ## Restore The Komodo CLI handles database restores as well. ```yaml services: cli: image: ghcr.io/moghtech/komodo-cli ## Optionally specify a specific folder with `--restore-folder`, ## otherwise restores the most recent backup. command: km database restore -y # --restore-folder 2025-08-14_03-00-01 volumes: # Same mount to backup files as above - /path/to/komodo/backups:/backups environment: ## Database port must be reachable. ## Note the different env vars needed compared to backup. ## This is to prevent any accidental restores. KOMODO_CLI_DATABASE_TARGET_ADDRESS: komodo.example.com:27017 KOMODO_CLI_DATABASE_TARGET_USERNAME: KOMODO_CLI_DATABASE_TARGET_PASSWORD: KOMODO_CLI_DATABASE_TARGET_DB_NAME: komodo-restore ``` :::warning The restore process can be run multiple times with same backup files, and won't create any extra copies. HOWEVER it will not "clear" the target database beforehand. If the restore database is already populated, those old documents will also remain. You may want to drop / delete the target database before restoring to it in this case. ::: ## Consistency So long as the backup process completes successfully, the files produces can always be restored no matter how active the Komodo instance is at the time of backup. However writes that happen during the backup process, such as updates to the resource configuration, may or may not be included in the backup depending on the timing. While it should be rare that this causes any kind of issue when it comes to restoring, if your Komodo undergoes a lot of usage at all hours and you are worried about consistency, you could consider [locking](https://www.mongodb.com/docs/manual/reference/method/db.fsyncLock/#mongodb-method-db.fsyncLock) Mongo before the backup. Just make sure to [unlock](https://www.mongodb.com/docs/manual/reference/method/db.fsyncUnlock/) the database afterwards. ================================================ FILE: docsite/docs/setup/connect-servers.mdx ================================================ # Connect More Servers ```mdx-code-block import RemoteCodeFile from "@site/src/components/RemoteCodeFile"; ``` Connecting a server to Komodo has 2 steps: 1. Install the Periphery agent on the server (either binary or container). 2. Add the server to Komodo via the Core API / UI. ## Install Periphery You can install Periphery as a systemd managed process, run it as a [docker container](https://github.com/moghtech/komodo/pkgs/container/komodo-periphery), or do whatever you want with the binary. :::warning Allowing unintended access to the Periphery agent API is a security risk. Ensure to take appropriate measures to block access to the Periphery API, such as firewall rules on port `8120`. Additionally, you can whitelist your Komodo Core IP address in the [Periphery config](https://github.com/moghtech/komodo/blob/main/config/periphery.config.toml#L46), and configure it to [only accept requests including your Core passkey](https://github.com/moghtech/komodo/blob/main/config/periphery.config.toml#L51). ::: ### Install the Periphery agent - systemd As root user: ```bash curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py | python3 ``` Periphery can also be installed to run as the calling user, just note this comes with some additional configuration. ```bash curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py | python3 - --user ``` You can find more information (and view the script) in the [readme](https://github.com/moghtech/komodo/tree/main/scripts). :::info This script can be run multiple times without issue, and it won't change existing config after the first run. Just run it again after a Komodo version release, and it will update the periphery version. ::: :::tip For deployment to many servers, a tool like [Ansible](https://docs.ansible.com/) should be used. An example of such a setup can be found here: https://github.com/bpbradley/ansible-role-komodo ::: ### Install the Periphery agent - container You can use a docker compose file: ```mdx-code-block ``` ### Manual install steps - binaries 1. Download the periphery binary from the latest [release](https://github.com/moghtech/komodo/releases). 2. Create and edit your config files, following the [config example](https://github.com/moghtech/komodo/blob/main/config/periphery.config.toml). :::note See the [periphery config docs](https://docs.rs/komodo_client/latest/komodo_client/entities/config/periphery/index.html) for more information on configuring periphery. ::: 3. Ensure that inbound connectivity is allowed on the port specified in periphery.config.toml (default 8120). 4. Install docker. See the [docker install docs](https://docs.docker.com/engine/install/). :::note Ensure that the user which periphery is run as has access to the docker group without sudo. ::: 5. Start the periphery binary with your preferred process manager, like systemd. ### Example periphery start command ``` periphery \ --config-path /path/to/periphery.config.base.toml \ --config-path /other_path/to/override-periphery-config-directory \ --config-keyword periphery \ --config-keyword config \ --merge-nested-config true ``` :::info You can run `periphery --help` to see the manual. ::: When running periphery in docker, use [command](https://docs.docker.com/reference/compose-file/services/#command) to pass in additional arguments. ``` command: periphery --config-path /path/in/container/to/periphery.config.base.toml ``` ### Passing config files Either file paths or directory paths can be passed to `--config-path` (alias: `-c`). By default, no paths will be used, meaning the configuration is entirely loaded via environment variables. When using directories, the file entries can be filtered by name with the `--config-keyword` argument, which can be passed multiple times to add more keywords. These are each wildcard patterns to match file names. Only config files with file names that contain a keyword will be merged, with files matching later defined keywords having higher priority on field conflicts. By default, the only keyword is `*config*.*`. This matches files like `config.toml`, `periphery.config.yaml`, etc. When passing multiple config files, later --config-path given in the command will always override previous ones. Directory config files are merged in alphabetical order by name, so `config_b.toml` will override `config_a.toml`. There are two ways to merge config files. The default behavior is to completely replace any base fields with whatever fields are present in the override config. So if you pass `allowed_ips = []` in your override config, the final allowed_ips will be an empty list as well. `--merge-nested-config true` will merge config fields recursively and extend config array fields. For example, with `--merge-nested-config true` you can specify an allowed ip in the base config, and another in the override config, they will both be present in the final config. Similarly, you can specify a base docker / github account pair, and extend them with additional accounts in the override config. ## Configuration The configuration can also be passed as **YAML** or **JSON**. You can use the it-tools to convert this TOML file to your preferred format: - YAML: https://it-tools.tech/toml-to-yaml - JSON: https://it-tools.tech/toml-to-json Quick download to `./komodo/periphery.config.toml`: ```bash wget -P komodo https://raw.githubusercontent.com/moghtech/komodo/main/config/periphery.config.toml ``` ```mdx-code-block ``` ================================================ FILE: docsite/docs/setup/ferretdb.mdx ================================================ # FerretDB :::info - If you setup Komodo using **Postgres** or **Sqlite** options prior to [**Komodo v1.18.0**](https://github.com/moghtech/komodo/releases/tag/v1.18.0), you are using **FerretDB v1**. - Komodo now uses **FerretDB v2**. For existing users, [**upgrading requires a migration**](https://github.com/moghtech/komodo/blob/main/bin/cli/docs/copy-database.md#ferretdb-v2-update-guide). ::: [**FerretDB**](https://www.ferretdb.com) is a MongoDB-compatible database backed by [Postgres + DocumentDB extension](https://github.com/microsoft/documentdb). It is a solid option with performance comparable to MongoDB, and can also be run on some systems which [do not support MongoDB](https://github.com/moghtech/komodo/issues/59). 1. Copy `komodo/ferretdb.compose.yaml` and `komodo/compose.env` to your host: ```bash wget -P komodo https://raw.githubusercontent.com/moghtech/komodo/main/compose/ferretdb.compose.yaml && \ wget -P komodo https://raw.githubusercontent.com/moghtech/komodo/main/compose/compose.env ``` 2. Edit the variables in `komodo/compose.env`. 3. Deploy using command: ```bash docker compose -p komodo -f komodo/ferretdb.compose.yaml --env-file komodo/compose.env up -d ``` ```mdx-code-block import ComposeAndEnv from "@site/src/components/ComposeAndEnv"; ``` ================================================ FILE: docsite/docs/setup/index.mdx ================================================ # Setup Komodo Core To run Komodo, you will need Docker. See [the docker install docs](https://docs.docker.com/engine/install/). ### Deploy with Docker Compose - [**Using MongoDB**](./mongo.mdx) - [**Using FerretDB** (Postgres)](./ferretdb.mdx) :::info Some systems [do not support running the latest MongoDB versions](https://github.com/moghtech/komodo/issues/59). Users with these systems should use FerretDB instead. ::: :::info **FerretDB v1** users: There is an [**upgrade guide for FerretDB v2** available here](https://github.com/moghtech/komodo/blob/main/bin/cli/docs/copy-database.md#ferretdb-v2-update-guide). ::: ### First login Core should now be accessible on the specified port and navigating to `http://

:` will display the login page. Enter your preferred admin username and password, and click **"Sign Up"**, _not_ "Log In", to create your admin user for Komodo. Any additional users to create accounts will be disabled by default, and must be enabled by an admin. ### Https Komodo Core only supports http, so a reverse proxy like [caddy](https://caddyserver.com/) should be used for https. ```mdx-code-block import DocCardList from '@theme/DocCardList'; ``` ================================================ FILE: docsite/docs/setup/mongo.mdx ================================================ # MongoDB [**MongoDB**](https://www.mongodb.com) is the standard database for Komodo. Komodo Core communicates with the database using the MongoDB driver. 1. Copy `komodo/mongo.compose.yaml` and `komodo/compose.env` to your host: ```bash wget -P komodo https://raw.githubusercontent.com/moghtech/komodo/main/compose/mongo.compose.yaml && \ wget -P komodo https://raw.githubusercontent.com/moghtech/komodo/main/compose/compose.env ``` 2. Edit the variables in `komodo/compose.env`. 3. Deploy using command: ```bash docker compose -p komodo -f komodo/mongo.compose.yaml --env-file komodo/compose.env up -d ``` ```mdx-code-block import ComposeAndEnv from "@site/src/components/ComposeAndEnv"; ``` ================================================ FILE: docsite/docs/setup/version-upgrades.md ================================================ # Version Upgrades Most version upgrades only require a redeployment of the Core container after pulling the latest version, and are fully backward compatible with the periphery clients, which may be updated later on as convenient. This is the default, and will be the case unless specifically mentioned in the [version release notes](https://github.com/moghtech/komodo/releases). Some Core API upgrades may change behavior such as building / cloning, and require updating the Periphery binaries to match the Core version before this functionality can be restored. This will be specifically mentioned in the release notes. ================================================ FILE: docsite/docusaurus.config.ts ================================================ import {themes as prismThemes} from 'prism-react-renderer'; import type {Config} from '@docusaurus/types'; import type * as Preset from '@docusaurus/preset-classic'; import dotenv from "dotenv" dotenv.config(); const config: Config = { title: "Komodo", tagline: "Build and deployment system", favicon: "img/favicon.ico", // Set the production url of your site here url: "https://komo.do", // Set the // pathname under which your site is served // For GitHub pages deployment, it is often '//' // baseUrl: "/komodo/", baseUrl: "/", // GitHub pages deployment config. // If you aren't using GitHub pages, you don't need these. organizationName: "moghtech", // Usually your GitHub org/user name. projectName: "komodo", // Usually your repo name. trailingSlash: false, deploymentBranch: "gh-pages-docs", onBrokenLinks: "throw", onBrokenMarkdownLinks: "warn", // Even if you don't use internationalization, you can use this field to set // useful metadata like html lang. For example, if your site is Chinese, you // may want to replace "en" with "zh-Hans". i18n: { defaultLocale: "en", locales: ["en"], }, presets: [ [ "classic", { docs: { sidebarPath: "./sidebars.ts", editUrl: "https://github.com/moghtech/komodo/tree/main/docsite", }, blog: { showReadingTime: true, editUrl: "https://github.com/moghtech/komodo/tree/main/docsite", }, theme: { customCss: "./src/css/custom.css", }, } satisfies Preset.Options, ], ], themeConfig: { image: "img/monitor-lizard.png", docs: { sidebar: { autoCollapseCategories: true, }, }, navbar: { title: "Komodo", logo: { alt: "monitor lizard", src: "img/komodo-512x512.png", width: "34px", }, items: [ { type: "docSidebar", sidebarId: "docs", position: "left", label: "docs", }, { href: "https://opencollective.com/komodo", label: "Donate", position: "right", }, { href: "https://docs.rs/komodo_client/latest/komodo_client", label: "Docs.rs", position: "right", }, { href: "https://github.com/moghtech/komodo", label: "Github", position: "right", }, ], }, footer: { style: "dark", copyright: `Built with Docusaurus`, }, prism: { theme: prismThemes.github, darkTheme: prismThemes.dracula, additionalLanguages: ["bash", "yaml", "toml"], }, } satisfies Preset.ThemeConfig, }; export default config; ================================================ FILE: docsite/package.json ================================================ { "name": "docsite", "version": "0.0.0", "private": true, "scripts": { "docusaurus": "docusaurus", "start": "docusaurus start", "build": "docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "typecheck": "tsc" }, "dependencies": { "@docusaurus/core": "^3.8.1", "@docusaurus/preset-classic": "^3.8.1", "@mdx-js/react": "^3.1.0", "clsx": "^2.1.1", "prism-react-renderer": "^2.4.1", "react": "^19.1.1", "react-dom": "^19.1.1" }, "devDependencies": { "@docusaurus/module-type-aliases": "^3.8.1", "@docusaurus/tsconfig": "^3.8.1", "@docusaurus/types": "^3.8.1", "dotenv": "^17.2.1", "typescript": "^5.9.2" }, "browserslist": { "production": [ ">0.5%", "not dead", "not op_mini all" ], "development": [ "last 3 chrome version", "last 3 firefox version", "last 5 safari version" ] }, "engines": { "node": ">=18.0" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } ================================================ FILE: docsite/runfile.toml ================================================ [dev-docsite] alias = "dd" description = "starts the documentation site (https://komo.do) in dev mode" cmd = "yarn && yarn start" [publish-docsite] description = "publishes the documentation site (https://komo.do) to github pages" cmd = "yarn && yarn deploy" ================================================ FILE: docsite/sidebars.ts ================================================ import type { SidebarsConfig } from "@docusaurus/plugin-content-docs"; /** * Creating a sidebar enables you to: - create an ordered group of docs - render a sidebar for each doc of that group - provide next/previous navigation The sidebars can be generated from the filesystem, or explicitly defined here. Create as many sidebars as you want. */ const sidebars: SidebarsConfig = { docs: [ "intro", { type: "category", label: "Setup", link: { type: "doc", id: "setup/index", }, items: [ "setup/mongo", "setup/ferretdb", "setup/connect-servers", "setup/backup", "setup/advanced", "setup/version-upgrades", ], }, { type: "category", label: "Resources", link: { type: "doc", id: "resources/index", }, items: [ { type: "category", label: "Build Images", link: { type: "doc", id: "resources/build-images/index", }, items: [ "resources/build-images/configuration", "resources/build-images/pre-build", "resources/build-images/builders", "resources/build-images/versioning", ], }, { type: "category", label: "Deploy Containers", link: { type: "doc", id: "resources/deploy-containers/index", }, items: [ "resources/deploy-containers/configuration", "resources/deploy-containers/lifetime-management", ], }, "resources/docker-compose", "resources/auto-update", "resources/variables", "resources/procedures", "resources/sync-resources", "resources/webhooks", "resources/permissioning", ], }, { type: "category", label: "Ecosystem", link: { type: "doc", id: "ecosystem/index", }, items: [ "ecosystem/cli", "ecosystem/api", "ecosystem/community", "ecosystem/development", ], }, ], }; export default sidebars; ================================================ FILE: docsite/src/components/ComposeAndEnv.tsx ================================================ import React from "react"; import RemoteCodeFile from "./RemoteCodeFile"; import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; export default function ComposeAndEnv({ file_name, }: { file_name: string; }) { return ( ); } ================================================ FILE: docsite/src/components/Divider.tsx ================================================ import React from "react"; export default function Divider() { return ( } > {children} ); }; export type PrimitiveConfigArgs = { placeholder?: string; label?: string; boldLabel?: boolean; description?: ReactNode; }; export type ConfigComponent = { label: string; boldLabel?: boolean; // defaults to true icon?: ReactNode; actions?: ReactNode; labelExtra?: ReactNode; description?: ReactNode; hidden?: boolean; labelHidden?: boolean; contentHidden?: boolean; components: { [K in keyof Partial]: | boolean | PrimitiveConfigArgs | ((value: T[K], set: (value: Partial) => void) => ReactNode); }; }; export const Config = ({ original, update, disabled, disableSidebar, set, onSave, components, selector, titleOther, file_contents_language, }: { original: T; update: Partial; disabled: boolean; disableSidebar?: boolean; set: React.Dispatch>>; onSave: () => Promise; selector?: ReactNode; titleOther?: ReactNode; components: Record< string, // sidebar key ConfigComponent[] | false | undefined >; file_contents_language?: MonacoLanguage; }) => { const sections = keys(components).filter((section) => !!components[section]); const changesMade = Object.keys(update).length ? true : false; const onConfirm = async () => { await onSave(); set({}); }; const onReset = () => set({}); return (
{!disableSidebar && ( )}
{sections.map( (section) => components[section] && (
{section &&

{section}

}
{section && (

{section}

)}
{components[section].map( ({ label, boldLabel = true, labelHidden, icon, labelExtra, actions, description, hidden, contentHidden, components, }) => ( ) )}
) )} {changesMade && (
Unsaved changes
)}
); }; export const ConfigAgain = < T extends Types.Resource["config"], >({ config, update, disabled, components, set, }: { config: T; update: Partial; disabled: boolean; components: Partial<{ [K in keyof T extends string ? keyof T : never]: | boolean | PrimitiveConfigArgs | ((value: T[K], set: (value: Partial) => void) => ReactNode); }>; set: (value: Partial) => void; }) => { return ( <> {keys(components).map((key) => { const component = components[key]; const value = update[key] ?? config[key]; if (typeof component === "function") { return ( {component(value, set)} ); } else if (typeof component === "object" || component === true) { const args = typeof component === "object" ? (component as PrimitiveConfigArgs) : undefined; switch (typeof value) { case "string": return ( set({ [key]: value } as Partial)} disabled={disabled} placeholder={args?.placeholder} description={args?.description} boldLabel={args?.boldLabel} /> ); case "number": return ( set({ [key]: Number(value) } as Partial) } disabled={disabled} placeholder={args?.placeholder} description={args?.description} boldLabel={args?.boldLabel} /> ); case "boolean": return ( set({ [key]: value } as Partial)} disabled={disabled} description={args?.description} boldLabel={args?.boldLabel} /> ); default: return (
{args?.label ?? key.toString()}
); } } else { return ; } })} ); }; ================================================ FILE: frontend/src/components/config/linked_repo.tsx ================================================ import { ResourceLink, ResourceSelector } from "@components/resources/common"; import { ConfigItem } from "./util"; export const LinkedRepoConfig = ({ linked_repo, repo_linked, set, disabled, }: { linked_repo: string | undefined; repo_linked: boolean; set: (update: { linked_repo: string; // Set other props back to default. git_provider: string; git_account: string; git_https: boolean; repo: string; branch: string; commit: string; }) => void; disabled: boolean; }) => { return ( Repo: ) : ( "Select Repo" ) } description={`Select an existing Repo to attach${!repo_linked ? ", or configure the repo below" : ""}.`} > set({ linked_repo, // Set other props back to default. git_provider: "github.com", git_account: "", git_https: true, repo: linked_repo ? "" : "namespace/repo", branch: "main", commit: "", }) } disabled={disabled} align="start" /> ); }; ================================================ FILE: frontend/src/components/config/maintenance.tsx ================================================ import { Button } from "@ui/button"; import { Input } from "@ui/input"; import { Switch } from "@ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@ui/dialog"; import { Badge } from "@ui/badge"; import { DataTable, SortableHeader } from "@ui/data-table"; import { Types } from "komodo_client"; import { useState } from "react"; import { PlusCircle, Pen, Trash2, Clock, Calendar, CalendarDays, } from "lucide-react"; import { TimezoneSelector } from "@components/util"; export const MaintenanceWindows = ({ windows, onUpdate, disabled, }: { windows: Types.MaintenanceWindow[]; onUpdate: (windows: Types.MaintenanceWindow[]) => void; disabled: boolean; }) => { const [isCreating, setIsCreating] = useState(false); const [editingWindow, setEditingWindow] = useState< [number, Types.MaintenanceWindow] | null >(null); const addWindow = (newWindow: Types.MaintenanceWindow) => { onUpdate([...windows, newWindow]); setIsCreating(false); }; const updateWindow = ( index: number, updatedWindow: Types.MaintenanceWindow ) => { onUpdate(windows.map((w, i) => (i === index ? updatedWindow : w))); setEditingWindow(null); }; const deleteWindow = (index: number) => { onUpdate(windows.filter((_, i) => i !== index)); }; const toggleWindow = (index: number, enabled: boolean) => { onUpdate(windows.map((w, i) => (i === index ? { ...w, enabled } : w))); }; return (
{!disabled && ( setIsCreating(false)} /> )} {windows.length > 0 && ( ( ), cell: ({ row }) => (
{row.original.name}
), size: 200, }, { accessorKey: "schedule_type", header: ({ column }) => ( ), cell: ({ row }) => ( ), size: 150, }, { accessorKey: "start_time", header: ({ column }) => ( ), cell: ({ row }) => ( {formatTime(row.original)} ), size: 180, }, { accessorKey: "duration_minutes", header: ({ column }) => ( ), cell: ({ row }) => ( {row.original.duration_minutes} min ), size: 100, }, { accessorKey: "enabled", header: ({ column }) => ( ), cell: ({ row }) => (
{row.original.enabled ? "Enabled" : "Disabled"} {!disabled && ( toggleWindow(row.index, enabled) } /> )}
), size: 120, }, { id: "actions", header: "Actions", cell: ({ row }) => !disabled && (
), size: 100, }, ]} /> )} {editingWindow && ( setEditingWindow(null)} > updateWindow(editingWindow[0], window)} onCancel={() => setEditingWindow(null)} /> )}
); }; const ScheduleIcon = ({ scheduleType, }: { scheduleType: Types.MaintenanceScheduleType; }) => { switch (scheduleType) { case "Daily": return ; case "Weekly": return ; case "OneTime": return ; default: return ; } }; const ScheduleDescription = ({ window, }: { window: Types.MaintenanceWindow; }): string => { switch (window.schedule_type) { case "Daily": return "Daily"; case "Weekly": return `Weekly (${window.day_of_week || "Monday"})`; case "OneTime": return `One-time (${window.date || "No date"})`; default: return "Unknown"; } }; const formatTime = (window: Types.MaintenanceWindow) => { const hours = window.hour!.toString().padStart(2, "0"); const minutes = window.minute!.toString().padStart(2, "0"); return `${hours}:${minutes} ${window.timezone ? `(${window.timezone})` : ""}`; }; interface MaintenanceWindowFormProps { initialData?: Types.MaintenanceWindow; onSave: (window: Types.MaintenanceWindow) => void; onCancel: () => void; } const MaintenanceWindowForm = ({ initialData, onSave, onCancel, }: MaintenanceWindowFormProps) => { const [formData, setFormData] = useState( initialData || { name: "", description: "", schedule_type: Types.MaintenanceScheduleType.Daily, day_of_week: "", date: "", hour: 5, minute: 0, timezone: "", duration_minutes: 60, enabled: true, } ); const [errors, setErrors] = useState>({}); const validate = (): boolean => { const newErrors: Record = {}; if (!formData.name.trim()) { newErrors.name = "Name is required"; } if (formData.hour! < 0 || formData.hour! > 23) { newErrors.hour = "Hour must be between 0 and 23"; } if (formData.minute! < 0 || formData.minute! > 59) { newErrors.minute = "Minute must be between 0 and 59"; } if (formData.duration_minutes <= 0) { newErrors.duration = "Duration must be greater than 0"; } if (formData.schedule_type && formData.schedule_type === "OneTime") { const date = formData.date; if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { newErrors.date = "Date must be in YYYY-MM-DD format"; } } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const handleSave = () => { if (validate()) { onSave(formData); } }; const updateScheduleType = (schedule_type: Types.MaintenanceScheduleType) => { setFormData((data) => ({ ...data, schedule_type, day_of_week: schedule_type === Types.MaintenanceScheduleType.Weekly ? "Monday" : "", date: schedule_type === Types.MaintenanceScheduleType.OneTime ? new Date().toISOString().split("T")[0] : "", })); }; return ( <> {initialData ? "Edit Maintenance Window" : "Create Maintenance Window"}
setFormData((data) => ({ ...data, name: e.target.value })) } placeholder="e.g., Daily Backup" className={errors.name ? "border-destructive" : ""} /> {errors.name && (

{errors.name}

)}
{formData.schedule_type === "Weekly" && (
)} {formData.schedule_type === "OneTime" && (
setFormData({ ...formData, date: e.target.value, }) } className={errors.date ? "border-destructive" : ""} /> {errors.date && (

{errors.date}

)}
)}
{ const [hour, minute] = e.target.value .split(":") .map((n) => parseInt(n) || 0); setFormData({ ...formData, hour, minute, }); }} className={ errors.hour || errors.minute ? "border-destructive" : "" } /> {(errors.hour || errors.minute) && (

{errors.hour || errors.minute}

)}
setFormData((data) => ({ ...data, timezone })) } triggerClassName="w-full" />
setFormData((data) => ({ ...data, duration_minutes: parseInt(e.target.value) || 60, })) } className={errors.duration ? "border-destructive" : ""} /> {errors.duration && (

{errors.duration}

)}
setFormData((data) => ({ ...data, description: e.target.value })) } placeholder="e.g., Automated backup process" />
); }; ================================================ FILE: frontend/src/components/config/util.tsx ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import { WebhookIdOrName, useCtrlKeyListener, useRead, useWebhookIdOrName, WebhookIntegration, useWebhookIntegrations, useSettingsView, usePromptHotkeys, } from "@lib/hooks"; import { Types } from "komodo_client"; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem, } from "@ui/select"; import { Button } from "@ui/button"; import { Input } from "@ui/input"; import { Switch } from "@ui/switch"; import { CheckCircle, MinusCircle, PlusCircle, Save, Search, SearchX, SquareArrowOutUpRight, } from "lucide-react"; import { ReactNode, useState } from "react"; import { cn, env_to_text, filterBySplit } from "@lib/utils"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@ui/dialog"; import { snake_case_to_upper_space_case } from "@lib/formatting"; import { ConfirmButton, ShowHideButton } from "@components/util"; import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@ui/command"; import { Card, CardContent, CardHeader } from "@ui/card"; import { soft_text_color_class_by_intention, text_color_class_by_intention, } from "@lib/color"; import { MonacoDiffEditor, MonacoEditor, MonacoLanguage, } from "@components/monaco"; import { useNavigate } from "react-router-dom"; import { Badge } from "@ui/badge"; export const ConfigItem = ({ label, boldLabel, description, children, className, }: { label?: ReactNode; boldLabel?: boolean; description?: ReactNode; children: ReactNode; className?: string; }) => (
{(label || description) && (
{label && typeof label === "string" && (
{label.split("_").join(" ")}
)} {label && typeof label !== "string" && label} {description && (
{description}
)}
)} {children}
); export const ConfigInput = ({ label, boldLabel, value, description, disabled, placeholder, onChange, onBlur, className, inputLeft, inputRight, }: { label: string; boldLabel?: boolean; value: string | number | undefined; description?: ReactNode; disabled?: boolean; placeholder?: string; onChange?: (value: string) => void; onBlur?: (value: string) => void; className?: string; inputLeft?: ReactNode; inputRight?: ReactNode; }) => ( {inputLeft || inputRight ? (
{inputLeft} onChange && onChange(e.target.value)} onBlur={(e) => onBlur && onBlur(e.target.value)} placeholder={placeholder} disabled={disabled} /> {inputRight}
) : ( onChange && onChange(e.target.value)} onBlur={(e) => onBlur && onBlur(e.target.value)} placeholder={placeholder} disabled={disabled} /> )}
); export const ConfigSwitch = ({ label, boldLabel, value: checked, description, disabled, onChange, }: { label: string; boldLabel?: boolean; value: boolean | undefined; description?: ReactNode; disabled: boolean; onChange: (value: boolean) => void; }) => (
!disabled && onChange(!checked)} > {/*
DISABLED
*/}
{checked ? "ENABLED" : "DISABLED"}
); export const ConfigList = ( props: InputListProps & { label?: string; addLabel?: string; boldLabel?: boolean; description?: ReactNode; configClassname?: string; } ) => { return ( {!props.disabled && ( )} {props.values.length > 0 && } ); }; export type InputListProps = { field: keyof T; values: string[]; disabled: boolean; set: (update: Partial) => void; placeholder?: string; className?: string; }; export const InputList = ({ field, values, disabled, set, placeholder, className, }: InputListProps) => (
{values.map((arg, i) => (
{ values[i] = e.target.value; set({ [field]: [...values] } as Partial); }} disabled={disabled} className={cn("w-[400px] max-w-full", className)} /> {!disabled && ( )}
))}
); interface ConfirmUpdateProps { previous: T; content: Partial; onConfirm: () => Promise; loading?: boolean; disabled: boolean; language?: MonacoLanguage; file_contents_language?: MonacoLanguage; key_listener?: boolean; } export function ConfirmUpdate({ previous, content, onConfirm, loading, disabled, language, file_contents_language, key_listener = false, }: ConfirmUpdateProps) { const [open, set] = useState(false); const handleConfirm = async () => { await onConfirm(); set(false); }; const handleCancel = () => { set(false); }; // Keep the existing Ctrl+Enter behavior for backward compatibility useCtrlKeyListener("Enter", () => { if (!key_listener) return; if (open) { handleConfirm(); } else { set(true); } }); // Add new prompt hotkeys for better UX usePromptHotkeys({ onConfirm: handleConfirm, onCancel: handleCancel, enabled: open, confirmDisabled: disabled || loading, }); return ( Confirm Update
{Object.entries(content).map(([key, val], i) => ( ))}
} onClick={handleConfirm} loading={loading} />
); } function ConfirmUpdateItem({ _key, val: _val, previous, language, file_contents_language, }: { _key: keyof T; val: T[keyof T]; previous: T; language?: MonacoLanguage; file_contents_language?: MonacoLanguage; }) { const [show, setShow] = useState(true); const val = typeof _val === "string" ? _val : Array.isArray(_val) ? _val.length > 0 && ["string", "number", "boolean"].includes(typeof _val[0]) ? JSON.stringify(_val) : JSON.stringify(_val, null, 2) : JSON.stringify(_val, null, 2); const prev_val = typeof previous[_key] === "string" ? previous[_key] : _key === "environment" || _key === "build_args" || _key === "secret_args" ? (env_to_text(previous[_key] as any) ?? "") // For backward compat with 1.14 : Array.isArray(previous[_key]) ? previous[_key].length > 0 && ["string", "number", "boolean"].includes(typeof previous[_key][0]) ? JSON.stringify(previous[_key]) : JSON.stringify(previous[_key], null, 2) : JSON.stringify(previous[_key], null, 2); const showDiff = val?.includes("\n") || prev_val?.includes("\n") || Math.max(val?.length ?? 0, prev_val?.length ?? 0) > 30; return (

{snake_case_to_upper_space_case(_key as string)}

{show && ( {showDiff ? ( ) : (
                
                  {prev_val || "None"}
                {" "}
                {"->"}{" "}
                
                  {val || "None"}
                
              
)}
)}
); } export const SystemCommand = ({ value, disabled, set, }: { value?: Types.SystemCommand; disabled: boolean; set: (value: Types.SystemCommand) => void; }) => { return (
Path:
set({ ...(value || {}), path: e.target.value })} disabled={disabled} />
set({ ...(value || {}), command })} readOnly={disabled} />
); }; export const AddExtraArgMenu = ({ onSelect, type, disabled, }: { onSelect: (suggestion: string) => void; type: "Deployment" | "Build" | "Stack" | "StackBuild"; disabled?: boolean; }) => { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const suggestions = useRead(`ListCommon${type}ExtraArgs`, {}).data ?? []; const filtered = filterBySplit(suggestions, search, (item) => item); if (suggestions.length === 0) { return ( ); } return ( No Suggestions Found { onSelect(""); setOpen(false); }} className="w-full cursor-pointer" > Empty Extra Arg {filtered?.map((suggestion) => ( { onSelect(suggestion); setOpen(false); }} className="w-full overflow-hidden overflow-ellipsis cursor-pointer" > {suggestion} ))} ); }; export const ImageRegistryConfig = ({ registry, setRegistry, disabled, builder_id, onRemove, imageName, }: { registry: Types.ImageRegistryConfig | undefined; setRegistry: (registry: Types.ImageRegistryConfig) => void; disabled: boolean; builder_id: string | undefined; onRemove: () => void; imageName: string | undefined; }) => { // This is the only way to get organizations for now const config_provider = useRead("ListDockerRegistriesFromConfig", { target: builder_id ? { type: "Builder", id: builder_id } : undefined, }).data?.find((provider) => { return provider.domain === registry?.domain; }); const organizations = config_provider?.organizations ?? []; const namespace = registry?.organization || registry?.account; return (
Pushes to:
{" "} {registry?.domain && registry.domain + " / "} {registry?.domain && (namespace ? namespace : "") + " / "} {imageName} {!registry?.domain && Local} } >
setRegistry({ ...registry, domain, }) } showCustom={false} showLabel /> setRegistry({ ...registry, account, }) } disabled={!registry?.domain || disabled} showLabel /> setRegistry({ ...registry, organization, }) } disabled={disabled} showLabel /> {!disabled && ( )}
); }; export const ProviderSelector = ({ disabled, account_type, selected, onSelect, showCustom = true, showLabel, }: { disabled: boolean; account_type: "git" | "docker"; selected: string | undefined; onSelect: (provider: string) => void; showCustom?: boolean; showLabel?: boolean; }) => { const [db_request, config_request]: | ["ListGitProviderAccounts", "ListGitProvidersFromConfig"] | ["ListDockerRegistryAccounts", "ListDockerRegistriesFromConfig"] = account_type === "git" ? ["ListGitProviderAccounts", "ListGitProvidersFromConfig"] : ["ListDockerRegistryAccounts", "ListDockerRegistriesFromConfig"]; const db_providers = useRead(db_request, {}).data; const config_providers = useRead(config_request, {}).data; const [customMode, setCustomMode] = useState(false); if (customMode) { return ( onSelect(e.target.value)} className="max-w-[75%] lg:max-w-[400px]" onBlur={() => setCustomMode(false)} onKeyDown={(e) => { if (e.key === "Enter") { setCustomMode(false); } }} autoFocus /> ); } const domains = new Set(); for (const provider of db_providers ?? []) { domains.add(provider.domain); } for (const provider of config_providers ?? []) { domains.add(provider.domain); } const providers = [...domains]; providers.sort(); return ( ); }; export const ProviderSelectorConfig = (params: { disabled: boolean; account_type: "git" | "docker"; selected: string | undefined; onSelect: (id: string) => void; https?: boolean; onHttpsSwitch?: () => void; description?: string; boldLabel?: boolean; }) => { const select = params.account_type === "git" ? "git provider" : "docker registry"; const label = params.account_type === "git" ? "Git Provider" : "Image Registry"; return ( {params.account_type === "git" ? (
) : ( )}
); }; export const AccountSelector = ({ disabled, id, type, account_type, provider, selected, onSelect, placeholder = "Select Account", showLabel, }: { disabled: boolean; type: "Server" | "Builder" | "None"; id?: string; account_type: "git" | "docker"; provider: string; selected: string | undefined; onSelect: (id: string) => void; placeholder?: string; showLabel?: boolean; }) => { const [db_request, config_request]: | ["ListGitProviderAccounts", "ListGitProvidersFromConfig"] | ["ListDockerRegistryAccounts", "ListDockerRegistriesFromConfig"] = account_type === "git" ? ["ListGitProviderAccounts", "ListGitProvidersFromConfig"] : ["ListDockerRegistryAccounts", "ListDockerRegistriesFromConfig"]; const config_params = type === "None" ? {} : { target: id ? { type, id } : undefined }; const db_accounts = useRead(db_request, {}).data?.filter( (account) => account.domain === provider ); const config_providers = useRead(config_request, config_params).data?.filter( (_provider) => _provider.domain === provider ); const _accounts = new Set(); for (const account of db_accounts ?? []) { if (account.username) { _accounts.add(account.username); } } for (const provider of config_providers ?? []) { for (const account of provider.accounts ?? []) { _accounts.add(account.username); } } const accounts = [..._accounts]; accounts.sort(); return ( ); }; export const AccountSelectorConfig = (params: { disabled: boolean; id?: string; type: "Server" | "Builder" | "None"; account_type: "git" | "docker"; provider: string; selected: string | undefined; onSelect: (id: string) => void; placeholder?: string; description?: string; }) => { return ( ); }; const OrganizationSelector = ({ organizations, selected, set, disabled, showLabel, }: { organizations: string[]; selected: string; set: (org: string) => void; disabled: boolean; showLabel?: boolean; }) => { const [customMode, setCustomMode] = useState(false); if (customMode) { return ( set(e.target.value)} className="max-w-[75%] lg:max-w-[400px]" onBlur={() => setCustomMode(false)} onKeyDown={(e) => { if (e.key === "Enter") { setCustomMode(false); } }} autoFocus /> ); } const orgs = selected === "" || organizations.includes(selected) ? organizations : [...organizations, selected]; orgs.sort(); return ( ); }; export const SecretSelector = ({ keys, onSelect, type, disabled, align = "start", side = "right", }: { keys: string[]; onSelect: (key: string) => void; type: "Variable" | "Secret"; disabled: boolean; align?: "start" | "center" | "end"; side?: "bottom" | "right"; }) => { const nav = useNavigate(); const [_, setSettingsView] = useSettingsView(); const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const filtered = filterBySplit(keys, search, (item) => item).sort((a, b) => { if (a > b) { return 1; } else if (a < b) { return -1; } else { return 0; } }); return ( {`No ${type}s Found`} {filtered.map((key) => ( { onSelect(key); setOpen(false); }} className="flex items-center justify-between cursor-pointer" >
{key}
))} { setOpen(false); setSettingsView("Variables"); nav("/settings"); }} className="flex items-center justify-between cursor-pointer" >
All
); }; export const WebhookBuilder = ({ git_provider, children, }: { git_provider: string; children?: ReactNode; }) => { return (
Auth style?
Resource Id or Name?
{children}
); }; /** Should call `useWebhookIntegrations` in util/hooks to get the current value */ export const WebhookIntegrationSelector = ({ git_provider, }: { git_provider: string; }) => { const { integrations, setIntegration } = useWebhookIntegrations(); const integration = integrations[git_provider] ? integrations[git_provider] : git_provider === "gitlab.com" ? "Gitlab" : "Github"; return ( ); }; /** Should call `useWebhookIdOrName` in util/hooks to get the current value */ export const WebhookIdOrNameSelector = () => { const [idOrName, setIdOrName] = useWebhookIdOrName(); return ( ); }; ================================================ FILE: frontend/src/components/export.tsx ================================================ import { useRead } from "@lib/hooks"; import { Types } from "komodo_client"; import { Button } from "@ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, } from "@ui/dialog"; import { FileDown, Loader2 } from "lucide-react"; import { useState } from "react"; import { CopyButton } from "./util"; import { MonacoEditor } from "./monaco"; export const ExportButton = ({ targets, user_groups, tags, include_variables, }: { targets?: Types.ResourceTarget[]; user_groups?: string[]; tags?: string[]; include_variables?: boolean; }) => { const [open, setOpen] = useState(false); return ( Export to Toml {targets || user_groups || include_variables ? ( ) : ( )} ); }; const ExportTargetsLoader = ({ targets, user_groups, include_variables, }: { targets?: Types.ResourceTarget[]; user_groups?: string[]; include_variables?: boolean; }) => { const { data, isPending } = useRead("ExportResourcesToToml", { targets: targets ? targets : [], user_groups: user_groups ? user_groups : [], include_variables, }); return ; }; const ExportAllLoader = ({ tags, }: { tags?: string[]; }) => { const { data, isPending } = useRead("ExportAllResourcesToToml", { tags, include_resources: true, include_variables: true, include_user_groups: true, }); return ; }; const ExportPre = ({ loading, content, }: { loading: boolean; content: string | undefined; }) => { return (
{loading && }
); }; ================================================ FILE: frontend/src/components/group-actions.tsx ================================================ import { useSelectedResources, useExecute, useWrite } from "@lib/hooks"; import { UsableResource } from "@types"; import { Button } from "@ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "@ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@ui/dropdown-menu"; import { Input } from "@ui/input"; import { Types } from "komodo_client"; import { ChevronDown, CheckCircle } from "lucide-react"; import { useState } from "react"; import { ConfirmButton } from "./util"; import { useToast } from "@ui/use-toast"; import { usableResourceExecuteKey } from "@lib/utils"; export const GroupActions = < T extends Types.ExecuteRequest["type"] | Types.WriteRequest["type"], >({ type, actions, }: { type: UsableResource; actions: T[]; }) => { const [action, setAction] = useState(); const [selected] = useSelectedResources(type); return ( <> setAction(undefined)} /> ); }; const GroupActionDropdownMenu = < T extends Types.ExecuteRequest["type"] | Types.WriteRequest["type"], >({ type, actions, onSelect, disabled, }: { type: UsableResource; actions: T[]; onSelect: (item: T) => void; disabled: boolean; }) => ( {type === "ResourceSync" && ( onSelect("RefreshResourceSyncPending" as any)} > )} {actions.map((action) => ( onSelect(action)}> ))} onSelect(`Delete${type}` as any)}> ); const GroupActionDialog = ({ type, action, onClose, }: { type: UsableResource; action: | (Types.ExecuteRequest["type"] | Types.WriteRequest["type"]) | undefined; onClose: () => void; }) => { const { toast } = useToast(); const [selected, setSelected] = useSelectedResources(type); const [text, setText] = useState(""); const { mutate: execute, isPending: executePending } = useExecute( action! as Types.ExecuteRequest["type"], { onSuccess: onClose, } ); const { mutate: write, isPending: writePending } = useWrite( action! as Types.WriteRequest["type"], { onSuccess: onClose, } ); if (!action) return; const formatted = action.replaceAll("Batch", "").replaceAll(type, ""); const isPending = executePending || writePending; return ( !o && onClose()}> Group Execute - {formatted}
    {selected.map((resource) => (
  • {resource}
  • ))}
{!action.startsWith("Refresh") && ( <>

{ navigator.clipboard.writeText(formatted); toast({ title: `Copied "${formatted}" to clipboard!` }); }} className="cursor-pointer" > Please enter {formatted} below to confirm this action.
You may click the action in bold to copy it

setText(e.target.value)} /> )}
} onClick={() => { for (const resource of selected) { if (action.startsWith("Delete")) { write({ id: resource } as any); } else if (action.startsWith("Refresh")) { write({ [usableResourceExecuteKey(type)]: resource } as any); } else { execute({ [usableResourceExecuteKey(type)]: resource, } as any); } } if (action.startsWith("Delete")) { setSelected([]); } }} disabled={action.startsWith("Refresh") ? false : text !== formatted} loading={isPending} />
); }; ================================================ FILE: frontend/src/components/inspect.tsx ================================================ import { Types } from "komodo_client"; import { Loader2 } from "lucide-react"; import { MonacoEditor } from "./monaco"; export const InspectContainerView = ({ container, error, isPending, isError, }: { container: Types.Container | undefined; error: unknown; isPending: boolean; isError: boolean; }) => { if (isPending) { return (
); } if (isError) { return (

Failed to inspect container.

{(error ?? undefined) && ( )}
); } return (
); }; ================================================ FILE: frontend/src/components/keys/table.tsx ================================================ import { CopyButton } from "@components/util"; import { Types } from "komodo_client"; import { DataTable } from "@ui/data-table"; import { Input } from "@ui/input"; import { ReactNode } from "react"; const ONE_DAY_MS = 1000 * 60 * 60 * 24; export const KeysTable = ({ keys, DeleteKey, }: { keys: Types.ApiKey[]; DeleteKey: (params: { api_key: string }) => ReactNode; }) => { return ( { return (
); }, }, { header: "Expires", accessorFn: ({ expires }) => expires ? "In " + ((expires - Date.now()) / ONE_DAY_MS).toFixed() + " Days" : "Never", }, { header: "Delete", cell: ({ row }) => , }, ]} /> ); }; ================================================ FILE: frontend/src/components/layouts.tsx ================================================ import { Button } from "@ui/button"; import { PlusCircle } from "lucide-react"; import { ReactNode, useState } from "react"; import { Link, Outlet, useNavigate } from "react-router-dom"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@ui/dialog"; import { Types } from "komodo_client"; import { ResourceComponents } from "./resources"; import { Card, CardHeader, CardTitle, CardContent, CardFooter } from "@ui/card"; import { ResourceTags } from "./tags"; import { Topbar } from "./topbar"; import { cn, usableResourcePath } from "@lib/utils"; import { Sidebar } from "./sidebar"; import { ResourceNameSimple } from "./resources/common"; import { useShiftKeyListener } from "@lib/hooks"; export const Layout = () => { const nav = useNavigate(); useShiftKeyListener("H", () => nav("/")); useShiftKeyListener("G", () => nav("/servers")); useShiftKeyListener("Z", () => nav("/stacks")); useShiftKeyListener("D", () => nav("/deployments")); useShiftKeyListener("B", () => nav("/builds")); useShiftKeyListener("R", () => nav("/repos")); useShiftKeyListener("P", () => nav("/procedures")); return ( <>
); }; interface PageProps { title?: ReactNode; icon?: ReactNode; titleRight?: ReactNode; titleOther?: ReactNode; children?: ReactNode; subtitle?: ReactNode; actions?: ReactNode; superHeader?: ReactNode; } export const Page = ({ superHeader, title, icon, titleRight, titleOther, subtitle, actions, children, }: PageProps) => (
{superHeader ? (
{superHeader} {(title || icon || subtitle || actions) && (
{icon}

{title}

{titleRight}
{subtitle}
{actions}
)}
) : ( (title || icon || subtitle || actions) && (
{icon}

{title}

{titleRight}
{subtitle}
{actions}
) )} {titleOther} {children}
); export const PageXlRow = ({ superHeader, title, icon, titleRight, titleOther, subtitle, actions, children, }: PageProps) => (
{superHeader ? (
{superHeader} {(title || icon || subtitle || actions) && (
{icon}

{title}

{titleRight}
{subtitle}
{actions}
)}
) : ( (title || icon || subtitle || actions) && (
{icon}

{title}

{titleRight}
{subtitle}
{actions}
) )} {titleOther} {children}
); interface SectionProps { title?: ReactNode; icon?: ReactNode; titleRight?: ReactNode; titleOther?: ReactNode; children?: ReactNode; actions?: ReactNode; // otherwise items-start itemsCenterTitleRow?: boolean; className?: string; } export const Section = ({ title, icon, titleRight, titleOther, actions, children, itemsCenterTitleRow, className, }: SectionProps) => (
{(title || icon || titleRight || titleOther || actions) && (
{title || icon ? (
{icon} {title &&

{title}

} {titleRight}
) : ( titleOther )} {actions}
)} {children}
); export const NewLayout = ({ entityType, children, enabled, onConfirm, onOpenChange, configureLabel = "a unique name", }: { entityType: string; children: ReactNode; enabled: boolean; onConfirm: () => Promise; onOpenChange?: (open: boolean) => void; configureLabel?: string; }) => { const [open, set] = useState(false); const [loading, setLoading] = useState(false); return ( { set(open); onOpenChange && onOpenChange(open); }} > New {entityType} Enter {configureLabel} for the new {entityType}.
{children}
); }; export const ResourceCard = ({ target: { type, id }, }: { target: Exclude; }) => { const Components = ResourceComponents[type]; return (
{/* */}
{Object.entries(Components.Info).map(([key, Info]) => ( ))}
); }; export const ResourceRow = ({ target: { type, id }, }: { target: Exclude; }) => { const Components = ResourceComponents[type]; return ( {Object.entries(Components.Info).map(([key, Info]) => ( ))}
{/* */}
); }; ================================================ FILE: frontend/src/components/log.tsx ================================================ import { logToHtml } from "@lib/utils"; import { Types } from "komodo_client"; import { Button } from "@ui/button"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { AlertOctagon, ChevronDown, RefreshCw, ScrollText, X, } from "lucide-react"; import { ReactNode, useEffect, useRef, useState } from "react"; import { Section } from "./layouts"; import { Switch } from "@ui/switch"; import { Input } from "@ui/input"; import { ToggleGroup, ToggleGroupItem } from "@ui/toggle-group"; import { useToast } from "@ui/use-toast"; import { useLocalStorage } from "@lib/hooks"; export type LogStream = "stdout" | "stderr"; export const LogSection = ({ regular_logs, search_logs, titleOther, extraParams, }: { regular_logs: ( timestamps: boolean, stream: LogStream, tail: number, poll: boolean ) => { Log: ReactNode; refetch: () => void; stderr: boolean; }; search_logs: ( timestamps: boolean, terms: string[], invert: boolean, poll: boolean ) => { Log: ReactNode; refetch: () => void; stderr: boolean }; titleOther?: ReactNode; extraParams?: ReactNode; }) => { const { toast } = useToast(); const [timestamps, setTimestamps] = useLocalStorage( "log-timestamps-v1", false ); const [stream, setStream] = useState("stdout"); const [tail, set] = useState("100"); const [terms, setTerms] = useState([]); const [invert, setInvert] = useState(false); const [search, setSearch] = useState(""); const [poll, setPoll] = useLocalStorage("log-poll-v1", false); const addTerm = () => { if (!search.length) return; if (terms.includes(search)) { toast({ title: "Search term is already present" }); setSearch(""); return; } setTerms([...terms, search]); setSearch(""); }; const clearSearch = () => { setSearch(""); setTerms([]); }; const { Log, refetch, stderr } = terms.length ? search_logs(timestamps, terms, invert, poll) : regular_logs(timestamps, stream, Number(tail), poll); return (
} titleOther={titleOther} itemsCenterTitleRow actions={
Invert
{terms.map((term, index) => ( ))}
setSearch(e.target.value)} onBlur={addTerm} onKeyDown={(e) => { if (e.key === "Enter") addTerm(); }} className="w-[180px] xl:w-[240px]" />
stdout stderr {stderr && ( )}
setTimestamps((t) => !t)} >
Timestamps
setPoll((p) => !p)} >
Poll
0} /> {extraParams}
} > {Log}
); }; export const Log = ({ log, stream, }: { log: Types.Log | undefined; stream: "stdout" | "stderr"; }) => { const _log = log?.[stream as keyof typeof log] as string | undefined; const ref = useRef(null); const scroll = () => ref.current?.scroll({ top: ref.current.scrollHeight, behavior: "smooth", }); useEffect(scroll, [_log]); return ( <>
      
); }; export const TailLengthSelector = ({ selected, onSelect, disabled, }: { selected: string; onSelect: (value: string) => void; disabled?: boolean; }) => ( ); ================================================ FILE: frontend/src/components/monaco.tsx ================================================ import { useEffect, useState } from "react"; import { DiffEditor, Editor } from "@monaco-editor/react"; import { useTheme } from "@ui/theme"; import { cn } from "@lib/utils"; import * as monaco from "monaco-editor"; import * as prettier from "prettier/standalone"; import * as pluginTypescript from "prettier/plugins/typescript"; import * as pluginEsTree from "prettier/plugins/estree"; import * as pluginYaml from "prettier/plugins/yaml"; import { useRead, useWindowDimensions } from "@lib/hooks"; const MIN_EDITOR_HEIGHT = 56; export type MonacoLanguage = | "yaml" | "toml" | "fancy_toml" | "json" | "key_value" | "ini" | "string_list" | "shell" | "dockerfile" | "rust" | "javascript" | "typescript"; const LANGUAGE_EXTENSIONS: Record = { yaml: [".yaml", ".yml"], toml: [".toml"], fancy_toml: [], json: [".json"], key_value: [".env", ".conf"], ini: [".ini"], string_list: [], shell: [".sh", ".bash", ".zsh"], dockerfile: ["Dockerfile"], rust: [".rs"], javascript: [".js", ".jsx", ".mjs", ".cjs"], typescript: [".ts", ".tsx"], }; export const language_from_path = (path: string) => { for (const [lang, extensions] of Object.entries(LANGUAGE_EXTENSIONS)) { for (const extension of extensions) { if (path.endsWith(extension)) { return lang as MonacoLanguage; } } } return undefined; }; export const MonacoEditor = ({ value, onValueChange, language: _language, readOnly, filename, minHeight, className, }: { value: string | undefined; onValueChange?: (value: string) => void; language: MonacoLanguage | undefined; filename?: string; readOnly?: boolean; minHeight?: number; className?: string; }) => { const enable_fancy_toml = useRead("GetCoreInfo", {}).data?.enable_fancy_toml ?? false; const language = ( _language === "fancy_toml" && !enable_fancy_toml ? "toml" : _language ) as MonacoLanguage; const dimensions = useWindowDimensions(); const [editor, setEditor] = useState(null); useEffect(() => { if (!editor) return; let node = editor.getDomNode(); if (!node) return; const callback = (e: any) => { if (e.key === "Escape") { (document.activeElement as any)?.blur?.(); } }; node.addEventListener("keydown", callback); return () => node.removeEventListener("keydown", callback); }, [editor]); useEffect(() => { if ( language !== "typescript" && language !== "javascript" && language !== "yaml" ) return; if (!editor) return; editor.addCommand( monaco.KeyMod.Alt | monaco.KeyMod.Shift | monaco.KeyCode.KeyF, async () => { if (!editor) return; const model = editor.getModel(); if (!model) return; const position = editor.getPosition(); let beforeOffset = (position && model.getOffsetAt(position)) ?? 0; const curr = editor.getValue(); const { formatted, cursorOffset } = await prettier.formatWithCursor( curr, { cursorOffset: beforeOffset, parser: language === "yaml" ? "yaml" : "typescript", plugins: language === "yaml" ? [pluginYaml] : [pluginTypescript, pluginEsTree as any], printWidth: 80, // Set the desired max line length } ); editor.setValue(formatted); editor.setPosition(model.getPositionAt(cursorOffset)); } ); }, [editor]); const line_count = value?.split(/\r\n|\r|\n/).length ?? 0; useEffect(() => { if (!editor) return; const contentHeight = line_count * 18 + 30; const containerNode = editor.getContainerDomNode(); containerNode.style.height = `${Math.max( Math.min(contentHeight, Math.floor(dimensions.height * 0.5)), minHeight ?? MIN_EDITOR_HEIGHT )}px`; }, [editor, line_count]); const { currentTheme } = useTheme(); const options: monaco.editor.IStandaloneEditorConstructionOptions = { minimap: { enabled: false }, // scrollbar: { alwaysConsumeMouseWheel: false }, scrollBeyondLastLine: false, folding: false, automaticLayout: true, renderValidationDecorations: "on", renderLineHighlightOnlyWhenFocus: true, readOnly, tabSize: 2, detectIndentation: true, quickSuggestions: true, padding: { top: 15, }, }; return (
onValueChange?.(v ?? "")} onMount={(editor) => setEditor(editor)} />
); }; const defaultPath = (filename?: string) => { if (!filename) return undefined; // Extract only the filename part of path, // avoiding critical issue when path starts with '/' const split = filename.split("/"); return split[split.length - 1]; }; const MIN_DIFF_HEIGHT = 100; const MAX_DIFF_HEIGHT = 400; export const MonacoDiffEditor = ({ original, modified, onModifiedValueChange, language: _language, readOnly, containerClassName, hideUnchangedRegions = true, }: { original: string | undefined; modified: string | undefined; onModifiedValueChange?: (value: string) => void; language: MonacoLanguage | undefined; readOnly?: boolean; containerClassName?: string; hideUnchangedRegions?: boolean; }) => { const enable_fancy_toml = useRead("GetCoreInfo", {}).data?.enable_fancy_toml ?? false; const language = ( _language === "fancy_toml" && !enable_fancy_toml ? "toml" : _language ) as MonacoLanguage; const [editor, setEditor] = useState(null); const original_line_count = original?.split(/\r\n|\r|\n/).length ?? 0; const modified_line_count = modified?.split(/\r\n|\r|\n/).length ?? 0; const line_count = Math.max(original_line_count, modified_line_count); useEffect(() => { if (!editor) return; const contentHeight = line_count * 18 + 30; const node = editor.getContainerDomNode(); node.style.height = `${Math.max( Math.min(contentHeight, MAX_DIFF_HEIGHT), MIN_DIFF_HEIGHT )}px`; }, [editor, line_count]); const { currentTheme } = useTheme(); const options: monaco.editor.IStandaloneDiffEditorConstructionOptions = { minimap: { enabled: true }, scrollbar: { alwaysConsumeMouseWheel: false }, scrollBeyondLastLine: false, hideUnchangedRegions: { enabled: hideUnchangedRegions }, folding: false, automaticLayout: true, renderValidationDecorations: "on", renderLineHighlightOnlyWhenFocus: true, readOnly, padding: { top: 15, }, }; return (
{ const modifiedEditor = editor.getModifiedEditor(); modifiedEditor.onDidChangeModelContent((_) => { onModifiedValueChange?.(modifiedEditor.getValue()); }); setEditor(editor); }} />
); }; ================================================ FILE: frontend/src/components/omnibar.tsx ================================================ import { useAllResources, useLocalStorage, useRead, useSettingsView, useUser } from "@lib/hooks"; import { Button } from "@ui/button"; import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandList, CommandSeparator, CommandItem, } from "@ui/command"; import { Box, Home, Search, User } from "lucide-react"; import { Fragment, ReactNode, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { cn, RESOURCE_TARGETS, usableResourcePath } from "@lib/utils"; import { Badge } from "@ui/badge"; import { ResourceComponents } from "./resources"; import { Switch } from "@ui/switch"; import { DOCKER_LINK_ICONS, TemplateMarker } from "./util"; import { UsableResource } from "@types"; export const OmniSearch = ({ className, setOpen, }: { className?: string; setOpen: (open: boolean) => void; }) => { return ( ); }; type OmniItem = { key: string; type: UsableResource; label: string; icon: ReactNode; template: boolean; onSelect: () => void; }; export const OmniDialog = ({ open, setOpen, }: { open: boolean; setOpen: (open: boolean) => void; }) => { const [search, setSearch] = useState(""); const navigate = useNavigate(); const nav = (value: string) => { setOpen(false); navigate(value); }; const items = useOmniItems(nav, search); const [showContainers, setShowContainers] = useLocalStorage( "omni-show-containers", false ); return (
Show containers
No results found. {Object.entries(items) .filter(([_, items]) => items.length > 0) .map(([key, items], i) => ( {i !== 0 && } {items.map(({ key, type, label, icon, onSelect, template }) => ( {icon} {label} {template && } ))} ))} {showContainers && ( setOpen(false)} /> )}
); }; const useOmniItems = ( nav: (path: string) => void, search: string ): Record => { const user = useUser().data; const resources = useAllResources(); const [_, setSettingsView] = useSettingsView(); return useMemo(() => { const searchTerms = search .toLowerCase() .split(" ") .filter((term) => term); return { "": [ { key: "Home", type: "Server" as UsableResource, label: "Home", icon: , onSelect: () => nav("/"), template: false, }, ...RESOURCE_TARGETS.map((_type) => { const type = _type === "ResourceSync" ? "Sync" : _type; const Components = ResourceComponents[_type]; return { key: type + "s", type: _type, label: type + "s", icon: , onSelect: () => nav(usableResourcePath(_type)), template: false, }; }), { key: "Containers", type: "Server" as UsableResource, label: "Containers", icon: , onSelect: () => nav("/containers"), template: false, }, (user?.admin && { key: "Users", type: "Server" as UsableResource, label: "Users", icon: , onSelect: () => { setSettingsView("Users"); nav("/settings"); }, template: false, }) as OmniItem, ] .filter((item) => item) .filter((item) => { const label = item.label.toLowerCase(); return ( searchTerms.length === 0 || searchTerms.every((term) => label.includes(term)) ); }), ...Object.fromEntries( RESOURCE_TARGETS.map((_type) => { const type = _type === "ResourceSync" ? "Sync" : _type; const lower_type = type.toLowerCase(); const Components = ResourceComponents[_type]; return [ type + "s", resources[_type] ?.filter((resource) => { const lower_name = resource.name.toLowerCase(); return ( searchTerms.length === 0 || searchTerms.every( (term) => lower_name.includes(term) || lower_type.includes(term) ) ); }) .map((resource) => ({ key: type + "-" + resource.name, type: _type, label: resource.name, icon: , onSelect: () => nav(`/${usableResourcePath(_type)}/${resource.id}`), template: resource.template, })) || [], ]; }) ), }; }, [user, resources, search]); }; const OmniContainers = ({ search, closeSearch, }: { search: string; closeSearch: () => void; }) => { const _containers = useRead("ListAllDockerContainers", {}).data; const containers = useMemo(() => { return _containers?.filter((c) => { const searchTerms = search .toLowerCase() .split(" ") .filter((term) => term); if (searchTerms.length === 0) return true; const lower = c.name.toLowerCase(); return searchTerms.every( (term) => lower.includes(term) || "containers".includes(term) ); }); }, [_containers, search]); const navigate = useNavigate(); if ((containers?.length ?? 0) < 1) return null; return ( <> {containers?.map((container) => ( { closeSearch(); navigate( `/servers/${container.server_id!}/container/${container.name}` ); }} > {container.name} ))} ); }; ================================================ FILE: frontend/src/components/resources/action/config.tsx ================================================ import { useLocalStorage, usePermissions, useRead, useWebhookIdOrName, useWebhookIntegrations, useWrite, } from "@lib/hooks"; import { Types } from "komodo_client"; import { Config } from "@components/config"; import { MonacoEditor } from "@components/monaco"; import { SecretsSearch } from "@components/config/env_vars"; import { Button } from "@ui/button"; import { ConfigItem, ConfigSwitch, WebhookBuilder, } from "@components/config/util"; import { Input } from "@ui/input"; import { useState } from "react"; import { CopyWebhook } from "../common"; import { ActionInfo } from "./info"; import { Switch } from "@ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { TimezoneSelector } from "@components/util"; import { snake_case_to_upper_space_case } from "@lib/formatting"; const ACTION_GIT_PROVIDER = "Action"; export const ActionConfig = ({ id }: { id: string }) => { const [branch, setBranch] = useState("main"); const { canWrite } = usePermissions({ type: "Action", id }); const action = useRead("GetAction", { action: id }).data; const config = action?.config; const name = action?.name; const global_disabled = useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false; const [update, set] = useLocalStorage>( `action-${id}-update-v1`, {} ); const { mutateAsync } = useWrite("UpdateAction"); const { integrations } = useWebhookIntegrations(); const [id_or_name] = useWebhookIdOrName(); if (!config) return null; const disabled = global_disabled || !canWrite; const webhook_integration = integrations[ACTION_GIT_PROVIDER] ?? "Github"; return ( { await mutateAsync({ id, config: update }); }} components={{ "": [ { label: "Action File", description: "Manage the action file contents here.", components: { file_contents: (file_contents, set) => { return (
Docs:
{["read", "execute", "write"].map((api) => ( ))}
set({ file_contents })} language="typescript" readOnly={disabled} />
); }, }, }, { label: "Arguments", description: "Manage the action file default arguments.", components: { arguments: (args, set) => { const format = update.arguments_format ?? config.arguments_format ?? Types.FileFormat.KeyValue; return (
set({ arguments: args })} language={ update.arguments_format ?? config.arguments_format ?? Types.FileFormat.KeyValue } readOnly={disabled} />
); }, }, }, { label: "Alert", labelHidden: true, components: { failure_alert: { boldLabel: true, description: "Send an alert any time the Procedure fails", }, }, }, { label: "Schedule", description: "Configure the Procedure to run at defined times using English or CRON.", components: { schedule_enabled: (schedule_enabled, set) => ( set({ schedule_enabled })} /> ), schedule_format: (schedule_format, set) => ( ), schedule: { label: "Expression", description: (update.schedule_format ?? config.schedule_format) === "Cron" ? (
second - minute - hour - day - month - day-of-week
) : (
Examples: - Run every day at 4:00 pm - Run at 21:00 on the 1st and 15th of the month - Every Sunday at midnight
), placeholder: (update.schedule_format ?? config.schedule_format) === "Cron" ? "0 0 0 ? * SUN" : "Enter English expression", }, schedule_timezone: (timezone, set) => { return ( set({ schedule_timezone }) } disabled={disabled} /> ); }, schedule_alert: { description: "Send an alert when the scheduled run occurs", }, }, }, { label: "Startup", labelHidden: true, components: { run_at_startup: { label: "Run on Startup", description: "Run this action on completion of startup of Komodo Core", }, }, }, { label: "Reload", labelHidden: true, components: { reload_deno_deps: { label: "Reload Dependencies", description: "Whether deno will be instructed to reload all dependencies. This can usually be kept disabled outside of development.", }, }, }, { label: "Webhook", description: `Copy the webhook given here, and configure your ${webhook_integration}-style repo provider to send webhooks to Komodo`, components: { ["Builder" as any]: () => (
Listen on branch:
setBranch(e.target.value)} className="w-[200px]" disabled={branch === "__ANY__"} />
No branch check:
{ if (checked) { setBranch("__ANY__"); } else { setBranch("main"); } }} />
), ["run" as any]: () => ( ), webhook_enabled: true, webhook_secret: { description: "Provide a custom webhook secret for this resource, or use the global default.", placeholder: "Input custom secret", }, }, }, ], }} /> ); }; const default_arguments = (format: Types.FileFormat) => { switch (format) { case Types.FileFormat.KeyValue: return "# ARG_NAME = value\n"; case Types.FileFormat.Toml: return '# ARG_NAME = "value"\n'; case Types.FileFormat.Yaml: return "# ARG_NAME: value\n"; case Types.FileFormat.Json: return "{}\n"; } }; ================================================ FILE: frontend/src/components/resources/action/index.tsx ================================================ import { ActionWithDialog, StatusBadge } from "@components/util"; import { useExecute, useRead } from "@lib/hooks"; import { RequiredResourceComponents } from "@types"; import { Clapperboard, Clock } from "lucide-react"; import { ActionConfig } from "./config"; import { ActionTable } from "./table"; import { DeleteResource, NewResource, ResourcePageHeader } from "../common"; import { action_state_intention, stroke_color_class_by_intention, } from "@lib/color"; import { cn, updateLogToHtml } from "@lib/utils"; import { Types } from "komodo_client"; import { DashboardPieChart } from "@pages/home/dashboard"; import { GroupActions } from "@components/group-actions"; import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip"; import { Card } from "@ui/card"; const useAction = (id?: string) => useRead("ListActions", {}).data?.find((d) => d.id === id); const ActionIcon = ({ id, size }: { id?: string; size: number }) => { const state = useAction(id)?.info.state; const color = stroke_color_class_by_intention(action_state_intention(state)); return ; }; export const ActionComponents: RequiredResourceComponents = { list_item: (id) => useAction(id), resource_links: () => undefined, Description: () => <>Custom scripts using the Komodo client., Dashboard: () => { const summary = useRead("GetActionsSummary", {}).data; return ( ); }, New: () => , GroupActions: () => , Table: ({ resources }) => ( ), Icon: ({ id }) => , BigIcon: ({ id }) => , State: ({ id }) => { let state = useAction(id)?.info.state; return ; }, Status: {}, Info: { Schedule: ({ id }) => { const next_scheduled_run = useAction(id)?.info.next_scheduled_run; return (
Next Run:
{next_scheduled_run ? new Date(next_scheduled_run).toLocaleString() : "Not Scheduled"}
); }, ScheduleErrors: ({ id }) => { const error = useAction(id)?.info.schedule_error; if (!error) { return null; } return (
Schedule Error
          
        
      );
    },
  },

  Actions: {
    RunAction: ({ id }) => {
      const running =
        (useRead(
          "GetActionActionState",
          { action: id },
          { refetchInterval: 5000 }
        ).data?.running ?? 0) > 0;
      const { mutate, isPending } = useExecute("RunAction");
      const action = useAction(id);
      if (!action) return null;
      return (
        }
          onClick={() => mutate({ action: id, args: {} })}
          disabled={running || isPending}
          loading={running}
        />
      );
    },
  },

  Page: {},

  Config: ActionConfig,

  DangerZone: ({ id }) => ,

  ResourcePageHeader: ({ id }) => {
    const action = useAction(id);
    return (
      }
        type="Action"
        id={id}
        resource={action}
        state={action?.info.state}
        status={undefined}
      />
    );
  },
};


================================================
FILE: frontend/src/components/resources/action/info.tsx
================================================
import { Section } from "@components/layouts";
import { Card, CardContent, CardHeader } from "@ui/card";
import { cn, getUpdateQuery, updateLogToHtml } from "@lib/utils";
import { useRead } from "@lib/hooks";
import { text_color_class_by_intention } from "@lib/color";

export const ActionInfo = ({ id }: { id: string }) => {
  const update = useRead("ListUpdates", {
    query: {
      ...getUpdateQuery({ type: "Action", id }, undefined),
      operation: "RunAction",
    },
  }).data?.updates[0];

  const full_update = useRead(
    "GetUpdate",
    { id: update?.id! },
    { enabled: !!update?.id }
  ).data;

  const log = full_update?.logs.find((log) => log.stage === "Execute Action");

  if (!log?.stdout && !log?.stderr) {
    return (
      
Never run
); } return (
{/* Last run */} {log?.stdout && ( Last run -
Stdout
          
        
      )}
      {log?.stderr && (
        
          
            Last run -
            
Stderr
          
        
      )}
    
); }; ================================================ FILE: frontend/src/components/resources/action/table.tsx ================================================ import { DataTable, SortableHeader } from "@ui/data-table"; import { TableTags } from "@components/tags"; import { ResourceLink } from "../common"; import { ActionComponents } from "."; import { Types } from "komodo_client"; import { useSelectedResources } from "@lib/hooks"; export const ActionTable = ({ actions, }: { actions: Types.ActionListItem[]; }) => { const [_, setSelectedResources] = useSelectedResources("Action"); return ( name, onSelect: setSelectedResources, }} columns={[ { accessorKey: "name", header: ({ column }) => ( ), cell: ({ row }) => ( ), }, { accessorKey: "info.state", header: ({ column }) => ( ), cell: ({ row }) => , }, { accessorKey: "info.next_scheduled_run", header: ({ column }) => ( ), sortingFn: (a, b) => { const sa = a.original.info.next_scheduled_run; const sb = b.original.info.next_scheduled_run; if (!sa && !sb) return 0; if (!sa) return 1; if (!sb) return -1; if (sa > sb) return 1; else if (sa < sb) return -1; else return 0; }, cell: ({ row }) => row.original.info.next_scheduled_run ? new Date(row.original.info.next_scheduled_run).toLocaleString() : "Not Scheduled", }, { header: "Tags", cell: ({ row }) => , }, ]} /> ); }; ================================================ FILE: frontend/src/components/resources/alerter/config/alert_types.tsx ================================================ import { ConfigItem } from "@components/config/util"; import { Types } from "komodo_client"; import { Badge } from "@ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger } from "@ui/select"; import { MinusCircle } from "lucide-react"; const ALERT_TYPES: Types.AlertData["type"][] = [ // Server "ServerVersionMismatch", "ServerUnreachable", "ServerCpu", "ServerMem", "ServerDisk", // Stack "StackStateChange", "StackImageUpdateAvailable", "StackAutoUpdated", // Deployment "ContainerStateChange", "DeploymentImageUpdateAvailable", "DeploymentAutoUpdated", // Misc "ScheduleRun", "BuildFailed", "ResourceSyncPendingUpdates", "RepoBuildFailed", "ActionFailed", "ProcedureFailed", "AwsBuilderTerminationFailed", "Custom", ]; export const AlertTypeConfig = ({ alert_types, set, disabled, }: { alert_types: Types.AlertData["type"][]; set: (alert_types: Types.AlertData["type"][]) => void; disabled: boolean; }) => { const at = ALERT_TYPES.filter( (alert_type) => !alert_types.includes(alert_type), ); return (
{at.length ? ( ) : undefined}
{alert_types.map((type) => ( { if (disabled) return; set(alert_types.filter((t) => t !== type)); }} > {type} {!disabled && } ))}
); }; ================================================ FILE: frontend/src/components/resources/alerter/config/endpoint.tsx ================================================ import { ConfigItem } from "@components/config/util"; import { MonacoEditor } from "@components/monaco"; import { Types } from "komodo_client"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { Input } from "@ui/input"; const ENDPOINT_TYPES: Types.AlerterEndpoint["type"][] = [ "Custom", "Discord", "Slack", "Ntfy", "Pushover", ]; export const EndpointConfig = ({ endpoint, set, disabled, }: { endpoint: Types.AlerterEndpoint; set: (endpoint: Types.AlerterEndpoint) => void; disabled: boolean; }) => { return ( set({ ...endpoint, params: { ...endpoint.params, url } }) } readOnly={disabled} /> {endpoint.type == "Ntfy" ? ( set({ ...endpoint, params: { ...endpoint.params, email: input.target.value }, }) } > ) : ( "" )} ); }; const default_url = (type: Types.AlerterEndpoint["type"]) => { return type === "Custom" ? "http://localhost:7000" : type === "Slack" ? "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX" : type === "Discord" ? "https://discord.com/api/webhooks/XXXXXXXXXXXX/XXXX-XXXXXXXXXX" : type === "Ntfy" ? "https://ntfy.sh/komodo" : type === "Pushover" ? "https://api.pushover.net/1/messages.json?token=XXXXXXXXXXXXX&user=XXXXXXXXXXXXX" : ""; }; ================================================ FILE: frontend/src/components/resources/alerter/config/index.tsx ================================================ import { Config } from "@components/config"; import { useLocalStorage, usePermissions, useRead, useWrite } from "@lib/hooks"; import { Types } from "komodo_client"; import { EndpointConfig } from "./endpoint"; import { AlertTypeConfig } from "./alert_types"; import { ResourcesConfig } from "./resources"; import { MaintenanceWindows } from "@components/config/maintenance"; export const AlerterConfig = ({ id }: { id: string }) => { const { canWrite } = usePermissions({ type: "Alerter", id }); const config = useRead("GetAlerter", { alerter: id }).data?.config; const global_disabled = useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false; const { mutateAsync } = useWrite("UpdateAlerter"); const [update, set] = useLocalStorage>( `alerter-${id}-update-v1`, {} ); if (!config) return null; const disabled = global_disabled || !canWrite; return ( { await mutateAsync({ id, config: update }); }} components={{ "": [ { label: "Enabled", labelHidden: true, components: { enabled: { boldLabel: true, description: "Whether to send alerts to the endpoint.", }, }, }, { label: "Endpoint", labelHidden: true, components: { endpoint: (endpoint, set) => ( set({ endpoint })} disabled={disabled} /> ), }, }, { label: "Filter", labelHidden: true, components: { alert_types: (alert_types, set) => ( set({ alert_types })} disabled={disabled} /> ), resources: (resources, set) => ( set({ resources })} disabled={disabled} blacklist={false} /> ), except_resources: (resources, set) => ( set({ except_resources })} disabled={disabled} blacklist={true} /> ), }, }, { label: "Maintenance", boldLabel: false, description: ( <> Configure maintenance windows to temporarily disable alerts during scheduled maintenance periods. When a maintenance window is active, alerts which would be sent by this alerter will be suppressed. ), components: { maintenance_windows: (values, set) => { return ( set({ maintenance_windows }) } disabled={disabled} /> ); }, }, }, ], }} /> ); }; ================================================ FILE: frontend/src/components/resources/alerter/config/resources.tsx ================================================ import { ConfigItem } from "@components/config/util"; import { ResourceComponents } from "@components/resources"; import { ResourceLink } from "@components/resources/common"; import { useRead } from "@lib/hooks"; import { resource_name } from "@lib/utils"; import { Types } from "komodo_client"; import { UsableResource } from "@types"; import { Button } from "@ui/button"; import { DataTable, SortableHeader } from "@ui/data-table"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTrigger, } from "@ui/dialog"; import { Input } from "@ui/input"; import { Switch } from "@ui/switch"; import { useState } from "react"; export const ResourcesConfig = ({ resources, set, disabled, blacklist, }: { resources: Types.ResourceTarget[]; set: (resources: Types.ResourceTarget[]) => void; disabled: boolean; blacklist: boolean; }) => { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const servers = useRead("ListServers", {}).data ?? []; const stacks = useRead("ListStacks", {}).data ?? []; const deployments = useRead("ListDeployments", {}).data ?? []; const builds = useRead("ListBuilds", {}).data ?? []; const repos = useRead("ListRepos", {}).data ?? []; const syncs = useRead("ListResourceSyncs", {}).data ?? []; const all_resources = [ ...servers.map((server) => { return { type: "Server", id: server.id, name: server.name.toLowerCase(), enabled: resources.find( (r) => r.type === "Server" && r.id === server.id ) ? true : false, }; }), ...stacks.map((stack) => { return { type: "Stack", id: stack.id, name: stack.name.toLowerCase(), enabled: resources.find((r) => r.type === "Stack" && r.id === stack.id) ? true : false, }; }), ...deployments.map((deployment) => ({ type: "Deployment", id: deployment.id, name: deployment.name.toLowerCase(), enabled: resources.find( (r) => r.type === "Deployment" && r.id === deployment.id ) ? true : false, })), ...builds.map((build) => ({ type: "Build", id: build.id, name: build.name.toLowerCase(), enabled: resources.find((r) => r.type === "Build" && r.id === build.id) ? true : false, })), ...repos.map((repo) => ({ type: "Repo", id: repo.id, name: repo.name.toLowerCase(), enabled: resources.find((r) => r.type === "Repo" && r.id === repo.id) ? true : false, })), ...syncs.map((sync) => ({ type: "ResourceSync", id: sync.id, name: sync.name.toLowerCase(), enabled: resources.find( (r) => r.type === "ResourceSync" && r.id === sync.id ) ? true : false, })), ]; const searchSplit = search.split(" "); const filtered_resources = searchSplit.length ? all_resources.filter((r) => { const name = r.name.toLowerCase(); return searchSplit.every((term) => name.includes(term)); }) : all_resources; return (
Alerter Resources
setSearch(e.target.value)} placeholder="Search..." className="w-[200px] lg:w-[300px]" />
( ), cell: ({ row }) => { const Components = ResourceComponents[ row.original.type as UsableResource ]; return (
{row.original.type}
); }, }, { accessorKey: "id", sortingFn: (a, b) => { const ra = resource_name( a.original.type as UsableResource, a.original.id ); const rb = resource_name( b.original.type as UsableResource, b.original.id ); if (!ra && !rb) return 0; if (!ra) return -1; if (!rb) return 1; if (ra > rb) return 1; else if (ra < rb) return -1; else return 0; }, header: ({ column }) => ( ), cell: ({ row: { original: resource_target } }) => { return ( ); }, }, { accessorKey: "enabled", header: ({ column }) => ( ), cell: ({ row }) => { return ( { if (row.original.enabled) { set( resources.filter( (r) => r.type !== row.original.type || r.id !== row.original.id ) ); } else { set([ ...resources, { type: row.original.type as UsableResource, id: row.original.id, }, ]); } }} /> ); }, }, ]} />
{resources.length ? (
Alerts {blacklist ? "blacklisted" : "whitelisted"} by{" "} {resources.length} resources
) : undefined}
); }; ================================================ FILE: frontend/src/components/resources/alerter/index.tsx ================================================ import { useExecute, useRead, useUser } from "@lib/hooks"; import { RequiredResourceComponents } from "@types"; import { AlarmClock, FlaskConical } from "lucide-react"; import { Link } from "react-router-dom"; import { Card, CardDescription, CardHeader, CardTitle } from "@ui/card"; import { AlerterConfig } from "./config"; import { DeleteResource, NewResource, ResourcePageHeader } from "../common"; import { AlerterTable } from "./table"; import { Types } from "komodo_client"; import { ConfirmButton } from "@components/util"; import { GroupActions } from "@components/group-actions"; const useAlerter = (id?: string) => useRead("ListAlerters", {}).data?.find((d) => d.id === id); export const AlerterComponents: RequiredResourceComponents = { list_item: (id) => useAlerter(id), resource_links: () => undefined, Description: () => <>Route alerts to various endpoints., Dashboard: () => { const alerters_count = useRead("ListAlerters", {}).data?.length; return (
Alerters {alerters_count} Total
); }, New: () => { const is_admin = useUser().data?.admin; return is_admin && ; }, GroupActions: () => , Table: ({ resources }) => ( ), Icon: () => , BigIcon: () => , State: () => null, Status: {}, Info: { Type: ({ id }) => { const alerter = useAlerter(id); return (
Type: {alerter?.info.endpoint_type}
); }, }, Actions: { TestAlerter: ({ id }) => { const { mutate, isPending } = useExecute("TestAlerter"); const alerter = useAlerter(id); if (!alerter) return null; return ( } loading={isPending} onClick={() => mutate({ alerter: id })} disabled={isPending} /> ); }, }, Page: {}, Config: AlerterConfig, DangerZone: ({ id }) => , ResourcePageHeader: ({ id }) => { const alerter = useAlerter(id); return ( } type="Alerter" id={id} resource={alerter} state={alerter?.info.enabled ? "Enabled" : "Disabled"} status={alerter?.info.endpoint_type} /> ); }, }; ================================================ FILE: frontend/src/components/resources/alerter/table.tsx ================================================ import { DataTable, SortableHeader } from "@ui/data-table"; import { ResourceLink } from "../common"; import { TableTags } from "@components/tags"; import { Types } from "komodo_client"; import { useSelectedResources } from "@lib/hooks"; export const AlerterTable = ({ alerters, }: { alerters: Types.AlerterListItem[]; }) => { const [_, setSelectedResources] = useSelectedResources("Alerter"); return ( name, onSelect: setSelectedResources, }} columns={[ { accessorKey: "name", header: ({ column }) => ( ), cell: ({ row }) => ( ), }, { accessorKey: "info.endpoint_type", header: ({ column }) => ( ), }, { accessorKey: "info.enabled", header: ({ column }) => ( ), }, { header: "Tags", cell: ({ row }) => , }, ]} /> ); }; ================================================ FILE: frontend/src/components/resources/build/actions.tsx ================================================ import { ConfirmButton } from "@components/util"; import { useExecute, usePermissions, useRead } from "@lib/hooks"; import { Types } from "komodo_client"; import { Ban, Hammer } from "lucide-react"; import { useBuilder } from "../builder"; export const RunBuild = ({ id }: { id: string }) => { const { canExecute } = usePermissions({ type: "Build", id }); const building = useRead( "GetBuildActionState", { build: id }, { refetchInterval: 5_000 } ).data?.building; const updates = useRead("ListUpdates", { query: { "target.type": "Build", "target.id": id, }, }).data; const { mutate: run_mutate, isPending: runPending } = useExecute("RunBuild"); const { mutate: cancel_mutate, isPending: cancelPending } = useExecute("CancelBuild"); const build = useRead("ListBuilds", {}).data?.find((d) => d.id === id); const builder = useBuilder(build?.info.builder_id); const canCancel = builder?.info.builder_type !== "Server"; // make sure hidden without perms. // not usually necessary, but this button also used in deployment actions. if (!canExecute) return null; // updates come in in descending order, so 'find' will find latest update matching operation const latestBuild = updates?.updates.find( (u) => u.operation === Types.Operation.RunBuild ); const latestCancel = updates?.updates.find( (u) => u.operation === Types.Operation.CancelBuild ); const cancelDisabled = !canCancel || cancelPending || (latestCancel && latestBuild ? latestCancel!.start_ts > latestBuild!.start_ts : false); if (building) { return ( } onClick={() => cancel_mutate({ build: id })} disabled={cancelDisabled} /> ); } else { return ( } loading={runPending} onClick={() => run_mutate({ build: id })} disabled={runPending} /> ); } }; ================================================ FILE: frontend/src/components/resources/build/chart.tsx ================================================ // import { // ColorType, // IChartApi, // ISeriesApi, // Time, // createChart, // } from "lightweight-charts"; // import { useEffect, useRef } from "react"; // import { useRead } from "@lib/hooks"; // import { // Card, // CardContent, // CardDescription, // CardHeader, // CardTitle, // } from "@ui/card"; // import { Hammer } from "lucide-react"; // import { Link } from "react-router-dom"; // import { convertTsMsToLocalUnixTsInSecs } from "@lib/utils"; // export const BuildChart = () => { // const container_ref = useRef(null); // const line_ref = useRef(); // const series_ref = useRef>(); // const build_stats = useRead("GetBuildMonthlyStats", {}).data; // const summary = useRead("GetBuildsSummary", {}).data; // const handleResize = () => // line_ref.current?.applyOptions({ // width: container_ref.current?.clientWidth, // }); // useEffect(() => { // if (!build_stats) return; // if (line_ref.current) line_ref.current.remove(); // const init = () => { // if (!container_ref.current) return; // // INIT LINE // line_ref.current = createChart(container_ref.current, { // width: container_ref.current.clientWidth, // height: container_ref.current.clientHeight, // layout: { // background: { type: ColorType.Solid, color: "transparent" }, // textColor: "grey", // fontSize: 12, // }, // grid: { // horzLines: { color: "transparent" }, // vertLines: { color: "transparent" }, // }, // handleScale: false, // handleScroll: false, // }); // line_ref.current.timeScale().fitContent(); // // INIT SERIES // series_ref.current = line_ref.current.addHistogramSeries({ // priceLineVisible: false, // }); // const max = build_stats.days.reduce((m, c) => Math.max(m, c.time), 0); // series_ref.current.setData( // build_stats.days.map((d) => ({ // time: convertTsMsToLocalUnixTsInSecs(d.ts) as Time, // value: d.count, // color: // d.time > max * 0.7 // ? "darkred" // : d.time > max * 0.35 // ? "darkorange" // : "darkgreen", // })) ?? [] // ); // }; // // Run the effect // init(); // window.addEventListener("resize", handleResize); // return () => { // window.removeEventListener("resize", handleResize); // }; // }, [build_stats]); // return ( // // // //
//
// Builds // //
{summary?.total} Total
|{" "} //
{build_stats?.total_time.toFixed(2)} Hours
//
//
// //
//
// //
// // // // ); // }; ================================================ FILE: frontend/src/components/resources/build/config.tsx ================================================ import { Config, ConfigComponent } from "@components/config"; import { AccountSelectorConfig, AddExtraArgMenu, ImageRegistryConfig, ConfigInput, ConfigItem, ConfigList, InputList, ProviderSelectorConfig, SystemCommand, WebhookBuilder, } from "@components/config/util"; import { getWebhookIntegration, useInvalidate, useLocalStorage, usePermissions, useRead, useWebhookIdOrName, useWebhookIntegrations, useWrite, } from "@lib/hooks"; import { Types } from "komodo_client"; import { Ban, CirclePlus, PlusCircle } from "lucide-react"; import { ReactNode } from "react"; import { CopyWebhook, ResourceLink, ResourceSelector } from "../common"; import { useToast } from "@ui/use-toast"; import { text_color_class_by_intention } from "@lib/color"; import { ConfirmButton, ShowHideButton } from "@components/util"; import { Link } from "react-router-dom"; import { SecretsSearch } from "@components/config/env_vars"; import { MonacoEditor } from "@components/monaco"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { LinkedRepoConfig } from "@components/config/linked_repo"; import { Button } from "@ui/button"; type BuildMode = "UI Defined" | "Files On Server" | "Git Repo" | undefined; const BUILD_MODES: BuildMode[] = ["UI Defined", "Files On Server", "Git Repo"]; function getBuildMode( update: Partial, config: Types.BuildConfig ): BuildMode { if (update.files_on_host ?? config.files_on_host) return "Files On Server"; if ( (update.repo ?? config.repo) || (update.linked_repo ?? config.linked_repo) ) return "Git Repo"; if (update.dockerfile ?? config.dockerfile) return "UI Defined"; return undefined; } export const BuildConfig = ({ id, titleOther, }: { id: string; titleOther: ReactNode; }) => { const [show, setShow] = useLocalStorage(`build-${id}-show`, { file: true, git: true, webhooks: true, }); const { canWrite } = usePermissions({ type: "Build", id }); const build = useRead("GetBuild", { build: id }).data; const config = build?.config; const name = build?.name; const webhook = useRead("GetBuildWebhookEnabled", { build: id }).data; const global_disabled = useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false; const [update, set] = useLocalStorage>( `build-${id}-update-v1`, {} ); const { mutateAsync } = useWrite("UpdateBuild"); const { integrations } = useWebhookIntegrations(); const [id_or_name] = useWebhookIdOrName(); if (!config) return null; const disabled = global_disabled || !canWrite; const git_provider = update.git_provider ?? config.git_provider; const webhook_integration = getWebhookIntegration(integrations, git_provider); const mode = getBuildMode(update, config); const setMode = (mode: BuildMode) => { if (mode === "Files On Server") { set({ ...update, files_on_host: true }); } else if (mode === "Git Repo") { set({ ...update, files_on_host: false, repo: update.repo || config.repo || "namespace/repo", }); } else if (mode === "UI Defined") { set({ ...update, files_on_host: false, repo: "", dockerfile: update.dockerfile || config.dockerfile || DEFAULT_BUILD_DOCKERFILE_CONTENTS, }); } else if (mode === undefined) { set({ ...update, files_on_host: false, repo: "", dockerfile: "", }); } }; let components: Record< string, false | ConfigComponent[] | undefined > = {}; const builder_component: ConfigComponent = { label: "Builder", labelHidden: true, components: { builder_id: (builder_id, set) => { return ( Builder:
) : ( "Select Builder" ) } description="Select the Builder to build with." boldLabel > set({ builder_id })} disabled={disabled} align="start" /> ); }, }, }; const version_component: ConfigComponent = { label: "Version", labelHidden: true, components: { version: (_version, set) => { const version = typeof _version === "object" ? `${_version.major}.${_version.minor}.${_version.patch}` : _version; return ( set({ version: version as any })} disabled={disabled} /> ); }, auto_increment_version: { description: "Automatically increment the patch number on every build.", }, }, }; const choose_mode: ConfigComponent = { label: "Choose Mode", labelHidden: true, components: { builder_id: () => { return ( ); }, }, }; const imageName = (update.image_name ?? config.image_name) || name; const customTag = update.image_tag ?? config.image_tag; const customTagPostfix = customTag ? `-${customTag}` : ""; const general_common: ConfigComponent[] = [ { label: "Registry", labelHidden: true, components: { image_registry: (image_registries, set) => (
{!disabled && ( )} {image_registries?.map((registry, index) => ( set({ image_registry: image_registries?.map((r, i) => i === index ? registry : r ) ?? [], }) } onRemove={() => set({ image_registry: image_registries?.filter((_, i) => i !== index) ?? [], }) } builder_id={update.builder_id ?? config.builder_id} disabled={disabled} /> ))}
), }, }, { label: "Tagging", labelHidden: true, components: { image_name: { description: "Push the image under a different name", placeholder: "Custom image name", }, image_tag: { description: `Push a custom tag, plus postfix the other tags (eg ':latest-${customTag ? customTag : ""}').`, placeholder: "Custom image tag", }, include_latest_tag: { description: `:latest${customTagPostfix}`, }, include_version_tags: { description: `:X.Y.Z${customTagPostfix} + :X.Y${customTagPostfix} + :X${customTagPostfix}`, }, include_commit_tag: { description: `:ae8f8ff${customTagPostfix}`, }, }, }, { label: "Links", labelHidden: true, components: { links: (values, set) => ( ), }, }, ]; const advanced: ConfigComponent[] = [ { label: "Pre Build", description: "Execute a shell command before running docker build. The 'path' is relative to the root of the repo.", components: { pre_build: (value, set) => ( set({ pre_build: value })} disabled={disabled} /> ), }, }, { label: "Build Args", description: "Pass build args to 'docker build'. These can be used in the Dockerfile via ARG, and are visible in the final image.", labelExtra: !disabled && , components: { build_args: (env, set) => ( set({ build_args })} language="key_value" readOnly={disabled} /> ), }, }, { label: "Secret Args", description: (
Pass secrets to 'docker build'. These values remain hidden in the final image by using docker secret mounts.
See docker docs.
), labelExtra: !disabled && , components: { secret_args: (env, set) => ( set({ secret_args })} language="key_value" readOnly={disabled} /> ), }, }, { label: "Extra Args", labelHidden: true, components: { extra_args: (value, set) => (
Pass extra arguments to 'docker build'.
See docker docs. } > {!disabled && ( set({ extra_args: [ ...(update.extra_args ?? config.extra_args ?? []), suggestion, ], }) } disabled={disabled} /> )}
), }, }, { label: "Labels", description: "Attach --labels to image.", components: { labels: (labels, set) => ( set({ labels })} readOnly={disabled} /> ), }, }, ]; if (mode === undefined) { components = { "": [builder_component, choose_mode], }; } else if (mode === "Files On Server") { components = { "": [ builder_component, version_component, { label: "Files", components: { build_path: { description: `Set the working directory when running the 'docker build' command. Can be absolute path, or relative to $PERIPHERY_BUILD_DIR/${build.name}`, placeholder: "/path/to/folder", }, dockerfile_path: { description: "The path to the dockerfile, relative to the build path.", placeholder: "Dockerfile", }, }, }, ...general_common, ], advanced, }; } else if (mode === "Git Repo") { const repo_linked = !!(update.linked_repo ?? config.linked_repo); components = { "": [ builder_component, version_component, { label: "Source", contentHidden: !show.git, actions: ( setShow({ ...show, git })} /> ), components: { linked_repo: (linked_repo, set) => ( ), ...(!repo_linked ? { git_provider: (provider, set) => { const https = update.git_https ?? config.git_https; return ( set({ git_provider })} https={https} onHttpsSwitch={() => set({ git_https: !https })} /> ); }, git_account: (account, set) => ( set({ git_account })} disabled={disabled} placeholder="None" /> ), repo: { placeholder: "Enter repo", description: "The repo path on the provider. {namespace}/{repo_name}", }, branch: { placeholder: "Enter branch", description: "Select a custom branch, or default to 'main'.", }, commit: { label: "Commit Hash", placeholder: "Input commit hash", description: "Optional. Switch to a specific commit hash after cloning the branch.", }, } : {}), }, }, { label: "Files", components: { build_path: { description: `The directory to run 'docker build', relative to the root of the repo.`, placeholder: "path/to/folder", }, dockerfile_path: { description: "The path to the dockerfile, relative to the build path.", placeholder: "Dockerfile", }, }, }, ...general_common, { label: "Webhook", description: `Copy the webhook given here, and configure your ${webhook_integration}-style repo provider to send webhooks to Komodo`, contentHidden: !show.webhooks, actions: ( setShow({ ...show, webhooks })} /> ), components: { ["Guard" as any]: () => { if (update.branch ?? config.branch) { return null; } return (
Must configure Branch before webhooks will work.
); }, ["Builder" as any]: () => ( ), ["build" as any]: () => ( ), webhook_enabled: webhook !== undefined && !webhook.managed, webhook_secret: { description: "Provide a custom webhook secret for this resource, or use the global default.", placeholder: "Input custom secret", }, ["managed" as any]: () => { const inv = useInvalidate(); const { toast } = useToast(); const { mutate: createWebhook, isPending: createPending } = useWrite("CreateBuildWebhook", { onSuccess: () => { toast({ title: "Webhook Created" }); inv(["GetBuildWebhookEnabled", { build: id }]); }, }); const { mutate: deleteWebhook, isPending: deletePending } = useWrite("DeleteBuildWebhook", { onSuccess: () => { toast({ title: "Webhook Deleted" }); inv(["GetBuildWebhookEnabled", { build: id }]); }, }); if (!webhook || !webhook.managed) return; return ( {webhook.enabled && (
Incoming webhook is{" "}
ENABLED
} variant="destructive" onClick={() => deleteWebhook({ build: id })} loading={deletePending} disabled={disabled || deletePending} />
)} {!webhook.enabled && (
Incoming webhook is{" "}
DISABLED
} onClick={() => createWebhook({ build: id })} loading={createPending} disabled={disabled || createPending} />
)}
); }, }, }, ], advanced, }; } else if (mode === "UI Defined") { components = { "": [ builder_component, version_component, { label: "Dockerfile", description: "Manage the dockerfile contents here.", contentHidden: !show.file, actions: ( setShow({ ...show, file })} /> ), components: { dockerfile: (dockerfile, set) => { const show_default = !dockerfile && update.dockerfile === undefined && !(update.repo ?? config.repo); return (
set({ dockerfile })} language="dockerfile" readOnly={disabled} />
); }, }, }, ...general_common, ], advanced, }; } return ( { await mutateAsync({ id, config: update }); }} components={components} file_contents_language="dockerfile" /> ); }; export const DEFAULT_BUILD_DOCKERFILE_CONTENTS = `## Add your dockerfile here FROM debian:stable-slim RUN echo 'Hello Komodo' `; ================================================ FILE: frontend/src/components/resources/build/index.tsx ================================================ import { Section } from "@components/layouts"; import { useInvalidate, useLocalStorage, useRead, useUser, useWrite, } from "@lib/hooks"; import { RequiredResourceComponents } from "@types"; import { Factory, FolderGit, Hammer, Loader2, RefreshCcw } from "lucide-react"; import { BuildConfig } from "./config"; import { BuildTable } from "./table"; import { DeleteResource, NewResource, ResourceLink, ResourcePageHeader, StandardSource, } from "../common"; import { DeploymentTable } from "../deployment/table"; import { RunBuild } from "./actions"; import { border_color_class_by_intention, build_state_intention, stroke_color_class_by_intention, } from "@lib/color"; import { cn } from "@lib/utils"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/tabs"; import { ResourceComponents } from ".."; import { Types } from "komodo_client"; import { DashboardPieChart } from "@pages/home/dashboard"; import { StatusBadge } from "@components/util"; import { Card } from "@ui/card"; import { Badge } from "@ui/badge"; import { useToast } from "@ui/use-toast"; import { Button } from "@ui/button"; import { useBuilder } from "../builder"; import { GroupActions } from "@components/group-actions"; import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip"; import { BuildInfo } from "./info"; export const useBuild = (id?: string) => useRead("ListBuilds", {}, { refetchInterval: 10_000 }).data?.find( (d) => d.id === id ); export const useFullBuild = (id: string) => useRead("GetBuild", { build: id }, { refetchInterval: 10_000 }).data; const BuildIcon = ({ id, size }: { id?: string; size: number }) => { const state = useBuild(id)?.info.state; const color = stroke_color_class_by_intention(build_state_intention(state)); return ; }; const ConfigInfoDeployments = ({ id }: { id: string }) => { const [view, setView] = useLocalStorage<"Config" | "Info" | "Deployments">( "build-tabs-v1", "Config" ); const deployments = useRead("ListDeployments", {}).data?.filter( (deployment) => deployment.info.build_id === id ); const deploymentsDisabled = (deployments?.length || 0) === 0; const titleOther = ( Config Info Deployments ); return (
} >
); }; export const BuildComponents: RequiredResourceComponents = { list_item: (id) => useBuild(id), resource_links: (resource) => (resource.config as Types.BuildConfig).links, Description: () => <>Build docker images., Dashboard: () => { const summary = useRead("GetBuildsSummary", {}).data; return ( ); }, New: () => { const user = useUser().data; const builders = useRead("ListBuilders", {}).data; if (!user) return null; if (!user.admin && !user.create_build_permissions) return null; return ( ); }, GroupActions: () => , Table: ({ resources }) => ( ), Icon: ({ id }) => , BigIcon: ({ id }) => , State: ({ id }) => { let state = useBuild(id)?.info.state; return ; }, Info: { Builder: ({ id }) => { const info = useBuild(id)?.info; const builder = useBuilder(info?.builder_id); return builder?.id ? ( ) : (
Unknown Builder
); }, Source: ({ id }) => { const info = useBuild(id)?.info; return ; }, Branch: ({ id }) => { const branch = useBuild(id)?.info.branch; return (
{branch}
); }, }, Status: { Hash: ({ id }) => { const info = useFullBuild(id)?.info; if (!info?.latest_hash) { return null; } const out_of_date = info.built_hash && info.built_hash !== info.latest_hash; return (
{info.built_hash ? "built" : "latest"}:{" "} {info.built_hash || info.latest_hash}
message {info.built_message || info.latest_message} {out_of_date && ( <> latest
{info.latest_hash} : {info.latest_message}
)}
); }, Refresh: ({ id }) => { const { toast } = useToast(); const inv = useInvalidate(); const { mutate, isPending } = useWrite("RefreshBuildCache", { onSuccess: () => { inv(["ListBuilds"], ["GetBuild", { build: id }]); toast({ title: "Refreshed build status cache" }); }, }); return ( ); }, }, Actions: { RunBuild }, Page: {}, Config: ConfigInfoDeployments, DangerZone: ({ id }) => , ResourcePageHeader: ({ id }) => { const build = useBuild(id); return ( } type="Build" id={id} resource={build} state={build?.info.state} status="" /> ); }, }; ================================================ FILE: frontend/src/components/resources/build/info.tsx ================================================ import { Section } from "@components/layouts"; import { ReactNode, useState } from "react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@ui/card"; import { useFullBuild } from "."; import { cn, updateLogToHtml } from "@lib/utils"; import { MonacoEditor } from "@components/monaco"; import { usePermissions } from "@lib/hooks"; import { ConfirmUpdate } from "@components/config/util"; import { useLocalStorage, useRead, useWrite } from "@lib/hooks"; import { Button } from "@ui/button"; import { Clock, FilePlus, History } from "lucide-react"; import { useToast } from "@ui/use-toast"; import { ConfirmButton, ShowHideButton } from "@components/util"; import { DEFAULT_BUILD_DOCKERFILE_CONTENTS } from "./config"; import { fmt_duration } from "@lib/formatting"; export const BuildInfo = ({ id, titleOther, }: { id: string; titleOther: ReactNode; }) => { const [edits, setEdits] = useLocalStorage<{ contents: string | undefined }>( `build-${id}-edits`, { contents: undefined } ); const [showContents, setShowContents] = useState(true); const { canWrite } = usePermissions({ type: "Build", id }); const { toast } = useToast(); const { mutateAsync, isPending } = useWrite("WriteBuildFileContents", { onSuccess: (res) => { toast({ title: res.success ? "Contents written." : "Failed to write contents.", variant: res.success ? undefined : "destructive", }); }, }); const build = useFullBuild(id); const recent_builds = useRead("ListUpdates", { query: { "target.type": "Build", "target.id": id, operation: "RunBuild" }, }).data; const _last_build = recent_builds?.updates[0]; const last_build = useRead( "GetUpdate", { id: _last_build?.id!, }, { enabled: !!_last_build } ).data; const file_on_host = build?.config?.files_on_host ?? false; const git_repo = build?.config?.repo || build?.config?.linked_repo ? true : false; const canEdit = canWrite && (file_on_host || git_repo); const remote_path = build?.info?.remote_path; const remote_contents = build?.info?.remote_contents; const remote_error = build?.info?.remote_error; return (
{/* Errors */} {remote_error && remote_error.length > 0 && (
{remote_path && ( <>
Path:
{remote_path} )}
{canEdit && ( } onClick={() => { if (build) { mutateAsync({ build: build.name, contents: DEFAULT_BUILD_DOCKERFILE_CONTENTS, }); } }} loading={isPending} /> )}
          
        
      )}

      {/* Update latest contents */}
      {remote_contents && remote_contents.length > 0 && (
        
          
            {remote_path && (
              
                
Path:
{remote_path}
)}
{canEdit && ( <> { if (build) { return await mutateAsync({ build: build.name, contents: edits.contents!, }).then(() => setEdits({ contents: undefined })); } }} disabled={!edits.contents} language="dockerfile" loading={isPending} /> )}
{showContents && ( setEdits({ contents })} /> )}
)} {/* Last build output */} {last_build && last_build.logs.length > 0 && ( Last Build Logs )} {last_build && last_build.logs.length > 0 && last_build.logs?.map((log, i) => ( {log.stage} Stage {i + 1} of {last_build.logs.length} | {fmt_duration(log.start_ts, log.end_ts)} {log.command && (
command
                    {log.command}
                  
)} {log.stdout && (
stdout
                
)} {log.stderr && (
stderr
                
)}
))}
); }; ================================================ FILE: frontend/src/components/resources/build/table.tsx ================================================ import { TableTags } from "@components/tags"; import { DataTable, SortableHeader } from "@ui/data-table"; import { fmt_version } from "@lib/formatting"; import { ResourceLink, StandardSource } from "../common"; import { BuildComponents } from "."; import { Types } from "komodo_client"; import { useSelectedResources } from "@lib/hooks"; export const BuildTable = ({ builds }: { builds: Types.BuildListItem[] }) => { const [_, setSelectedResources] = useSelectedResources("Build"); return ( name, onSelect: setSelectedResources, }} columns={[ { header: ({ column }) => ( ), accessorKey: "name", cell: ({ row }) => , size: 200, }, { header: ({ column }) => ( ), accessorKey: "info.repo", cell: ({ row }) => , size: 200, }, { header: "Version", accessorFn: ({ info }) => fmt_version(info.version), size: 120, }, { accessorKey: "info.state", header: ({ column }) => ( ), cell: ({ row }) => , size: 120, }, { header: "Tags", cell: ({ row }) => , }, ]} /> ); }; ================================================ FILE: frontend/src/components/resources/builder/config.tsx ================================================ import { Config } from "@components/config"; import { ConfigItem, ConfigList } from "@components/config/util"; import { useLocalStorage, usePermissions, useRead, useWrite } from "@lib/hooks"; import { Types } from "komodo_client"; import { useState } from "react"; import { ResourceLink, ResourceSelector } from "../common"; import { Button } from "@ui/button"; import { MinusCircle, PlusCircle } from "lucide-react"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTrigger, } from "@ui/dialog"; import { Card } from "@ui/card"; import { cn } from "@lib/utils"; import { Input } from "@ui/input"; import { MonacoEditor } from "@components/monaco"; export const BuilderConfig = ({ id }: { id: string }) => { const config = useRead("GetBuilder", { builder: id }).data?.config; if (config?.type === "Aws") return ; if (config?.type === "Server") return ; if (config?.type === "Url") return ; }; const AwsBuilderConfig = ({ id }: { id: string }) => { const { canWrite } = usePermissions({ type: "Builder", id }); const config = useRead("GetBuilder", { builder: id }).data?.config ?.params as Types.AwsBuilderConfig; const global_disabled = useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false; const [update, set] = useLocalStorage>( `aws-builder-${id}-update-v1`, {} ); const { mutateAsync } = useWrite("UpdateBuilder"); if (!config) return null; const disabled = global_disabled || !canWrite; return ( { await mutateAsync({ id, config: { type: "Aws", params: update } }); }} components={{ "": [ { label: "General", components: { region: { description: "Configure the AWS region to launch the instance in.", placeholder: "Input region", }, instance_type: { description: "Choose the instance type to launch", placeholder: "Input instance type", }, ami_id: { description: "Create an Ami with Docker and Komodo Periphery installed.", placeholder: "Input Ami Id", }, volume_gb: { description: "The size of the disk to attach to the instance.", placeholder: "Input size", }, key_pair_name: { description: "Attach a key pair to the instance", placeholder: "Input key pair name", }, }, }, { label: "Network", components: { subnet_id: { description: "Configure the subnet to launch the instance in.", placeholder: "Input subnet id", }, security_group_ids: (values, set) => ( ), assign_public_ip: { description: "Whether to assign a public IP to the build instance.", }, use_public_ip: { description: "Whether to connect to the instance over the public IP. Otherwise, will use the internal IP.", }, port: { description: "Configure the port to connect to Periphery on.", placeholder: "Input port", }, use_https: { description: "Whether to connect to Periphery using HTTPS.", }, }, }, { label: "User Data", description: "Run a script to setup the instance.", components: { user_data: (user_data, set) => { return ( set({ user_data })} readOnly={disabled} /> ); }, }, }, ], additional: [ { label: "Git Providers", boldLabel: false, description: "If you configured additional git providers / tokens in Periphery config on the builder, add them here so they will be suggested.", components: { git_providers: (providers, set) => providers && ( <> {!disabled && ( )} ), }, }, { label: "Docker Registries", boldLabel: false, description: "If you configured additional registries / tokens in Periphery config on the builder, add them here so they will be suggested.", components: { docker_registries: (providers, set) => providers && ( <> {!disabled && ( )} ), }, }, { label: "Secret Keys", labelHidden: true, components: { secrets: (secrets, set) => ( ), }, }, ], }} /> ); }; const ServerBuilderConfig = ({ id }: { id: string }) => { const { canWrite } = usePermissions({ type: "Builder", id }); const config = useRead("GetBuilder", { builder: id }).data?.config; const [update, set] = useLocalStorage>( `server-builder-${id}-update-v1`, {} ); const { mutateAsync } = useWrite("UpdateBuilder"); if (!config) return null; const disabled = !canWrite; return ( { await mutateAsync({ id, config: { type: "Server", params: update } }); }} components={{ "": [ { label: "Server", labelHidden: true, components: { server_id: (server_id, set) => { return ( Server: ) : ( "Select Server" ) } description="Select the Server to build on." > set({ server_id })} disabled={disabled} align="start" /> ); }, }, }, ], }} /> ); }; const UrlBuilderConfig = ({ id }: { id: string }) => { const { canWrite } = usePermissions({ type: "Builder", id }); const config = useRead("GetBuilder", { builder: id }).data?.config; const [update, set] = useLocalStorage>( `url-builder-${id}-update-v1`, {} ); const { mutateAsync } = useWrite("UpdateBuilder"); if (!config) return null; const disabled = !canWrite; return ( { await mutateAsync({ id, config: { type: "Url", params: update } }); }} components={{ "": [ { label: "General", labelHidden: true, components: { address: { description: "The address of the Periphery agent", placeholder: "https://periphery:8120", }, passkey: { description: "Use a custom passkey to authenticate with Periphery", placeholder: "Custom passkey", }, }, }, ], }} /> ); }; const ProvidersConfig = (params: { type: "git" | "docker"; providers: Types.GitProvider[] | Types.DockerRegistry[]; set: (input: Partial) => void; disabled: boolean; }) => { const arr_field = params.type === "git" ? "git_providers" : "docker_registries"; if (!params.providers.length) return null; return (
{params.providers?.map((_, index) => (
{!params.disabled && ( )}
))}
); }; const ProviderDialog = ({ type, providers, set, disabled, index, }: { type: "git" | "docker"; providers: Types.GitProvider[] | Types.DockerRegistry[]; index: number; set: (input: Partial) => void; disabled: boolean; }) => { const [open, setOpen] = useState(false); const provider = providers[index]; const arr_field = type === "git" ? "git_providers" : "docker_registries"; const example_domain = type === "git" ? "github.com" : "docker.io"; const update_domain = (domain: string) => set({ [arr_field]: providers.map((provider, i) => i === index ? { ...provider, domain } : provider ), }); const add_account = () => set({ [arr_field]: providers.map( (provider: Types.GitProvider | Types.DockerRegistry, i) => i === index ? { ...provider, accounts: [...(provider.accounts ?? []), { username: "" }], } : provider ) as Types.GitProvider[] | Types.DockerRegistry[], }); const update_username = (username: string, account_index: number) => set({ [arr_field]: providers.map( (provider: Types.GitProvider | Types.DockerRegistry, provider_index) => provider_index === index ? { ...provider, accounts: provider.accounts?.map((account, i) => account_index === i ? { username } : account ), } : provider ) as Types.GitProvider[] | Types.DockerRegistry[], }); const remove_account = (account_index) => set({ [arr_field]: providers.map( (provider: Types.GitProvider | Types.DockerRegistry, provider_index) => provider_index === index ? { ...provider, accounts: provider.accounts?.filter( (_, i) => account_index !== i ), } : provider ) as Types.GitProvider[] | Types.DockerRegistry[], }); const add_organization = () => set({ [arr_field]: providers.map((provider: Types.DockerRegistry, i) => i === index ? { ...provider, organizations: [...(provider.organizations ?? []), ""], } : provider ) as Types.DockerRegistry[], }); const update_organization = (name: string, organization_index: number) => set({ [arr_field]: providers.map( (provider: Types.DockerRegistry, provider_index) => provider_index === index ? { ...provider, organizations: provider.organizations?.map((organization, i) => organization_index === i ? name : organization ), } : provider ) as Types.GitProvider[] | Types.DockerRegistry[], }); const remove_organization = (organization_index) => set({ [arr_field]: providers.map( (provider: Types.DockerRegistry, provider_index) => provider_index === index ? { ...provider, organizations: provider.organizations?.filter( (_, i) => organization_index !== i ), } : provider ) as Types.DockerRegistry[], }); return (
{provider.domain}
accounts:
{" "} {provider.accounts?.length || 0}
{(provider as Types.DockerRegistry).organizations !== undefined && (
organizations:
{" "} {(provider as Types.DockerRegistry).organizations?.length || 0}
)}
{type === "git" ? "Git Provider" : "Docker Registry"}
{/* Domain */}
Domain
update_domain(e.target.value)} disabled={disabled} className="w-[300px]" placeholder={example_domain} />
{/* Accounts */}
Available Accounts
{provider.accounts?.map((account, account_index) => { return (
update_username(e.target.value, account_index) } /> {!disabled && ( )}
); })}
{/* Organizations */} {type === "docker" && (
Available Organizations
{(provider as Types.DockerRegistry).organizations?.map( (organization, organization_index) => { return (
update_organization( e.target.value, organization_index ) } placeholder="Organization Name" /> {!disabled && ( )}
); } )}
)}
); }; ================================================ FILE: frontend/src/components/resources/builder/index.tsx ================================================ import { NewLayout } from "@components/layouts"; import { useRead, useUser, useWrite } from "@lib/hooks"; import { Types } from "komodo_client"; import { RequiredResourceComponents } from "@types"; import { Card, CardDescription, CardHeader, CardTitle } from "@ui/card"; import { Input } from "@ui/input"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { Cloud, Bot, Factory } from "lucide-react"; import { ReactNode, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { BuilderConfig } from "./config"; import { DeleteResource, ResourceLink, ResourcePageHeader } from "../common"; import { BuilderTable } from "./table"; import { GroupActions } from "@components/group-actions"; import { useServer } from "../server"; import { cn } from "@lib/utils"; import { ColorIntention, server_state_intention, stroke_color_class_by_intention, } from "@lib/color"; export const useBuilder = (id?: string) => useRead("ListBuilders", {}, { refetchInterval: 10_000 }).data?.find( (d) => d.id === id ); const Icon = ({ id, size }: { id?: string; size: number }) => { const info = useBuilder(id)?.info; if (info?.builder_type === "Server" && info.instance_type) { return ; } else { return ; } }; const ServerIcon = ({ server_id, size, }: { server_id: string; size: number; }) => { const state = useServer(server_id)?.info.state; return ( ); }; export const BuilderInstanceType = ({ id }: { id: string }) => { let info = useBuilder(id)?.info; if (info?.builder_type === "Server") { return ( info.instance_type && ( ) ); } else { return (
{info?.instance_type}
); } }; export const BuilderComponents: RequiredResourceComponents = { list_item: (id) => useBuilder(id), resource_links: () => undefined, Description: () => <>Build on your servers, or single-use AWS instances., Dashboard: () => { const builders_count = useRead("ListBuilders", {}).data?.length; return (
Builders {builders_count} Total
); }, New: () => { const is_admin = useUser().data?.admin; const nav = useNavigate(); const { mutateAsync } = useWrite("CreateBuilder"); const [name, setName] = useState(""); const [type, setType] = useState(); if (!is_admin) return null; return ( { if (!type) return; const id = (await mutateAsync({ name, config: { type, params: {} } })) ._id?.$oid!; nav(`/builders/${id}`); }} enabled={!!name && !!type} >
Name setName(e.target.value)} />
Builder Type
); }, GroupActions: () => , Table: ({ resources }) => ( ), Icon: ({ id }) => , BigIcon: ({ id }) => , State: () => null, Status: {}, Info: { Provider: ({ id }) => { const builder_type = useBuilder(id)?.info.builder_type; return (
{builder_type}
); }, InstanceType: ({ id }) => , }, Actions: {}, Page: {}, Config: BuilderConfig, DangerZone: ({ id }) => , ResourcePageHeader: ({ id }) => { const builder = useBuilder(id); if (builder?.info.builder_type === "Server" && builder.info.instance_type) { return ( ); } return ( } /> ); }, }; const ServerInnerResourcePageHeader = ({ builder, server_id, }: { builder: Types.BuilderListItem; server_id: string; }) => { const state = useServer(server_id)?.info.state; return ( } /> ); }; const InnerResourcePageHeader = ({ id, builder, intent, icon, }: { id: string; builder: Types.BuilderListItem | undefined; intent: ColorIntention; icon: ReactNode; }) => { return ( ); }; ================================================ FILE: frontend/src/components/resources/builder/table.tsx ================================================ import { DataTable, SortableHeader } from "@ui/data-table"; import { ResourceLink } from "../common"; import { TableTags } from "@components/tags"; import { BuilderInstanceType } from "."; import { Types } from "komodo_client"; import { useSelectedResources } from "@lib/hooks"; export const BuilderTable = ({ builders, }: { builders: Types.BuilderListItem[]; }) => { const [_, setSelectedResources] = useSelectedResources("Builder"); return ( name, onSelect: setSelectedResources, }} columns={[ { accessorKey: "name", header: ({ column }) => ( ), cell: ({ row }) => ( ), }, { accessorKey: "info.builder_type", header: ({ column }) => ( ), }, { accessorKey: "info.instance_type", header: ({ column }) => ( ), cell: ({ row }) => , }, { header: "Tags", cell: ({ row }) => , }, ]} /> ); }; ================================================ FILE: frontend/src/components/resources/common.tsx ================================================ import { ActionWithDialog, ConfirmButton, CopyButton, RepoLink, TemplateMarker, TextUpdateMenuSimple, } from "@components/util"; import { useInvalidate, usePermissions, useRead, useWrite, WebhookIntegration, } from "@lib/hooks"; import { UsableResource } from "@types"; import { Button } from "@ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@ui/command"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@ui/dialog"; import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover"; import { Check, ChevronsUpDown, Copy, Edit2, Loader2, NotepadText, SearchX, Server, Trash, X, } from "lucide-react"; import { ReactNode, useEffect, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { ResourceComponents } from "."; import { Input } from "@ui/input"; import { useToast } from "@ui/use-toast"; import { NewLayout } from "@components/layouts"; import { Types } from "komodo_client"; import { cn, filterBySplit, usableResourcePath } from "@lib/utils"; import { ColorIntention, hex_color_by_intention, text_color_class_by_intention, } from "@lib/color"; import { Switch } from "@ui/switch"; import { ResourceListItem } from "komodo_client/dist/types"; import { Badge } from "@ui/badge"; export const ResourcePageHeader = ({ type, id, intent, icon, resource, name, state, status, }: { type: UsableResource | undefined; id: string | undefined; intent: ColorIntention; icon: ReactNode; resource: Types.ResourceListItem | undefined; /** Only pass if not passing resource */ name?: string; state: string | undefined; status: string | undefined; }) => { const color = text_color_class_by_intention(intent); const background = hex_color_by_intention(intent) + "15"; return (
{icon}
{type && id && resource?.name ? ( ) : (

)} {!type && (

{resource?.name ?? name}

)}

{state}

{status}

{type && id && resource && ( )}
); }; const TemplateSwitch = ({ type, id, resource, }: { type: UsableResource; id: string; resource: ResourceListItem; }) => { const { toast } = useToast(); const inv = useInvalidate(); const { canWrite } = usePermissions({ type, id }); const { mutate, isPending } = useWrite("UpdateResourceMeta", { onSuccess: () => { inv([`List${type}s`], [`Get${type}`]); toast({ title: `Updated is template on ${type} ${resource.name}` }); }, }); return (
canWrite && resource && !isPending && mutate({ target: { type, id }, template: !resource.template }) } > Template {isPending ? ( ) : ( )}
); }; const ResourceName = ({ type, id, name, }: { type: UsableResource; id: string; name: string; }) => { const invalidate = useInvalidate(); const { toast } = useToast(); const { canWrite } = usePermissions({ type, id }); const [newName, setName] = useState(""); const [editing, setEditing] = useState(false); const { mutate, isPending } = useWrite(`Rename${type}`, { onSuccess: () => { invalidate([`List${type}s`]); toast({ title: `${type} Renamed` }); setEditing(false); }, onError: () => { // If fails, set name back to original setName(name); }, }); // Ensure the newName is updated if the outer name changes useEffect(() => setName(name), [name]); if (editing) { return (
setName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { if (newName && name !== newName) { mutate({ id, name: newName }); } } else if (e.key === "Escape") { setEditing(false); } }} autoFocus /> {name !== newName && ( )} {name === newName && ( )}
); } else { return (
{ if (canWrite) { setEditing(true); } }} >

{name}

{canWrite && ( )}
); } }; export const ResourceDescription = ({ type, id, disabled, }: { type: UsableResource; id: string; disabled: boolean; }) => { const { toast } = useToast(); const inv = useInvalidate(); const key = type === "ResourceSync" ? "sync" : type.toLowerCase(); const resource = useRead(`Get${type}`, { [key]: id, } as any).data; const { mutate: update_description } = useWrite("UpdateResourceMeta", { onSuccess: () => { inv([`Get${type}`]); toast({ title: `Updated description on ${type} ${resource?.name}` }); }, }); return ( update_description({ target: { type, id }, description, }) } triggerClassName="text-muted-foreground" disabled={disabled} /> ); }; export const ResourceSelector = ({ type, selected, onSelect, disabled, align, templates = Types.TemplatesQueryBehavior.Exclude, placeholder, }: { type: UsableResource; selected: string | undefined; templates?: Types.TemplatesQueryBehavior; onSelect?: (id: string) => void; disabled?: boolean; align?: "start" | "center" | "end"; placeholder?: string; }) => { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const templateFilterFn = templates === Types.TemplatesQueryBehavior.Exclude ? (r: Types.ResourceListItem) => !r.template : templates === Types.TemplatesQueryBehavior.Only ? (r: Types.ResourceListItem) => r.template : () => true; const resources = useRead(`List${type}s`, {}).data?.filter(templateFilterFn); const name = resources?.find((r) => r.id === selected)?.name; if (!resources) return null; const filtered = filterBySplit( resources as Types.ResourceListItem[], search, (item) => item.name ).sort((a, b) => { if (a.name > b.name) { return 1; } else if (a.name < b.name) { return -1; } else { return 0; } }); return ( {`No ${type}s Found`} {!search && ( { onSelect && onSelect(""); setOpen(false); }} className="flex items-center justify-between cursor-pointer" >
None
)} {filtered.map((resource) => ( { onSelect && onSelect(resource.id); setOpen(false); }} className="flex items-center justify-between cursor-pointer" >
{resource.name}
))}
); }; export const ResourceLink = ({ type, id, onClick, }: { type: UsableResource; id: string; onClick?: () => void; }) => { const Components = ResourceComponents[type]; const resource = Components.list_item(id); return ( { e.stopPropagation(); onClick?.(); }} className="flex items-center gap-2 text-sm hover:underline" > {resource?.template && } ); }; export const ResourceNameSimple = ({ type, id, }: { type: UsableResource; id: string; }) => { const Components = ResourceComponents[type]; const name = Components.list_item(id)?.name ?? "unknown"; return <>{name}; }; export const CopyResource = ({ id, disabled, type, }: { id: string; disabled?: boolean; type: Exclude; }) => { const [open, setOpen] = useState(false); const [name, setName] = useState(""); const nav = useNavigate(); const inv = useInvalidate(); const { mutateAsync: copy } = useWrite(`Copy${type}`); const onConfirm = async () => { if (!name) return; try { const res = await copy({ id, name }); inv([`List${type}s`]); nav(`/${usableResourcePath(type)}/${res._id?.$oid}`); setOpen(false); } catch (error: any) { // Keep dialog open for validation errors (409/400), close for system errors const status = error?.status || error?.response?.status; if (status !== 409 && status !== 400) { setOpen(false); } } }; return ( Copy {type}

Provide a name for the newly created {type}.

setName(e.target.value)} />
} disabled={!name} onClick={async () => { await onConfirm(); }} />
); }; export const NewResource = ({ type, readable_type, server_id, builder_id, build_id, name: _name = "", }: { type: UsableResource; readable_type?: string; server_id?: string; builder_id?: string; build_id?: string; name?: string; }) => { const nav = useNavigate(); const { toast } = useToast(); const showTemplateSelector = (useRead(`List${type}s`, {}).data?.filter((r) => r.template).length ?? 0) > 0; const { mutateAsync: create } = useWrite(`Create${type}`); const { mutateAsync: copy } = useWrite(`Copy${type}`); const [templateId, setTemplateId] = useState(""); const [name, setName] = useState(_name); const type_display = type === "ResourceSync" ? "resource-sync" : type.toLowerCase(); const config: Types._PartialDeploymentConfig | Types._PartialRepoConfig = type === "Deployment" ? { server_id, image: build_id ? { type: "Build", params: { build_id } } : { type: "Image", params: { image: "" } }, } : type === "Stack" ? { server_id } : type === "Repo" ? { server_id, builder_id } : type === "Build" ? { builder_id } : {}; const onConfirm = async () => { if (!name) toast({ title: "Name cannot be empty" }); const result = templateId ? await copy({ name, id: templateId }) : await create({ name, config }); const resourceId = result._id?.$oid; if (resourceId) { nav(`/${usableResourcePath(type)}/${resourceId}`); } }; return ( setName(_name)} >
{readable_type ?? type} Name setName(e.target.value)} onKeyDown={(e) => { if (!name) return; if (e.key === "Enter") { onConfirm().catch(() => {}); } }} />
{showTemplateSelector && (
Template
)}
); }; export const DeleteResource = ({ type, id, }: { type: UsableResource; id: string; }) => { const nav = useNavigate(); const key = type === "ResourceSync" ? "sync" : type.toLowerCase(); const resource = useRead(`Get${type}`, { [key]: id, } as any).data; const { mutateAsync, isPending } = useWrite(`Delete${type}`); if (!resource) return null; return (
} onClick={async () => { await mutateAsync({ id }); nav(`/${usableResourcePath(type)}`); }} disabled={isPending} loading={isPending} forceConfirmDialog />
); }; export const CopyWebhook = ({ integration, path, }: { integration: WebhookIntegration; path: string; }) => { const base_url = useRead("GetCoreInfo", {}).data?.webhook_base_url; const url = base_url + "/listener/" + integration.toLowerCase() + path; return (
); }; export const StandardSource = ({ info, }: { info: | { linked_repo: string; files_on_host: boolean; repo: string; repo_link: string; } | undefined; }) => { if (!info) { return ; } if (info.files_on_host) { return (
Files on Server
); } if (info.linked_repo) { return ; } if (info.repo) { return ; } return (
UI Defined
); }; ================================================ FILE: frontend/src/components/resources/deployment/actions.tsx ================================================ import { ActionWithDialog, ConfirmButton } from "@components/util"; import { Play, Trash, Pause, Rocket, RefreshCcw, Square, Download, } from "lucide-react"; import { useExecute, useRead } from "@lib/hooks"; import { useEffect, useState } from "react"; import { Types } from "komodo_client"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, } from "@ui/select"; import { useDeployment } from "."; import { parse_key_value } from "@lib/utils"; interface DeploymentId { id: string; } export const DeployDeployment = ({ id }: DeploymentId) => { const deployment = useRead("GetDeployment", { deployment: id }).data; const [signal, setSignal] = useState(); useEffect( () => setSignal(deployment?.config?.termination_signal), [deployment?.config?.termination_signal] ); const { mutate: deploy, isPending } = useExecute("Deploy"); const deployments = useRead("ListDeployments", {}).data; const deployment_item = deployments?.find((d) => d.id === id); const deploying = useRead( "GetDeploymentActionState", { deployment: id }, { refetchInterval: 5_000 } ).data?.deploying; const pending = isPending || deploying; if (!deployment) return null; const deployed = deployment_item?.info.state !== Types.DeploymentState.NotDeployed && deployment_item?.info.state !== Types.DeploymentState.Unknown; const term_signal_labels = deployed && parse_key_value(deployment.config?.term_signal_labels ?? "").map( (s) => ({ signal: s.key, label: s.value }) as Types.TerminationSignalLabel ); if (deployed) { return ( } onClick={() => deploy({ deployment: id, stop_signal: signal })} disabled={pending} loading={pending} additional={ term_signal_labels && term_signal_labels.length > 1 ? ( ) : undefined } /> ); } else { return ( } onClick={() => deploy({ deployment: id })} disabled={pending} loading={pending} /> ); } }; export const DestroyDeployment = ({ id }: DeploymentId) => { const deployment = useRead("GetDeployment", { deployment: id }).data; const [signal, setSignal] = useState(); useEffect( () => setSignal(deployment?.config?.termination_signal), [deployment?.config?.termination_signal] ); const { mutate, isPending } = useExecute("DestroyDeployment"); const deployments = useRead("ListDeployments", {}).data; const state = deployments?.find((d) => d.id === id)?.info.state; const destroying = useRead( "GetDeploymentActionState", { deployment: id, }, { refetchInterval: 5000 } ).data?.destroying; const pending = isPending || destroying; if (!deployment) return null; if (state === Types.DeploymentState.NotDeployed) return null; const term_signal_labels = parse_key_value( deployment.config?.term_signal_labels ?? "" ).map( (s) => ({ signal: s.key, label: s.value }) as Types.TerminationSignalLabel ); return ( } onClick={() => mutate({ deployment: id, signal })} disabled={pending} loading={pending} additional={ term_signal_labels && term_signal_labels.length > 1 ? ( ) : undefined } /> ); }; export const PullDeployment = ({ id }: DeploymentId) => { const deployment = useDeployment(id); const { mutate: pull, isPending: pullPending } = useExecute("PullDeployment"); const action_state = useRead( "GetDeploymentActionState", { deployment: id, }, { refetchInterval: 5000 } ).data; if (!deployment) return null; return ( } onClick={() => pull({ deployment: id })} disabled={pullPending} loading={pullPending || action_state?.pulling} /> ); }; export const RestartDeployment = ({ id }: DeploymentId) => { const deployment = useDeployment(id); const state = deployment?.info.state; const { mutate: restart, isPending: restartPending } = useExecute("RestartDeployment"); const action_state = useRead( "GetDeploymentActionState", { deployment: id, }, { refetchInterval: 5000 } ).data; if (!deployment) return null; if (state !== Types.DeploymentState.Running) { return null; } return ( } onClick={() => restart({ deployment: id })} disabled={restartPending} loading={restartPending || action_state?.restarting} /> ); }; export const StartStopDeployment = ({ id }: DeploymentId) => { const deployment = useDeployment(id); const state = deployment?.info.state; const { mutate: start, isPending: startPending } = useExecute("StartDeployment"); const action_state = useRead( "GetDeploymentActionState", { deployment: id, }, { refetchInterval: 5000 } ).data; if (!deployment) return null; if (state === Types.DeploymentState.Exited) { return ( } onClick={() => start({ deployment: id })} disabled={startPending} loading={startPending || action_state?.starting} /> ); } if (state !== Types.DeploymentState.NotDeployed) { return ; } }; const StopDeployment = ({ id }: DeploymentId) => { const deployment = useRead("GetDeployment", { deployment: id }).data; const [signal, setSignal] = useState(); useEffect( () => setSignal(deployment?.config?.termination_signal), [deployment?.config?.termination_signal] ); const { mutate, isPending } = useExecute("StopDeployment"); const stopping = useRead( "GetDeploymentActionState", { deployment: id, }, { refetchInterval: 5000 } ).data?.stopping; const pending = isPending || stopping; if (!deployment) return null; const term_signal_labels = parse_key_value( deployment.config?.term_signal_labels ?? "" ).map( (s) => ({ signal: s.key, label: s.value }) as Types.TerminationSignalLabel ); return ( } onClick={() => mutate({ deployment: id, signal })} disabled={pending} loading={pending} additional={ term_signal_labels && term_signal_labels.length > 1 ? ( ) : undefined } /> ); }; const TermSignalSelector = ({ signals, signal, setSignal, }: { signals: Types.TerminationSignalLabel[]; signal: Types.TerminationSignal | undefined; setSignal: (signal: Types.TerminationSignal) => void; }) => { const label = signals.find((s) => s.signal === signal)?.label; return (
Termination
{label}
); }; export const PauseUnpauseDeployment = ({ id }: DeploymentId) => { const deployment = useDeployment(id); const state = deployment?.info.state; const { mutate: unpause, isPending: unpausePending } = useExecute("UnpauseDeployment"); const { mutate: pause, isPending: pausePending } = useExecute("PauseDeployment"); const action_state = useRead( "GetDeploymentActionState", { deployment: id, }, { refetchInterval: 5000 } ).data; if (!deployment) return null; if (state === Types.DeploymentState.Paused) { return ( } onClick={() => unpause({ deployment: id })} disabled={unpausePending} loading={unpausePending || action_state?.unpausing} /> ); } if (state === Types.DeploymentState.Running) { return ( } onClick={() => pause({ deployment: id })} disabled={pausePending} loading={pausePending || action_state?.pausing} /> ); } }; ================================================ FILE: frontend/src/components/resources/deployment/config/components/image.tsx ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import { ResourceSelector } from "@components/resources/common"; import { fmt_date, fmt_version } from "@lib/formatting"; import { useRead } from "@lib/hooks"; import { filterBySplit } from "@lib/utils"; import { Types } from "komodo_client"; import { CaretSortIcon } from "@radix-ui/react-icons"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@ui/command"; import { Input } from "@ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { SearchX } from "lucide-react"; import { useState } from "react"; const BuildVersionSelector = ({ disabled, buildId, selected, onSelect, }: { disabled: boolean; buildId: string | undefined; selected: Types.Version | undefined; onSelect: (version: Types.Version) => void; }) => { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const versions = useRead( "ListBuildVersions", { build: buildId! }, { enabled: !!buildId } ).data; const filtered = filterBySplit(versions, search, (item) => fmt_version(item.version) ); return (
{selected ? fmt_version(selected) : "Latest"}
No Versions Found { onSelect({ major: 0, minor: 0, patch: 0 }); setOpen(false); }} >
Latest
{filtered?.map((v) => { const version = fmt_version(v.version); return ( { onSelect(v.version); setOpen(false); }} className="flex items-center justify-between cursor-pointer" >
{version}
{fmt_date(new Date(v.ts))}
); })}
); }; const ImageTypeSelector = ({ selected, onSelect, disabled, }: { selected: Types.DeploymentImage["type"] | undefined; onSelect: (type: Types.DeploymentImage["type"]) => void; disabled: boolean; }) => ( ); export const ImageConfig = ({ image, set, disabled, }: { image: Types.DeploymentImage | undefined; set: (input: Partial) => void; disabled: boolean; }) => (
set({ image: { type: type, params: type === "Image" ? { image: "" } : ({ build_id: "", version: { major: 0, minor: 0, patch: 0 }, } as any), }, }) } /> {image?.type === "Build" && ( <> set({ image: { ...image, params: { ...image.params, build_id: id }, }, }) } disabled={disabled} /> set({ image: { ...image, params: { ...image.params, version, }, }, }) } disabled={disabled} /> )} {image?.type === "Image" && ( set({ image: { ...image, params: { image: e.target.value }, }, }) } className="w-full" placeholder="image name" disabled={disabled} /> )}
); ================================================ FILE: frontend/src/components/resources/deployment/config/components/network.tsx ================================================ import { ConfigItem } from "@components/config/util"; import { useRead } from "@lib/hooks"; import { Input } from "@ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { useState } from "react"; export const NetworkModeSelector = ({ server_id, selected, onSelect, disabled, }: { server_id: string | undefined; selected: string | undefined; onSelect: (type: string) => void; disabled: boolean; }) => { const _networks = useRead( "ListDockerNetworks", { server: server_id! }, { enabled: !!server_id } ) .data?.filter((n) => n.name) .map((network) => network.name) ?? []; const [customMode, setCustomMode] = useState(false); const networks = !selected || _networks.includes(selected) ? _networks : [..._networks, selected]; return ( {customMode ? ( onSelect(e.target.value)} className="max-w-[75%] lg:max-w-[400px]" onBlur={() => setCustomMode(false)} onKeyDown={(e) => { if (e.key === "Enter") { setCustomMode(false); } }} autoFocus /> ) : ( )} ); }; ================================================ FILE: frontend/src/components/resources/deployment/config/components/restart.tsx ================================================ import { ConfigItem } from "@components/config/util"; import { Types } from "komodo_client"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { object_keys } from "@lib/utils"; const format_mode = (m: string) => m.split("-").join(" "); export const RestartModeSelector = ({ selected, set, disabled, }: { selected: Types.RestartMode | undefined; set: (input: Partial) => void; disabled: boolean; }) => ( ); ================================================ FILE: frontend/src/components/resources/deployment/config/components/term-signal.tsx ================================================ import { ConfigItem } from "@components/config/util"; import { Types } from "komodo_client"; import { Input } from "@ui/input"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { useToast } from "@ui/use-toast"; import { useEffect, useState } from "react"; export const DefaultTerminationSignal = ({ arg, set, disabled, }: { arg?: Types.TerminationSignal; set: (input: Partial) => void; disabled: boolean; }) => { return ( ); }; export const TerminationTimeout = ({ arg, set, disabled, }: { arg: number; set: (input: Partial) => void; disabled: boolean; }) => { const { toast } = useToast(); const [input, setInput] = useState(arg.toString()); useEffect(() => { setInput(arg.toString()); }, [arg]); return (
setInput(e.target.value)} onBlur={(e) => { const num = Number(e.target.value); if (num || num === 0) { set({ termination_timeout: num }); } else { toast({ title: "Termination timeout must be a number" }); setInput(arg.toString()); } }} disabled={disabled} /> seconds
); }; ================================================ FILE: frontend/src/components/resources/deployment/config/index.tsx ================================================ import { useLocalStorage, usePermissions, useRead, useWrite } from "@lib/hooks"; import { Types } from "komodo_client"; import { ReactNode } from "react"; import { AccountSelectorConfig, AddExtraArgMenu, ConfigItem, ConfigList, ConfigSwitch, InputList, } from "@components/config/util"; import { ImageConfig } from "./components/image"; import { RestartModeSelector } from "./components/restart"; import { NetworkModeSelector } from "./components/network"; import { Config } from "@components/config"; import { ResourceLink, ResourceSelector } from "@components/resources/common"; import { Link } from "react-router-dom"; import { SecretsSearch } from "@components/config/env_vars"; import { MonacoEditor } from "@components/monaco"; import { DefaultTerminationSignal, TerminationTimeout, } from "./components/term-signal"; import { extract_registry_domain } from "@lib/utils"; export const DeploymentConfig = ({ id, titleOther, }: { id: string; titleOther: ReactNode; }) => { const { canWrite } = usePermissions({ type: "Deployment", id }); const config = useRead("GetDeployment", { deployment: id }).data?.config; const builds = useRead("ListBuilds", {}).data; const global_disabled = useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false; const [update, set] = useLocalStorage>( `deployment-${id}-update-v1`, {} ); const { mutateAsync } = useWrite("UpdateDeployment"); if (!config) return null; const network = update.network ?? config.network; const hide_ports = network === "host" || network === "none"; const auto_update = update.auto_update ?? config.auto_update ?? false; const disabled = global_disabled || !canWrite; return ( { await mutateAsync({ id, config: update }); }} components={{ "": [ { label: "Server", labelHidden: true, components: { server_id: (server_id, set) => { return ( Server: ) : ( "Select Server" ) } description="Select the Server to deploy on." > set({ server_id })} disabled={disabled} align="start" /> ); }, }, }, { label: (update.image ?? config.image)?.type === "Build" ? "Build" : "Image", description: "Either pass a docker image directly, or choose a Build to deploy", components: { image: (value, set) => ( ), image_registry_account: (account, set) => { const image = update.image ?? config.image; const provider = image?.type === "Image" && image.params.image ? extract_registry_domain(image.params.image) : image?.type === "Build" && image.params.build_id ? builds?.find((b) => b.id === image.params.build_id) ?.info.image_registry_domain : undefined; return ( set({ image_registry_account }) } disabled={disabled} placeholder={ image?.type === "Build" ? "Same as Build" : undefined } description={ image?.type === "Build" ? "Select an alternate account used to log in to the provider" : undefined } /> ); }, redeploy_on_build: (update.image?.type ?? config.image?.type) === "Build" && { description: "Automatically redeploy when the image is built.", }, }, }, { label: "Network", labelHidden: true, components: { network: (value, set) => ( set({ network })} disabled={disabled} /> ), ports: !hide_ports && ((ports, set) => ( set({ ports })} readOnly={disabled} /> )), links: (values, set) => ( ), }, }, { label: "Environment", description: "Pass these variables to the container", components: { environment: (env, set) => (
set({ environment })} language="key_value" readOnly={disabled} />
), // skip_secret_interp: true, }, }, { label: "Volumes", description: "Configure the volume bindings.", components: { volumes: (volumes, set) => ( set({ volumes })} readOnly={disabled} /> ), }, }, { label: "Restart", labelHidden: true, components: { restart: (value, set) => ( ), }, }, { label: "Auto Update", hidden: (update.image ?? config.image)?.type === "Build", components: { poll_for_updates: (poll, set) => { return ( set({ poll_for_updates })} disabled={disabled || auto_update} /> ); }, auto_update: { description: "Trigger a redeploy if a newer image is found.", }, }, }, ], advanced: [ { label: "Command", labelHidden: true, components: { command: (value, set) => (
Replace the CMD, or extend the ENTRYPOINT.
See docker docs. {/* */} } > set({ command })} readOnly={disabled} />
), }, }, { label: "Labels", description: "Attach --labels to the container.", components: { labels: (labels, set) => ( set({ labels })} readOnly={disabled} /> ), }, }, { label: "Extra Args", labelHidden: true, components: { extra_args: (value, set) => (
Pass extra arguments to 'docker run'.
See docker docs. } > {!disabled && ( set({ extra_args: [ ...(update.extra_args ?? config.extra_args ?? []), suggestion, ], }) } disabled={disabled} /> )}
), }, }, { label: "Termination", description: "Configure the signals used to 'docker stop' the container. Options are SIGTERM, SIGQUIT, SIGINT, and SIGHUP.", components: { termination_signal: (value, set) => ( ), termination_timeout: (value, set) => ( ), term_signal_labels: (value, set) => ( set({ term_signal_labels }) } readOnly={disabled} /> ), }, }, ], }} /> ); }; export const DEFAULT_TERM_SIGNAL_LABELS = ` # SIGTERM: sigterm label # SIGQUIT: sigquit label # SIGINT: sigint label # SIGHUP: sighup label `; ================================================ FILE: frontend/src/components/resources/deployment/index.tsx ================================================ import { useLocalStorage, useRead } from "@lib/hooks"; import { ConnectExecQuery, Types } from "komodo_client"; import { RequiredResourceComponents } from "@types"; import { CircleArrowUp, HardDrive, Rocket, Server } from "lucide-react"; import { cn } from "@lib/utils"; import { useServer } from "../server"; import { DeployDeployment, StartStopDeployment, DestroyDeployment, RestartDeployment, PauseUnpauseDeployment, PullDeployment, } from "./actions"; import { DeploymentLogs } from "./log"; import { deployment_state_intention, stroke_color_class_by_intention, } from "@lib/color"; import { DeploymentTable } from "./table"; import { DeleteResource, NewResource, ResourceLink, ResourcePageHeader, } from "../common"; import { RunBuild } from "../build/actions"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/tabs"; import { DeploymentConfig } from "./config"; import { DashboardPieChart } from "@pages/home/dashboard"; import { ContainerPortsTableView, DockerResourceLink, StatusBadge, } from "@components/util"; import { GroupActions } from "@components/group-actions"; import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip"; import { usePermissions } from "@lib/hooks"; import { ContainerTerminal } from "@components/terminal/container"; import { DeploymentInspect } from "./inspect"; import { useMemo } from "react"; // const configOrLog = atomWithStorage("config-or-log-v1", "Config"); export const useDeployment = (id?: string) => useRead("ListDeployments", {}, { refetchInterval: 10_000 }).data?.find( (d) => d.id === id ); export const useFullDeployment = (id: string) => useRead("GetDeployment", { deployment: id }, { refetchInterval: 10_000 }) .data; const ConfigTabs = ({ id }: { id: string }) => { const deployment = useDeployment(id); if (!deployment) return null; return ; }; const ConfigTabsInner = ({ deployment, }: { deployment: Types.DeploymentListItem; }) => { // const [view, setView] = useAtom(configOrLog); const [_view, setView] = useLocalStorage< "Config" | "Log" | "Inspect" | "Terminal" >("deployment-tabs-v1", "Config"); const { specificLogs, specificInspect, specificTerminal } = usePermissions({ type: "Deployment", id: deployment.id, }); const container_exec_disabled = useServer(deployment.info.server_id)?.info.container_exec_disabled ?? true; const state = deployment.info.state; const logsDisabled = !specificLogs || state === undefined || state === Types.DeploymentState.Unknown || state === Types.DeploymentState.NotDeployed; const inspectDisabled = !specificInspect || state === undefined || state === Types.DeploymentState.Unknown || state === Types.DeploymentState.NotDeployed; const terminalDisabled = !specificTerminal || container_exec_disabled || state !== Types.DeploymentState.Running; const view = (logsDisabled && _view === "Log") || (inspectDisabled && _view === "Inspect") || (terminalDisabled && _view === "Terminal") ? "Config" : _view; const tabs = useMemo( () => ( Config {specificLogs && ( Log )} {specificInspect && ( Inspect )} {specificTerminal && ( Terminal )} ), [ specificLogs, logsDisabled, specificInspect, inspectDisabled, specificTerminal, terminalDisabled, ] ); const terminalQuery = useMemo( () => ({ type: "deployment", query: { deployment: deployment.id, // This is handled inside ContainerTerminal shell: "", }, }) as ConnectExecQuery, [deployment.id] ); return ( ); }; const DeploymentIcon = ({ id, size }: { id?: string; size: number }) => { const state = useDeployment(id)?.info.state; const color = stroke_color_class_by_intention( deployment_state_intention(state) ); return ; }; export const DeploymentComponents: RequiredResourceComponents = { list_item: (id) => useDeployment(id), resource_links: (resource) => (resource.config as Types.DeploymentConfig).links, Description: () => <>Deploy containers on your servers., Dashboard: () => { const summary = useRead("GetDeploymentsSummary", {}).data; const all = [ summary?.running ?? 0, summary?.stopped ?? 0, summary?.unhealthy ?? 0, summary?.unknown ?? 0, ]; const [running, stopped, unhealthy, unknown] = all; return ( item === 0) && { title: "Not Deployed", intention: "Neutral", value: summary?.not_deployed ?? 0, }, { intention: "Good", value: running, title: "Running" }, { title: "Stopped", intention: "Warning", value: stopped, }, { title: "Unhealthy", intention: "Critical", value: unhealthy, }, { title: "Unknown", intention: "Unknown", value: unknown, }, ]} /> ); }, New: ({ server_id: _server_id, build_id }) => { const servers = useRead("ListServers", {}).data; const server_id = _server_id ? _server_id : servers && servers.length === 1 ? servers[0].id : undefined; return ( ); }, Table: ({ resources }) => { return ( ); }, GroupActions: () => ( ), Icon: ({ id }) => , BigIcon: ({ id }) => , State: ({ id }) => { const state = useDeployment(id)?.info.state ?? Types.DeploymentState.Unknown; return ( ); }, Info: { Server: ({ id }) => { const info = useDeployment(id)?.info; const server = useServer(info?.server_id); return server?.id ? ( ) : (
Unknown Server
); }, Image: ({ id }) => { const config = useFullDeployment(id)?.config; const info = useDeployment(id)?.info; return info?.build_id ? ( ) : (
{info?.image.startsWith("sha256:") ? ( config?.image as Extract< Types.DeploymentImage, { type: "Image" } > )?.params.image : info?.image || "N/A"}
); }, Container: ({ id }) => { const deployment = useDeployment(id); if ( !deployment || [ Types.DeploymentState.Unknown, Types.DeploymentState.NotDeployed, ].includes(deployment.info.state) ) return null; return ( ); }, Ports: ({ id }) => { const deployment = useDeployment(id); const container = useRead( "ListDockerContainers", { server: deployment?.info.server_id!, }, { refetchInterval: 10_000, enabled: !!deployment?.info.server_id } ).data?.find((container) => container.name === deployment?.name); if (!container) return null; return ( ); }, }, Status: { UpdateAvailable: ({ id }) => , }, Actions: { RunBuild: ({ id }) => { const build_id = useDeployment(id)?.info.build_id; if (!build_id) return null; return ; }, DeployDeployment, PullDeployment, RestartDeployment, PauseUnpauseDeployment, StartStopDeployment, DestroyDeployment, }, Page: {}, Config: ConfigTabs, DangerZone: ({ id }) => , ResourcePageHeader: ({ id }) => { const deployment = useDeployment(id); return ( } type="Deployment" id={id} resource={deployment} state={ deployment?.info.state === Types.DeploymentState.NotDeployed ? "Not Deployed" : deployment?.info.state } status={deployment?.info.status} /> ); }, }; export const UpdateAvailable = ({ id, small, }: { id: string; small?: boolean; }) => { const info = useDeployment(id)?.info; const state = info?.state ?? Types.DeploymentState.Unknown; if ( !info || !info?.update_available || [Types.DeploymentState.NotDeployed, Types.DeploymentState.Unknown].includes( state ) ) { return null; } return (
{!small && (
Update Available
)}
There is a newer image available
); }; ================================================ FILE: frontend/src/components/resources/deployment/inspect.tsx ================================================ import { usePermissions, useRead } from "@lib/hooks"; import { ReactNode } from "react"; import { Types } from "komodo_client"; import { Section } from "@components/layouts"; import { InspectContainerView } from "@components/inspect"; export const DeploymentInspect = ({ id, titleOther, }: { id: string; titleOther: ReactNode; }) => { const { specific } = usePermissions({ type: "Deployment", id }); if (!specific.includes(Types.SpecificPermission.Inspect)) { return (

User does not have permission to inspect this Deployment.

); } return (
); }; const DeploymentInspectInner = ({ id }: { id: string }) => { const { data: container, error, isPending, isError, } = useRead("InspectDeploymentContainer", { deployment: id, }); return ( ); }; ================================================ FILE: frontend/src/components/resources/deployment/log.tsx ================================================ import { useRead } from "@lib/hooks"; import { Types } from "komodo_client"; import { ReactNode } from "react"; import { useDeployment } from "."; import { Log, LogSection } from "@components/log"; export const DeploymentLogs = ({ id, titleOther, }: { id: string; titleOther: ReactNode; }) => { const state = useDeployment(id)?.info.state; if ( state === undefined || state === Types.DeploymentState.Unknown || state === Types.DeploymentState.NotDeployed ) { return null; } return ; }; const DeploymentLogsInner = ({ id, titleOther, }: { id: string; titleOther: ReactNode; }) => { return ( NoSearchLogs(id, tail, timestamps, stream, poll) } search_logs={(timestamps, terms, invert, poll) => SearchLogs(id, terms, invert, timestamps, poll) } titleOther={titleOther} /> ); }; const NoSearchLogs = ( id: string, tail: number, timestamps: boolean, stream: string, poll: boolean ) => { const { data: log, refetch } = useRead( "GetDeploymentLog", { deployment: id, tail, timestamps, }, { refetchInterval: poll ? 3000 : false } ); return { Log: (
), refetch, stderr: !!log?.stderr, }; }; const SearchLogs = ( id: string, terms: string[], invert: boolean, timestamps: boolean, poll: boolean ) => { const { data: log, refetch } = useRead( "SearchDeploymentLog", { deployment: id, terms, combinator: Types.SearchCombinator.And, invert, timestamps, }, { refetchInterval: poll ? 10000 : false } ); return { Log: (
), refetch, stderr: !!log?.stderr, }; }; ================================================ FILE: frontend/src/components/resources/deployment/table.tsx ================================================ import { TableTags } from "@components/tags"; import { Types } from "komodo_client"; import { DataTable, SortableHeader } from "@ui/data-table"; import { useRead, useSelectedResources } from "@lib/hooks"; import { ResourceLink } from "../common"; import { DeploymentComponents, UpdateAvailable } from "."; import { HardDrive } from "lucide-react"; import { useCallback } from "react"; export const DeploymentTable = ({ deployments, }: { deployments: Types.DeploymentListItem[]; }) => { const servers = useRead("ListServers", {}).data; const serverName = useCallback( (id: string) => servers?.find((server) => server.id === id)?.name, [servers] ); const [_, setSelectedResources] = useSelectedResources("Deployment"); return ( name, onSelect: setSelectedResources, }} columns={[ { accessorKey: "name", header: ({ column }) => ( ), cell: ({ row }) => (
), size: 200, }, { accessorKey: "info.image", header: ({ column }) => ( ), cell: ({ row: { original: { info: { build_id, image }, }, }, }) => , size: 200, }, { accessorKey: "info.server_id", sortingFn: (a, b) => { const sa = serverName(a.original.info.server_id); const sb = serverName(b.original.info.server_id); if (!sa && !sb) return 0; if (!sa) return 1; if (!sb) return -1; if (sa > sb) return 1; else if (sa < sb) return -1; else return 0; }, header: ({ column }) => ( ), cell: ({ row }) => ( ), size: 200, }, { accessorKey: "info.state", header: ({ column }) => ( ), cell: ({ row }) => ( ), size: 120, }, { header: "Tags", cell: ({ row }) => , }, ]} /> ); }; const Image = ({ build_id, image, }: { build_id: string | undefined; image: string; }) => { const builds = useRead("ListBuilds", {}).data; if (build_id) { const build = builds?.find((build) => build.id === build_id); if (build) { return ; } else { return undefined; } } else { const [img] = image.split(":"); return (
{img}
); } }; ================================================ FILE: frontend/src/components/resources/index.tsx ================================================ import { RequiredResourceComponents, UsableResource } from "@types"; import { AlerterComponents } from "./alerter"; import { BuildComponents } from "./build"; import { BuilderComponents } from "./builder"; import { DeploymentComponents } from "./deployment"; import { RepoComponents } from "./repo"; import { ServerComponents } from "./server"; import { ProcedureComponents } from "./procedure/index"; import { ResourceSyncComponents } from "./resource-sync"; import { StackComponents } from "./stack"; import { ActionComponents } from "./action"; export const ResourceComponents: { [key in UsableResource]: RequiredResourceComponents; } = { Server: ServerComponents, Stack: StackComponents, Deployment: DeploymentComponents, Build: BuildComponents, Repo: RepoComponents, Procedure: ProcedureComponents, Action: ActionComponents, ResourceSync: ResourceSyncComponents, Builder: BuilderComponents, Alerter: AlerterComponents, }; ================================================ FILE: frontend/src/components/resources/procedure/config.tsx ================================================ import { useLocalStorage, usePermissions, useRead, useWebhookIdOrName, useWebhookIntegrations, useWrite, } from "@lib/hooks"; import { Types } from "komodo_client"; import { Config } from "@components/config"; import { Button } from "@ui/button"; import { ConfigItem, ConfigSwitch, WebhookBuilder, } from "@components/config/util"; import { Input } from "@ui/input"; import { useEffect, useState } from "react"; import { CopyWebhook, ResourceSelector } from "../common"; import { Switch } from "@ui/switch"; import { ArrowDown, ArrowUp, ChevronsUpDown, Minus, MinusCircle, Plus, PlusCircle, SearchX, Settings2, CheckCircle, } from "lucide-react"; import { useToast } from "@ui/use-toast"; import { TextUpdateMenuMonaco, TimezoneSelector } from "@components/util"; import { Card } from "@ui/card"; import { filterBySplit, text_to_env } from "@lib/utils"; import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover"; import { fmt_upper_camelcase } from "@lib/formatting"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@ui/command"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@ui/dropdown-menu"; import { DotsHorizontalIcon } from "@radix-ui/react-icons"; import { DataTable } from "@ui/data-table"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@ui/dialog"; import { MonacoEditor } from "@components/monaco"; import { quote as shellQuote, parse as shellParse } from "shell-quote"; type ExecutionType = Types.Execution["type"]; type ExecutionConfigComponent< T extends ExecutionType, P = Extract["params"], > = React.FC<{ params: P; setParams: React.Dispatch>; disabled: boolean; }>; type MinExecutionType = Exclude< ExecutionType, | "StartContainer" | "RestartContainer" | "PauseContainer" | "UnpauseContainer" | "StopContainer" | "DestroyContainer" | "DeleteNetwork" | "DeleteImage" | "DeleteVolume" | "TestAlerter" >; type ExecutionConfigParams = Extract< Types.Execution, { type: T } >["params"]; type ExecutionConfigs = { [ExType in MinExecutionType]: { Component: ExecutionConfigComponent; params: ExecutionConfigParams; }; }; const PROCEDURE_GIT_PROVIDER = "Procedure"; const new_stage = (next_index: number) => ({ name: `Stage ${next_index}`, enabled: true, executions: [default_enabled_execution()], }); const default_enabled_execution: () => Types.EnabledExecution = () => ({ enabled: true, execution: { type: "None", params: {}, }, }); export const ProcedureConfig = ({ id }: { id: string }) => { const [branch, setBranch] = useState("main"); const { canWrite } = usePermissions({ type: "Procedure", id }); const procedure = useRead("GetProcedure", { procedure: id }).data; const config = procedure?.config; const name = procedure?.name; const global_disabled = useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false; const [update, set] = useLocalStorage>( `procedure-${id}-update-v1`, {}, ); const { mutateAsync } = useWrite("UpdateProcedure"); const { integrations } = useWebhookIntegrations(); const [id_or_name] = useWebhookIdOrName(); if (!config) return null; const disabled = global_disabled || !canWrite; const webhook_integration = integrations[PROCEDURE_GIT_PROVIDER] ?? "Github"; const stages = update.stages || procedure.config?.stages || []; const add_stage = () => set((config) => ({ ...config, stages: [...stages, new_stage(stages.length + 1)], })); return ( { await mutateAsync({ id, config: update }); }} components={{ "": [ { label: "Stages", description: "The executions in a stage are all run in parallel. The stages themselves are run sequentially.", components: { stages: (stages, set) => (
{stages && stages.map((stage, index) => ( set({ stages: stages.map((s, i) => index === i ? stage : s, ), }) } removeStage={() => set({ stages: stages.filter((_, i) => index !== i), }) } moveUp={ index === 0 ? undefined : () => set({ stages: stages.map((stage, i) => { // Make sure its not the first row if (i === index && index !== 0) { return stages[index - 1]; } else if (i === index - 1) { // Reverse the entry, moving this row "Up" return stages[index]; } else { return stage; } }), }) } moveDown={ index === stages.length - 1 ? undefined : () => set({ stages: stages.map((stage, i) => { // The index also cannot be the last index, which cannot be moved down if ( i === index && index !== stages.length - 1 ) { return stages[index + 1]; } else if (i === index + 1) { // Move the row "Down" return stages[index]; } else { return stage; } }), }) } insertAbove={() => set({ stages: [ ...stages.slice(0, index), new_stage(index + 1), ...stages.slice(index), ], }) } insertBelow={() => set({ stages: [ ...stages.slice(0, index + 1), new_stage(index + 2), ...stages.slice(index + 1), ], }) } disabled={disabled} /> ))}
), }, }, { label: "Alert", labelHidden: true, components: { failure_alert: { boldLabel: true, description: "Send an alert any time the Procedure fails", }, }, }, { label: "Schedule", description: "Configure the Procedure to run at defined times using English or CRON.", components: { schedule_enabled: (schedule_enabled, set) => ( set({ schedule_enabled })} /> ), schedule_format: (schedule_format, set) => ( ), schedule: { label: "Expression", description: (update.schedule_format ?? config.schedule_format) === "Cron" ? (
second - minute - hour - day - month - day-of-week
) : (
Examples: - Run every day at 4:00 pm - Run at 21:00 on the 1st and 15th of the month - Every Sunday at midnight
), placeholder: (update.schedule_format ?? config.schedule_format) === "Cron" ? "0 0 0 ? * SUN" : "Enter English expression", }, schedule_timezone: (timezone, set) => { return ( set({ schedule_timezone }) } disabled={disabled} /> ); }, schedule_alert: { description: "Send an alert when the scheduled run occurs", }, }, }, { label: "Webhook", description: `Copy the webhook given here, and configure your ${webhook_integration}-style repo provider to send webhooks to Komodo`, components: { ["Builder" as any]: () => (
Listen on branch:
setBranch(e.target.value)} className="w-[200px]" disabled={branch === "__ANY__"} />
No branch check:
{ if (checked) { setBranch("__ANY__"); } else { setBranch("main"); } }} />
), ["run" as any]: () => ( ), webhook_enabled: true, webhook_secret: { description: "Provide a custom webhook secret for this resource, or use the global default.", placeholder: "Input custom secret", }, }, }, ], }} /> ); }; const Stage = ({ stage, setStage, removeStage, moveUp, moveDown, insertAbove, insertBelow, disabled, }: { stage: Types.ProcedureStage; setStage: (stage: Types.ProcedureStage) => void; removeStage: () => void; insertAbove: () => void; insertBelow: () => void; moveUp: (() => void) | undefined; moveDown: (() => void) | undefined; disabled: boolean; }) => { return (
setStage({ ...stage, name: e.target.value })} className="w-[300px] text-md" />
Enabled:
setStage({ ...stage, enabled })} /> {moveUp && ( Move Up )} {moveDown && ( Move Down )} {(moveUp ?? moveDown) && } Insert Above{" "}
Insert Below{" "}
Remove
setStage({ ...stage, executions: [default_enabled_execution()], }) } variant="secondary" disabled={disabled} > Add Execution } columns={[ { header: "Execution", size: 250, cell: ({ row: { original, index } }) => ( setStage({ ...stage, executions: stage.executions!.map((item, i) => i === index ? ({ ...item, execution: { type, params: TARGET_COMPONENTS[ type as Types.Execution["type"] ].params, }, } as Types.EnabledExecution) : item, ), }) } /> ), }, { header: "Target", size: 250, cell: ({ row: { original: { execution: { type, params }, }, index, }, }) => { const Component = TARGET_COMPONENTS[type].Component; return ( setStage({ ...stage, executions: stage.executions!.map((item, i) => i === index ? { ...item, execution: { type, params }, } : item, ) as Types.EnabledExecution[], }) } /> ); }, }, { header: "Add / Remove", size: 150, cell: ({ row: { index } }) => (
), }, { header: "Enabled", size: 100, cell: ({ row: { original: { enabled }, index, }, }) => { return ( setStage({ ...stage, executions: stage.executions!.map((item, i) => i === index ? { ...item, enabled: !enabled } : item, ), }) } disabled={disabled} /> ); }, }, ]} />
); }; const ExecutionTypeSelector = ({ type, onSelect, disabled, }: { type: Types.Execution["type"]; onSelect: (type: Types.Execution["type"]) => void; disabled: boolean; }) => { const execution_types = Object.keys(TARGET_COMPONENTS).filter( (c) => !["None"].includes(c), ); const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const filtered = filterBySplit(execution_types, search, (item) => item); return ( Empty. {filtered.map((type) => ( onSelect(type as Types.Execution["type"])} className="flex items-center justify-between" >
{fmt_upper_camelcase(type)}
))}
); }; const TARGET_COMPONENTS: ExecutionConfigs = { None: { params: {}, Component: () => <>, }, // Procedure RunProcedure: { params: { procedure: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ procedure })} disabled={disabled} /> ), }, BatchRunProcedure: { params: { pattern: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ pattern })} disabled={disabled} language="string_list" fullWidth /> ), }, // Action RunAction: { params: { action: "", args: {} }, Component: ({ params, setParams, disabled }) => (
setParams({ action, args: params.args })} disabled={disabled} /> setParams({ action: params.action, args: JSON.parse(args) }) } disabled={disabled} language="json" triggerChild={ } />
), }, BatchRunAction: { params: { pattern: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ pattern })} disabled={disabled} language="string_list" fullWidth /> ), }, // Build RunBuild: { params: { build: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ build })} disabled={disabled} /> ), }, BatchRunBuild: { params: { pattern: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ pattern })} disabled={disabled} language="string_list" fullWidth /> ), }, CancelBuild: { params: { build: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ build })} disabled={disabled} /> ), }, // Deployment Deploy: { params: { deployment: "" }, Component: ({ params, setParams, disabled }) => { return ( setParams({ deployment })} disabled={disabled} /> ); }, }, BatchDeploy: { params: { pattern: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ pattern })} disabled={disabled} language="string_list" fullWidth /> ), }, PullDeployment: { params: { deployment: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ deployment })} disabled={disabled} /> ), }, StartDeployment: { params: { deployment: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ deployment })} disabled={disabled} /> ), }, RestartDeployment: { params: { deployment: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ deployment })} disabled={disabled} /> ), }, PauseDeployment: { params: { deployment: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ deployment })} disabled={disabled} /> ), }, UnpauseDeployment: { params: { deployment: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ deployment })} disabled={disabled} /> ), }, StopDeployment: { params: { deployment: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ deployment: id })} disabled={disabled} /> ), }, DestroyDeployment: { params: { deployment: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ deployment })} disabled={disabled} /> ), }, BatchDestroyDeployment: { params: { pattern: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ pattern })} disabled={disabled} language="string_list" fullWidth /> ), }, // Stack DeployStack: { params: { stack: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ stack: id })} disabled={disabled} /> ), }, BatchDeployStack: { params: { pattern: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ pattern })} disabled={disabled} language="string_list" fullWidth /> ), }, DeployStackIfChanged: { params: { stack: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ stack: id })} disabled={disabled} /> ), }, BatchDeployStackIfChanged: { params: { pattern: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ pattern })} disabled={disabled} language="string_list" fullWidth /> ), }, PullStack: { params: { stack: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ stack: id })} disabled={disabled} /> ), }, BatchPullStack: { params: { pattern: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ pattern })} disabled={disabled} language="string_list" fullWidth /> ), }, StartStack: { params: { stack: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ stack: id })} disabled={disabled} /> ), }, RestartStack: { params: { stack: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ stack: id })} disabled={disabled} /> ), }, PauseStack: { params: { stack: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ stack: id })} disabled={disabled} /> ), }, UnpauseStack: { params: { stack: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ stack: id })} disabled={disabled} /> ), }, StopStack: { params: { stack: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ stack: id })} disabled={disabled} /> ), }, DestroyStack: { params: { stack: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ stack: id })} disabled={disabled} /> ), }, BatchDestroyStack: { params: { pattern: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ pattern })} disabled={disabled} language="string_list" fullWidth /> ), }, RunStackService: { params: { stack: "", service: "", command: undefined, no_tty: undefined, no_deps: undefined, detach: undefined, service_ports: undefined, env: undefined, workdir: undefined, user: undefined, entrypoint: undefined, pull: undefined, }, Component: ({ params, setParams, disabled }) => { const [open, setOpen] = useState(false); // local mirrors to allow cancel without committing const [stack, setStack] = useState(params.stack ?? ""); const [service, setService] = useState(params.service ?? ""); const [commandText, setCommand] = useState( params.command && params.command.length ? shellQuote(params.command) : "" ); const [no_tty, setNoTty] = useState(!!params.no_tty); const [no_deps, setNoDeps] = useState(!!params.no_deps); const [detach, setDetach] = useState(!!params.detach); const [service_ports, setServicePorts] = useState(!!params.service_ports); const [workdir, setWorkdir] = useState(params.workdir ?? ""); const [user, setUser] = useState(params.user ?? ""); const [entrypoint, setEntrypoint] = useState(params.entrypoint ?? ""); const [pull, setPull] = useState(!!params.pull); const env_text = ( params.env ? Object.entries(params.env) .map(([k, v]) => `${k}=${v}`) .join("\n") : " # VARIABLE = value\n" ) as string; const [envText, setEnvText] = useState(env_text); useEffect(() => { setStack(params.stack ?? ""); setService(params.service ?? ""); setCommand( params.command && params.command.length ? shellQuote(params.command) : "" ); setNoTty(!!params.no_tty); setNoDeps(!!params.no_deps); setDetach(!!params.detach); setServicePorts(!!params.service_ports); setWorkdir(params.workdir ?? ""); setUser(params.user ?? ""); setEntrypoint(params.entrypoint ?? ""); setPull(!!params.pull); setEnvText( params.env ? Object.entries(params.env) .map(([k, v]) => `${k}=${v}`) .join("\n") : " # VARIABLE = value\n" ); }, [params]); const onConfirm = () => { const envArray = text_to_env(envText); const env = envArray.length ? envArray.reduce>( (acc, { variable, value }) => { if (variable) acc[variable] = value; return acc; }, {} ) : undefined; const parsed = commandText.trim() ? shellParse(commandText.trim()).map((tok) => typeof tok === "string" ? tok : ((tok as any).op ?? String(tok)) ) : []; setParams({ stack, service, command: parsed.length ? (parsed as string[]) : undefined, no_tty: no_tty ? true : undefined, no_deps: no_deps ? true : undefined, service_ports: service_ports ? true : undefined, workdir: workdir || undefined, user: user || undefined, entrypoint: entrypoint || undefined, pull: pull ? true : undefined, detach: detach ? true : undefined, env, } as any); setOpen(false); }; return ( Run Stack Service
Stack
setStack(id)} disabled={disabled} align="start" />
Service
setService(e.target.value)} disabled={disabled} />
Command
setCommand(e.target.value)} disabled={disabled} />
Working Directory
setWorkdir(e.target.value)} disabled={disabled} />
User
setUser(e.target.value)} disabled={disabled} />
Entrypoint
setEntrypoint(e.target.value)} disabled={disabled} />
Extra Environment Variables
); }, }, // Repo CloneRepo: { params: { repo: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ repo })} disabled={disabled} /> ), }, BatchCloneRepo: { params: { pattern: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ pattern })} disabled={disabled} language="string_list" fullWidth /> ), }, PullRepo: { params: { repo: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ repo })} disabled={disabled} /> ), }, BatchPullRepo: { params: { pattern: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ pattern })} disabled={disabled} language="string_list" fullWidth /> ), }, BuildRepo: { params: { repo: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ repo })} disabled={disabled} /> ), }, BatchBuildRepo: { params: { pattern: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ pattern })} disabled={disabled} language="string_list" fullWidth /> ), }, CancelRepoBuild: { params: { repo: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ repo })} disabled={disabled} /> ), }, // Server // StartContainer: { // params: { server: "" }, // Component: ({ params, setParams, disabled }) => ( // setParams({ server })} // disabled={disabled} // /> // ), // }, // RestartContainer: { // params: { server: "" }, // Component: ({ params, setParams, disabled }) => ( // setParams({ server })} // disabled={disabled} // /> // ), // }, // PauseContainer: { // params: { server: "" }, // Component: ({ params, setParams, disabled }) => ( // setParams({ server })} // disabled={disabled} // /> // ), // }, // UnpauseContainer: { // params: { server: "" }, // Component: ({ params, setParams, disabled }) => ( // setParams({ server })} // disabled={disabled} // /> // ), // }, // StopContainer: { // params: { server: "" }, // Component: ({ params, setParams, disabled }) => ( // setParams({ server })} // disabled={disabled} // /> // ), // }, // DestroyContainer: { // params: { server: "", container: "" }, // Component: ({ params, setParams, disabled }) => ( // setParams({ server })} // disabled={disabled} // /> // ), // }, StartAllContainers: { params: { server: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ server: id })} disabled={disabled} /> ), }, RestartAllContainers: { params: { server: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ server: id })} disabled={disabled} /> ), }, PauseAllContainers: { params: { server: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ server: id })} disabled={disabled} /> ), }, UnpauseAllContainers: { params: { server: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ server: id })} disabled={disabled} /> ), }, StopAllContainers: { params: { server: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ server: id })} disabled={disabled} /> ), }, PruneContainers: { params: { server: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ server })} disabled={disabled} /> ), }, PruneNetworks: { params: { server: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ server })} disabled={disabled} /> ), }, PruneImages: { params: { server: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ server })} disabled={disabled} /> ), }, PruneVolumes: { params: { server: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ server })} disabled={disabled} /> ), }, PruneDockerBuilders: { params: { server: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ server })} disabled={disabled} /> ), }, PruneBuildx: { params: { server: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ server })} disabled={disabled} /> ), }, PruneSystem: { params: { server: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ server })} disabled={disabled} /> ), }, RunSync: { params: { sync: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ sync: id })} disabled={disabled} /> ), }, CommitSync: { params: { sync: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ sync: id })} disabled={disabled} /> ), }, ClearRepoCache: { params: {}, Component: () => <>, }, BackupCoreDatabase: { params: {}, Component: () => <>, }, GlobalAutoUpdate: { params: {}, Component: () => <>, }, SendAlert: { params: { message: "" }, Component: ({ params, setParams, disabled }) => ( setParams({ message })} disabled={disabled} language={undefined} fullWidth /> ), }, Sleep: { params: { duration_ms: 0 }, Component: ({ params, setParams, disabled }) => { const { toast } = useToast(); const [internal, setInternal] = useState( params.duration_ms?.toString() ?? "" ); useEffect(() => { setInternal(params.duration_ms?.toString() ?? ""); }, [params.duration_ms]); return ( setInternal(e.target.value)} onBlur={() => { const duration_ms = Number(internal); if (duration_ms) { setParams({ duration_ms }); } else { toast({ title: "Duration must be valid number", variant: "destructive", }); } }} disabled={disabled} /> ); }, }, }; ================================================ FILE: frontend/src/components/resources/procedure/index.tsx ================================================ import { ActionWithDialog, StatusBadge } from "@components/util"; import { useExecute, useRead } from "@lib/hooks"; import { RequiredResourceComponents } from "@types"; import { Clock, Route } from "lucide-react"; import { ProcedureConfig } from "./config"; import { ProcedureTable } from "./table"; import { DeleteResource, NewResource, ResourcePageHeader } from "../common"; import { procedure_state_intention, stroke_color_class_by_intention, } from "@lib/color"; import { cn, updateLogToHtml } from "@lib/utils"; import { Types } from "komodo_client"; import { DashboardPieChart } from "@pages/home/dashboard"; import { GroupActions } from "@components/group-actions"; import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip"; import { Card } from "@ui/card"; const useProcedure = (id?: string) => useRead("ListProcedures", {}).data?.find((d) => d.id === id); const ProcedureIcon = ({ id, size }: { id?: string; size: number }) => { const state = useProcedure(id)?.info.state; const color = stroke_color_class_by_intention( procedure_state_intention(state) ); return ; }; export const ProcedureComponents: RequiredResourceComponents = { list_item: (id) => useProcedure(id), resource_links: () => undefined, Description: () => <>Orchestrate multiple Komodo executions., Dashboard: () => { const summary = useRead("GetProceduresSummary", {}).data; return ( ); }, New: () => , GroupActions: () => ( ), Table: ({ resources }) => ( ), Icon: ({ id }) => , BigIcon: ({ id }) => , State: ({ id }) => { let state = useProcedure(id)?.info.state; return ( ); }, Status: {}, Info: { Schedule: ({ id }) => { const next_scheduled_run = useProcedure(id)?.info.next_scheduled_run; return (
Next Run:
{next_scheduled_run ? new Date(next_scheduled_run).toLocaleString() : "Not Scheduled"}
); }, ScheduleErrors: ({ id }) => { const error = useProcedure(id)?.info.schedule_error; if (!error) { return null; } return (
Schedule Error
          
        
      );
    },
  },

  Actions: {
    RunProcedure: ({ id }) => {
      const running = useRead(
        "GetProcedureActionState",
        { procedure: id },
        { refetchInterval: 5000 }
      ).data?.running;
      const { mutate, isPending } = useExecute("RunProcedure");
      const procedure = useProcedure(id);
      if (!procedure) return null;
      return (
        }
          onClick={() => mutate({ procedure: id })}
          disabled={running || isPending}
          loading={running}
        />
      );
    },
  },

  Page: {},

  Config: ProcedureConfig,

  DangerZone: ({ id }) => ,

  ResourcePageHeader: ({ id }) => {
    const procedure = useProcedure(id);

    return (
      }
        type="Procedure"
        id={id}
        resource={procedure}
        state={procedure?.info.state}
        status={`${procedure?.info.stages} Stage${procedure?.info.stages === 1 ? "" : "s"}`}
      />
    );
  },
};


================================================
FILE: frontend/src/components/resources/procedure/table.tsx
================================================
import { DataTable, SortableHeader } from "@ui/data-table";
import { TableTags } from "@components/tags";
import { ResourceLink } from "../common";
import { ProcedureComponents } from ".";
import { Types } from "komodo_client";
import { useSelectedResources } from "@lib/hooks";

export const ProcedureTable = ({
  procedures,
}: {
  procedures: Types.ProcedureListItem[];
}) => {
  const [_, setSelectedResources] = useSelectedResources("Procedure");

  return (
     name,
        onSelect: setSelectedResources,
      }}
      columns={[
        {
          accessorKey: "name",
          header: ({ column }) => (
            
          ),
          cell: ({ row }) => (
            
          ),
        },
        {
          accessorKey: "info.state",
          header: ({ column }) => (
            
          ),
          cell: ({ row }) => ,
        },
        {
          accessorKey: "info.next_scheduled_run",
          header: ({ column }) => (
            
          ),
          sortingFn: (a, b) => {
            const sa = a.original.info.next_scheduled_run;
            const sb = b.original.info.next_scheduled_run;

            if (!sa && !sb) return 0;
            if (!sa) return 1;
            if (!sb) return -1;

            if (sa > sb) return 1;
            else if (sa < sb) return -1;
            else return 0;
          },
          cell: ({ row }) =>
            row.original.info.next_scheduled_run
              ? new Date(row.original.info.next_scheduled_run).toLocaleString()
              : "Not Scheduled",
        },
        {
          header: "Tags",
          cell: ({ row }) => ,
        },
      ]}
    />
  );
};


================================================
FILE: frontend/src/components/resources/repo/actions.tsx
================================================
import { ConfirmButton } from "@components/util";
import { useExecute, usePermissions, useRead } from "@lib/hooks";
import {
  ArrowDownToDot,
  ArrowDownToLine,
  Ban,
  Hammer,
  Loader2,
} from "lucide-react";
import { useRepo } from ".";
import { Types } from "komodo_client";
import { useBuilder } from "../builder";

export const CloneRepo = ({ id }: { id: string }) => {
  const { mutate, isPending } = useExecute("CloneRepo");
  const cloning = useRead(
    "GetRepoActionState",
    { repo: id },
    { refetchInterval: 5000 }
  ).data?.cloning;
  const info = useRepo(id)?.info;
  if (!info?.server_id) return null;
  const hash = info?.latest_hash;
  const isCloned = (hash?.length || 0) > 0;
  const pending = isPending || cloning;
  return (
    
        ) : (
          
        )
      }
      onClick={() => mutate({ repo: id })}
      disabled={pending}
      loading={pending}
    />
  );
};

export const PullRepo = ({ id }: { id: string }) => {
  const { mutate, isPending } = useExecute("PullRepo");
  const pulling = useRead(
    "GetRepoActionState",
    { repo: id },
    { refetchInterval: 5000 }
  ).data?.pulling;
  const info = useRepo(id)?.info;
  if (!info?.server_id) return null;
  const hash = info?.latest_hash;
  const isCloned = (hash?.length || 0) > 0;
  if (!isCloned) return null;
  const pending = isPending || pulling;
  return (
    
        ) : (
          
        )
      }
      onClick={() => mutate({ repo: id })}
      disabled={pending}
      loading={pending}
    />
  );
};

export const BuildRepo = ({ id }: { id: string }) => {
  const { canExecute } = usePermissions({ type: "Repo", id });
  const building = useRead(
    "GetRepoActionState",
    { repo: id },
    { refetchInterval: 5000 }
  ).data?.building;
  const updates = useRead("ListUpdates", {
    query: {
      "target.type": "Repo",
      "target.id": id,
    },
  }).data;
  const { mutate: run_mutate, isPending: runPending } = useExecute("BuildRepo");
  const { mutate: cancel_mutate, isPending: cancelPending } =
    useExecute("CancelRepoBuild");

  const repo = useRepo(id);
  const builder = useBuilder(repo?.info.builder_id);
  const canCancel = builder?.info.builder_type !== "Server";

  // Don't show if builder not attached
  if (!builder) return null;

  // make sure hidden without perms.
  // not usually necessary, but this button also used in deployment actions.
  if (!canExecute) return null;

  // updates come in in descending order, so 'find' will find latest update matching operation
  const latestBuild = updates?.updates.find(
    (u) => u.operation === Types.Operation.BuildRepo
  );
  const latestCancel = updates?.updates.find(
    (u) => u.operation === Types.Operation.CancelRepoBuild
  );
  const cancelDisabled =
    !canCancel ||
    cancelPending ||
    (latestCancel && latestBuild
      ? latestCancel!.start_ts > latestBuild!.start_ts
      : false);

  if (building) {
    return (
      }
        onClick={() => cancel_mutate({ repo: id })}
        disabled={cancelDisabled}
      />
    );
  } else {
    return (
      
          ) : (
            
          )
        }
        onClick={() => run_mutate({ repo: id })}
        disabled={runPending}
      />
    );
  }
};


================================================
FILE: frontend/src/components/resources/repo/config.tsx
================================================
import { Config } from "@components/config";
import {
  AccountSelectorConfig,
  ConfigItem,
  InputList,
  ProviderSelectorConfig,
  SystemCommand,
  WebhookBuilder,
} from "@components/config/util";
import {
  getWebhookIntegration,
  useInvalidate,
  useLocalStorage,
  usePermissions,
  useRead,
  useWebhookIdOrName,
  useWebhookIntegrations,
  useWrite,
} from "@lib/hooks";
import { Types } from "komodo_client";
import { CopyWebhook, ResourceLink, ResourceSelector } from "../common";
import { useToast } from "@ui/use-toast";
import { text_color_class_by_intention } from "@lib/color";
import { ConfirmButton } from "@components/util";
import { Ban, CirclePlus, PlusCircle } from "lucide-react";
import { Button } from "@ui/button";
import { SecretsSearch } from "@components/config/env_vars";
import { MonacoEditor } from "@components/monaco";

export const RepoConfig = ({ id }: { id: string }) => {
  const { canWrite } = usePermissions({ type: "Repo", id });
  const repo = useRead("GetRepo", { repo: id }).data;
  const config = repo?.config;
  const name = repo?.name;
  const webhooks = useRead("GetRepoWebhooksEnabled", { repo: id }).data;
  const global_disabled =
    useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false;
  const [update, set] = useLocalStorage>(
    `repo-${id}-update-v1`,
    {}
  );
  const { mutateAsync } = useWrite("UpdateRepo");
  const { integrations } = useWebhookIntegrations();
  const [id_or_name] = useWebhookIdOrName();

  if (!config) return null;

  const disabled = global_disabled || !canWrite;

  const git_provider = update.git_provider ?? config.git_provider;
  const webhook_integration = getWebhookIntegration(integrations, git_provider);

  return (
     {
        await mutateAsync({ id, config: update });
      }}
      components={{
        "": [
          {
            label: "Server",
            labelHidden: true,
            components: {
              server_id: (server_id, set) => {
                return (
                  
                          Server:
                          
                        
                      ) : (
                        "Select Server"
                      )
                    }
                    description="Select the Server to clone on."
                  >
                     set({ server_id })}
                      disabled={disabled}
                      align="start"
                    />
                  
                );
              },
            },
          },
          {
            label: "Builder",
            labelHidden: true,
            components: {
              builder_id: (builder_id, set) => {
                return (
                  
                          Builder:
                          
                        
                      ) : (
                        "Select Builder"
                      )
                    }
                    description="Select the Builder to build with."
                  >
                     set({ builder_id })}
                      disabled={disabled}
                      align="start"
                    />
                  
                );
              },
            },
          },
          {
            label: "Source",
            components: {
              git_provider: (provider, set) => {
                const https = update.git_https ?? config.git_https;
                return (
                   set({ git_provider })}
                    https={https}
                    onHttpsSwitch={() => set({ git_https: !https })}
                  />
                );
              },
              git_account: (account, set) => (
                 set({ git_account })}
                  disabled={disabled}
                  placeholder="None"
                />
              ),
              repo: {
                placeholder: "Enter repo",
                description:
                  "The repo path on the provider. {namespace}/{repo_name}",
              },
              branch: {
                placeholder: "Enter branch",
                description: "Select a custom branch, or default to 'main'.",
              },
              commit: {
                label: "Commit Hash",
                placeholder: "Input commit hash",
                description:
                  "Optional. Switch to a specific commit hash after cloning the branch.",
              },
            },
          },
          {
            label: "Path",
            labelHidden: true,
            components: {
              path: {
                label: "Clone Path",
                boldLabel: true,
                placeholder: "/clone/path/on/host",
                description: (
                  
Explicitly specify the folder on the host to clone the repo in.
If relative (no leading '/'), relative to {"$root_directory/repos/" + repo.name}
), }, }, }, { label: "Environment", description: "Write these variables to a .env-formatted file at the specified path, before on_clone / on_pull are run.", components: { environment: (env, set) => (
set({ environment })} language="key_value" readOnly={disabled} />
), env_file_path: { description: "The path to write the file to, relative to the root of the repo.", placeholder: ".env", }, // skip_secret_interp: true, }, }, { label: "On Clone", description: "Execute a shell command after cloning the repo. The given Cwd is relative to repo root.", components: { on_clone: (value, set) => ( set({ on_clone: value })} disabled={disabled} /> ), }, }, { label: "On Pull", description: "Execute a shell command after pulling the repo. The given Cwd is relative to repo root.", components: { on_pull: (value, set) => ( set({ on_pull: value })} disabled={disabled} /> ), }, }, { label: "Webhooks", description: `Copy the webhook given here, and configure your ${webhook_integration}-style repo provider to send webhooks to Komodo`, components: { ["Guard" as any]: () => { if (update.branch ?? config.branch) { return null; } return (
Must configure Branch before webhooks will work.
); }, ["Builder" as any]: () => ( ), ["pull" as any]: () => ( ), ["clone" as any]: () => ( ), ["build" as any]: () => ( ), webhook_enabled: webhooks !== undefined && !webhooks.managed, webhook_secret: { description: "Provide a custom webhook secret for this resource, or use the global default.", placeholder: "Input custom secret", }, ["managed" as any]: () => { const inv = useInvalidate(); const { toast } = useToast(); const { mutate: createWebhook, isPending: createPending } = useWrite("CreateRepoWebhook", { onSuccess: () => { toast({ title: "Webhook Created" }); inv(["GetRepoWebhooksEnabled", { repo: id }]); }, }); const { mutate: deleteWebhook, isPending: deletePending } = useWrite("DeleteRepoWebhook", { onSuccess: () => { toast({ title: "Webhook Deleted" }); inv(["GetRepoWebhooksEnabled", { repo: id }]); }, }); if (!webhooks || !webhooks.managed) return; return ( {webhooks.clone_enabled && (
Incoming webhook is{" "}
ENABLED
and will trigger
CLONE
} variant="destructive" onClick={() => deleteWebhook({ repo: id, action: Types.RepoWebhookAction.Clone, }) } loading={deletePending} disabled={disabled || deletePending} />
)} {!webhooks.clone_enabled && webhooks.pull_enabled && (
Incoming webhook is{" "}
ENABLED
and will trigger
PULL
} variant="destructive" onClick={() => deleteWebhook({ repo: id, action: Types.RepoWebhookAction.Pull, }) } loading={deletePending} disabled={disabled || deletePending} />
)} {webhooks.build_enabled && (
Incoming webhook is{" "}
ENABLED
and will trigger
BUILD
} variant="destructive" onClick={() => deleteWebhook({ repo: id, action: Types.RepoWebhookAction.Build, }) } loading={deletePending} disabled={disabled || deletePending} />
)} {!webhooks.clone_enabled && !webhooks.pull_enabled && !webhooks.build_enabled && (
Incoming webhook is{" "}
DISABLED
{(update.server_id ?? config.server_id) && ( } onClick={() => createWebhook({ repo: id, action: Types.RepoWebhookAction.Clone, }) } loading={createPending} disabled={disabled || createPending} /> )} {(update.server_id ?? config.server_id) && ( } onClick={() => createWebhook({ repo: id, action: Types.RepoWebhookAction.Pull, }) } loading={createPending} disabled={disabled || createPending} /> )} {(update.builder_id ?? config.builder_id) && ( } onClick={() => createWebhook({ repo: id, action: Types.RepoWebhookAction.Build, }) } loading={createPending} disabled={disabled || createPending} /> )}
)}
); }, }, }, { label: "Links", description: "Add quick links in the resource header", contentHidden: ((update.links ?? config.links)?.length ?? 0) === 0, actions: !disabled && ( ), components: { links: (values, set) => ( ), }, }, ], }} /> ); }; ================================================ FILE: frontend/src/components/resources/repo/index.tsx ================================================ import { useInvalidate, useRead, useWrite } from "@lib/hooks"; import { RequiredResourceComponents } from "@types"; import { Card } from "@ui/card"; import { GitBranch, Loader2, RefreshCcw } from "lucide-react"; import { RepoConfig } from "./config"; import { BuildRepo, CloneRepo, PullRepo } from "./actions"; import { DeleteResource, NewResource, ResourceLink, ResourcePageHeader, } from "../common"; import { RepoTable } from "./table"; import { repo_state_intention, stroke_color_class_by_intention, } from "@lib/color"; import { cn } from "@lib/utils"; import { useServer } from "../server"; import { Types } from "komodo_client"; import { DashboardPieChart } from "@pages/home/dashboard"; import { RepoLink, StatusBadge } from "@components/util"; import { Badge } from "@ui/badge"; import { useToast } from "@ui/use-toast"; import { Button } from "@ui/button"; import { useBuilder } from "../builder"; import { GroupActions } from "@components/group-actions"; import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip"; export const useRepo = (id?: string) => useRead("ListRepos", {}, { refetchInterval: 10_000 }).data?.find( (d) => d.id === id ); export const useFullRepo = (id: string) => useRead("GetRepo", { repo: id }, { refetchInterval: 10_000 }).data; const RepoIcon = ({ id, size }: { id?: string; size: number }) => { const state = useRepo(id)?.info.state; const color = stroke_color_class_by_intention(repo_state_intention(state)); return ; }; export const RepoComponents: RequiredResourceComponents = { list_item: (id) => useRepo(id), resource_links: (resource) => (resource.config as Types.RepoConfig).links, Description: () => <>Build using custom scripts. Or anything else., Dashboard: () => { const summary = useRead("GetReposSummary", {}).data; return ( ); }, New: ({ server_id }) => , GroupActions: () => ( ), Table: ({ resources }) => ( ), Icon: ({ id }) => , BigIcon: ({ id }) => , State: ({ id }) => { const state = useRepo(id)?.info.state; return ; }, Info: { Target: ({ id }) => { const info = useRepo(id)?.info; const server = useServer(info?.server_id); const builder = useBuilder(info?.builder_id); return (
{server?.id && (builder?.id ? (
) : ( ))} {builder?.id && }
); }, Source: ({ id }) => { const info = useRepo(id)?.info; if (!info) { return ; } return ; }, Branch: ({ id }) => { const branch = useRepo(id)?.info.branch; return (
{branch}
); }, }, Status: { Cloned: ({ id }) => { const info = useRepo(id)?.info; if (!info?.cloned_hash || info.cloned_hash === info.latest_hash) { return null; } return (
cloned: {info.cloned_hash}
commit message:
{info.cloned_message}
); }, Built: ({ id }) => { const info = useRepo(id)?.info; const fullInfo = useFullRepo(id)?.info; if (!info?.built_hash || info.built_hash === info.latest_hash) { return null; } return (
built: {info.built_hash}
commit message {fullInfo?.built_message}
); }, Latest: ({ id }) => { const info = useRepo(id)?.info; const fullInfo = useFullRepo(id)?.info; if (!info?.latest_hash) { return null; } return (
latest: {info.latest_hash}
commit message {fullInfo?.latest_message}
); }, Refresh: ({ id }) => { const { toast } = useToast(); const inv = useInvalidate(); const { mutate, isPending } = useWrite("RefreshRepoCache", { onSuccess: () => { inv(["ListRepos"], ["GetRepo", { repo: id }]); toast({ title: "Refreshed repo status cache" }); }, }); return ( ); }, }, Actions: { BuildRepo, PullRepo, CloneRepo }, Page: {}, Config: RepoConfig, DangerZone: ({ id }) => , ResourcePageHeader: ({ id }) => { const repo = useRepo(id); return ( } type="Repo" id={id} resource={repo} state={repo?.info.state} status="" /> ); }, }; ================================================ FILE: frontend/src/components/resources/repo/table.tsx ================================================ import { DataTable, SortableHeader } from "@ui/data-table"; import { ResourceLink } from "../common"; import { TableTags } from "@components/tags"; import { RepoComponents } from "."; import { Types } from "komodo_client"; import { useSelectedResources } from "@lib/hooks"; import { RepoLink } from "@components/util"; export const RepoTable = ({ repos }: { repos: Types.RepoListItem[] }) => { const [_, setSelectedResources] = useSelectedResources("Repo"); return ( name, onSelect: setSelectedResources, }} columns={[ { header: ({ column }) => ( ), accessorKey: "name", cell: ({ row }) => , size: 200, }, { header: ({ column }) => ( ), accessorKey: "info.repo", cell: ({ row }) => ( ), size: 200, }, { header: ({ column }) => ( ), accessorKey: "info.branch", size: 200, }, { header: ({ column }) => ( ), accessorKey: "info.state", cell: ({ row }) => , size: 120, }, { header: "Tags", cell: ({ row }) => , }, ]} /> ); }; ================================================ FILE: frontend/src/components/resources/resource-sync/actions.tsx ================================================ import { ActionButton, ActionWithDialog, ConfirmButton, } from "@components/util"; import { useExecute, useInvalidate, useRead, useWrite } from "@lib/hooks"; import { file_contents_empty, sync_no_changes } from "@lib/utils"; import { usePermissions } from "@lib/hooks"; import { NotebookPen, RefreshCcw, SquarePlay } from "lucide-react"; import { useFullResourceSync, useResourceSyncTabsView } from "."; export const RefreshSync = ({ id }: { id: string }) => { const inv = useInvalidate(); const { mutate, isPending } = useWrite("RefreshResourceSyncPending", { onSuccess: () => inv(["GetResourceSync"], ["ListResourceSyncs"]), }); const pending = isPending; return ( } onClick={() => mutate({ sync: id })} disabled={pending} loading={pending} /> ); }; export const ExecuteSync = ({ id }: { id: string }) => { const { mutate, isPending } = useExecute("RunSync"); const syncing = useRead( "GetResourceSyncActionState", { sync: id }, { refetchInterval: 5000 } ).data?.syncing; const sync = useFullResourceSync(id); const { view } = useResourceSyncTabsView(sync); if ( view !== "Execute" || !sync || sync_no_changes(sync) || !sync.info?.remote_contents ) { return null; } let all_empty = true; for (const contents of sync.info.remote_contents) { if (contents.contents.length > 0) { all_empty = false; break; } } if (all_empty) return null; const pending = isPending || syncing; return ( } onClick={() => mutate({ sync: id })} disabled={pending} loading={pending} /> ); }; export const CommitSync = ({ id }: { id: string }) => { const { mutate, isPending } = useWrite("CommitSync"); const sync = useFullResourceSync(id); const { view } = useResourceSyncTabsView(sync); const { canWrite } = usePermissions({ type: "ResourceSync", id }); if (view !== "Commit" || !canWrite || !sync) { return null; } const freshSync = !sync.config?.files_on_host && file_contents_empty(sync.config?.file_contents) && !sync.config?.repo && !sync.config?.linked_repo; if (!freshSync && (!sync.config?.managed || sync_no_changes(sync))) { return null; } if (freshSync) { return ( } onClick={() => mutate({ sync: id })} disabled={isPending} loading={isPending} /> ); } else { return ( } onClick={() => mutate({ sync: id })} disabled={isPending} loading={isPending} /> ); } }; ================================================ FILE: frontend/src/components/resources/resource-sync/config.tsx ================================================ import { Config, ConfigComponent } from "@components/config"; import { AccountSelectorConfig, ConfigItem, ConfigList, ConfigSwitch, ProviderSelectorConfig, WebhookBuilder, } from "@components/config/util"; import { getWebhookIntegration, useInvalidate, useLocalStorage, usePermissions, useRead, useWebhookIdOrName, useWebhookIntegrations, useWrite, } from "@lib/hooks"; import { Types } from "komodo_client"; import { ReactNode, useState } from "react"; import { CopyWebhook } from "../common"; import { useToast } from "@ui/use-toast"; import { text_color_class_by_intention } from "@lib/color"; import { ConfirmButton, ShowHideButton } from "@components/util"; import { Ban, CirclePlus, MinusCircle, SearchX, Tag } from "lucide-react"; import { MonacoEditor } from "@components/monaco"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { filterBySplit } from "@lib/utils"; import { Button } from "@ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@ui/command"; import { LinkedRepoConfig } from "@components/config/linked_repo"; type SyncMode = "UI Defined" | "Files On Server" | "Git Repo" | undefined; const SYNC_MODES: SyncMode[] = ["UI Defined", "Files On Server", "Git Repo"]; function getSyncMode( update: Partial, config: Types.ResourceSyncConfig ): SyncMode { if (update.files_on_host ?? config.files_on_host) return "Files On Server"; if ( (update.repo ?? config.repo) || (update.linked_repo ?? config.linked_repo) ) return "Git Repo"; if (update.file_contents ?? config.file_contents) return "UI Defined"; return undefined; } export const ResourceSyncConfig = ({ id, titleOther, }: { id: string; titleOther: ReactNode; }) => { const [show, setShow] = useLocalStorage(`sync-${id}-show`, { file: true, git: true, webhooks: true, }); const { canWrite } = usePermissions({ type: "ResourceSync", id }); const sync = useRead("GetResourceSync", { sync: id }).data; const config = sync?.config; const name = sync?.name; const webhooks = useRead("GetSyncWebhooksEnabled", { sync: id }).data; const global_disabled = useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false; const [update, set] = useLocalStorage>( `sync-${id}-update-v1`, {} ); const { mutateAsync } = useWrite("UpdateResourceSync"); const { integrations } = useWebhookIntegrations(); const [id_or_name] = useWebhookIdOrName(); if (!config) return null; const disabled = global_disabled || !canWrite; const git_provider = update.git_provider ?? config.git_provider; const webhook_integration = getWebhookIntegration(integrations, git_provider); const mode = getSyncMode(update, config); const managed = update.managed ?? config.managed ?? false; const setMode = (mode: SyncMode) => { if (mode === "Files On Server") { set({ ...update, files_on_host: true }); } else if (mode === "Git Repo") { set({ ...update, files_on_host: false, repo: update.repo || config.repo || "namespace/repo", }); } else if (mode === "UI Defined") { set({ ...update, files_on_host: false, repo: "", file_contents: update.file_contents || config.file_contents || "# Initialize the sync to import your current resources.\n", }); } else if (mode === undefined) { set({ ...update, files_on_host: false, repo: "", file_contents: "", }); } }; let components: Record< string, false | ConfigComponent[] | undefined > = {}; const choose_mode: ConfigComponent = { label: "Choose Mode", labelHidden: true, components: { file_contents: () => { return ( ); }, }, }; const general_common: ConfigComponent = { label: "General", components: { delete: (delete_mode, set) => { return ( set({ delete: delete_mode })} disabled={disabled || managed} /> ); }, managed: { label: "Managed", description: "Enabled managed mode / the 'Commit' button. Commit is the 'reverse' of Execute, and will update the sync file with your configs updated in the UI.", }, }, }; const include_toggles: ConfigComponent = { label: "Include", components: { include_resources: { label: "Sync Resources", description: "Include resources (servers, stacks, etc.) in the sync.", }, include_variables: { label: "Sync Variables", description: "Include variables in the sync.", }, include_user_groups: { label: "Sync User Groups", description: "Include user groups in the sync.", }, }, }; const include_resources = update.include_resources ?? config.include_resources; const match_tags: ConfigComponent = { label: "Match Tags", description: "Only sync resources matching all of these tags.", components: { match_tags: (values, set) => ( ), }, }; const pending_alerts: ConfigComponent = { label: "Alerts", components: { pending_alert: { label: "Pending Alerts", description: "Send a message to your Alerters when the Sync has Pending Changes", }, }, }; if (mode === undefined) { components = { "": [choose_mode], }; } else if (mode === "Files On Server") { components = { "": [ { label: "General", components: { resource_path: (values, set) => ( ), ...general_common.components, }, }, match_tags, include_toggles, pending_alerts, ], }; } else if (mode === "Git Repo") { const repo_linked = !!(update.linked_repo ?? config.linked_repo); const source_config: ConfigComponent = { label: "Source", contentHidden: !show.git, actions: ( setShow({ ...show, git })} /> ), components: { linked_repo: (linked_repo, set) => ( ), ...(!repo_linked ? { git_provider: (provider: string | undefined, set) => { const https = update.git_https ?? config.git_https; return ( set({ git_provider })} https={https} onHttpsSwitch={() => set({ git_https: !https })} /> ); }, git_account: (value: string | undefined, set) => { return ( set({ git_account })} disabled={disabled} placeholder="None" /> ); }, repo: { placeholder: "Enter repo", description: "The repo path on the provider. {namespace}/{repo_name}", }, branch: { placeholder: "Enter branch", description: "Select a custom branch, or default to 'main'.", }, commit: { label: "Commit Hash", placeholder: "Input commit hash", description: "Optional. Switch to a specific commit hash after cloning the branch.", }, } : {}), }, }; const webhooks_config: ConfigComponent = { label: "Git Webhooks", description: `Copy the webhook given here, and configure your ${webhook_integration}-style repo provider to send webhooks to Komodo`, contentHidden: !show.webhooks, actions: ( setShow({ ...show, webhooks })} /> ), components: { ["Guard" as any]: () => { if (update.branch ?? config.branch) { return null; } return (
Must configure Branch before webhooks will work.
); }, ["Builder" as any]: () => ( ), ["Refresh" as any]: () => ( ), ["Sync" as any]: () => ( ), webhook_enabled: webhooks !== undefined && !webhooks.managed, webhook_secret: { description: "Provide a custom webhook secret for this resource, or use the global default.", placeholder: "Input custom secret", }, ["managed" as any]: () => { const inv = useInvalidate(); const { toast } = useToast(); const { mutate: createWebhook, isPending: createPending } = useWrite( "CreateSyncWebhook", { onSuccess: () => { toast({ title: "Webhook Created" }); inv(["GetSyncWebhooksEnabled", { sync: id }]); }, } ); const { mutate: deleteWebhook, isPending: deletePending } = useWrite( "DeleteSyncWebhook", { onSuccess: () => { toast({ title: "Webhook Deleted" }); inv(["GetSyncWebhooksEnabled", { sync: id }]); }, } ); if (!webhooks || !webhooks.managed) return; return ( {webhooks.sync_enabled && (
Incoming webhook is{" "}
ENABLED
and will trigger
SYNC EXECUTION
} variant="destructive" onClick={() => deleteWebhook({ sync: id, action: Types.SyncWebhookAction.Sync, }) } loading={deletePending} disabled={disabled || deletePending} />
)} {!webhooks.sync_enabled && webhooks.refresh_enabled && (
Incoming webhook is{" "}
ENABLED
and will trigger
PENDING REFRESH
} variant="destructive" onClick={() => deleteWebhook({ sync: id, action: Types.SyncWebhookAction.Refresh, }) } loading={deletePending} disabled={disabled || deletePending} />
)} {!webhooks.sync_enabled && !webhooks.refresh_enabled && (
Incoming webhook is{" "}
DISABLED
} onClick={() => createWebhook({ sync: id, action: Types.SyncWebhookAction.Refresh, }) } loading={createPending} disabled={disabled || createPending} /> } onClick={() => createWebhook({ sync: id, action: Types.SyncWebhookAction.Sync, }) } loading={createPending} disabled={disabled || createPending} />
)}
); }, }, }; components = { "": [ source_config, { label: "General", components: { resource_path: (values, set) => ( ), ...general_common.components, }, }, match_tags, include_toggles, pending_alerts, webhooks_config, ], }; } else if (mode === "UI Defined") { components = { "": [ { label: "Resource File", description: "Manage the resource file contents here, or use a git repo / the files on host option.", actions: ( setShow((show) => ({ ...show, file }))} /> ), contentHidden: !show.file, components: { file_contents: (file_contents, set) => { return ( set({ file_contents })} language="fancy_toml" readOnly={disabled} /> ); }, }, }, general_common, match_tags, include_toggles, pending_alerts, ], }; } return ( { await mutateAsync({ id, config: update }); }} components={components} file_contents_language="fancy_toml" /> ); }; const MatchTags = ({ tags, set, disabled, }: { tags: string[]; set: (update: Partial) => void; disabled: boolean; }) => { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const all_tags = useRead("ListTags", {}).data; const filtered = filterBySplit(all_tags, search, (item) => item.name); return (
{ setSearch(""); setOpen(open); }} > No Tags Found {filtered ?.filter((tag) => !tags.includes(tag.name)) .map((tag) => ( { set({ match_tags: [...tags, tag.name] }); setSearch(""); setOpen(false); }} className="flex items-center justify-between cursor-pointer" >
{tag.name}
))}
set({ match_tags: tags.filter((name) => name !== tag) }) } disabled={disabled} />
); }; const MatchTagsTags = ({ tags, onBadgeClick, disabled, }: { tags?: string[]; onBadgeClick: (tag: string) => void; disabled: boolean; }) => { return ( <> {tags?.map((tag) => ( ))} ); }; ================================================ FILE: frontend/src/components/resources/resource-sync/index.tsx ================================================ import { atomWithStorage, useRead, useUser } from "@lib/hooks"; import { RequiredResourceComponents } from "@types"; import { Card } from "@ui/card"; import { Clock, FolderSync } from "lucide-react"; import { DeleteResource, NewResource, ResourcePageHeader, StandardSource, } from "../common"; import { ResourceSyncTable } from "./table"; import { Types } from "komodo_client"; import { CommitSync, ExecuteSync, RefreshSync } from "./actions"; import { border_color_class_by_intention, resource_sync_state_intention, stroke_color_class_by_intention, } from "@lib/color"; import { cn, sync_no_changes } from "@lib/utils"; import { fmt_date } from "@lib/formatting"; import { DashboardPieChart } from "@pages/home/dashboard"; import { StatusBadge } from "@components/util"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/tabs"; import { ResourceSyncConfig } from "./config"; import { ResourceSyncInfo } from "./info"; import { ResourceSyncPending } from "./pending"; import { Badge } from "@ui/badge"; import { GroupActions } from "@components/group-actions"; import { useAtom } from "jotai"; import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip"; export const useResourceSync = (id?: string) => useRead("ListResourceSyncs", {}, { refetchInterval: 10_000 }).data?.find( (d) => d.id === id ); export const useFullResourceSync = (id: string) => useRead("GetResourceSync", { sync: id }, { refetchInterval: 10_000 }).data; const ResourceSyncIcon = ({ id, size }: { id?: string; size: number }) => { const state = useResourceSync(id)?.info.state; const color = stroke_color_class_by_intention( resource_sync_state_intention(state) ); return ; }; type ResourceSyncTabsView = "Config" | "Info" | "Execute" | "Commit"; const syncTabsViewAtom = atomWithStorage( "sync-tabs-v4", "Config" ); export const useResourceSyncTabsView = ( sync: Types.ResourceSync | undefined ) => { const [_view, setView] = useAtom(syncTabsViewAtom); const hideInfo = sync?.config?.files_on_host ? false : sync?.config?.file_contents ? true : false; const showPending = sync && (!sync_no_changes(sync) || sync.info?.pending_error); const view = _view === "Info" && hideInfo ? "Config" : (_view === "Execute" || _view === "Commit") && !showPending ? sync?.config?.files_on_host || sync?.config?.repo || sync?.config?.linked_repo ? "Info" : "Config" : _view === "Commit" && !sync?.config?.managed ? "Execute" : _view; return { view, setView, hideInfo, showPending, }; }; const ConfigInfoPending = ({ id }: { id: string }) => { const sync = useFullResourceSync(id); const { view, setView, hideInfo, showPending } = useResourceSyncTabsView(sync); const title = ( Config Info Execute {sync?.config?.managed && ( Commit )} ); return ( ); }; export const ResourceSyncComponents: RequiredResourceComponents = { list_item: (id) => useResourceSync(id), resource_links: () => undefined, Description: () => <>Declare resources in TOML files., Dashboard: () => { const summary = useRead("GetResourceSyncsSummary", {}).data; return ( ); }, New: () => { const admin = useUser().data?.admin; return ( admin && ); }, GroupActions: () => ( ), Table: ({ resources }) => ( ), Icon: ({ id }) => , BigIcon: ({ id }) => , State: ({ id }) => { const state = useResourceSync(id)?.info.state; return ( ); }, Info: { Source: ({ id }) => { const info = useResourceSync(id)?.info; return ; }, LastSync: ({ id }) => { const last_ts = useResourceSync(id)?.info.last_sync_ts; return (
{last_ts ? fmt_date(new Date(last_ts)) : "Never"}
); }, }, Status: { Hash: ({ id }) => { const info = useFullResourceSync(id)?.info; if (!info?.pending_hash) { return null; } const out_of_date = info.last_sync_hash && info.last_sync_hash !== info.pending_hash; return (
{info.last_sync_hash ? "synced" : "latest"}:{" "} {info.last_sync_hash || info.pending_hash}
message {info.last_sync_message || info.pending_message} {out_of_date && ( <> latest
{info.pending_hash} : {info.pending_message}
)}
); }, }, Actions: { RefreshSync, ExecuteSync, CommitSync }, Page: {}, Config: ConfigInfoPending, DangerZone: ({ id }) => , ResourcePageHeader: ({ id }) => { const sync = useResourceSync(id); return ( } type="ResourceSync" id={id} resource={sync} state={sync?.info.state} status="" /> ); }, }; ================================================ FILE: frontend/src/components/resources/resource-sync/info.tsx ================================================ import { Section } from "@components/layouts"; import { ReactNode, useState } from "react"; import { Card, CardContent, CardHeader } from "@ui/card"; import { useFullResourceSync } from "."; import { cn, updateLogToHtml } from "@lib/utils"; import { MonacoEditor } from "@components/monaco"; import { usePermissions } from "@lib/hooks"; import { useLocalStorage, useWrite } from "@lib/hooks"; import { useToast } from "@ui/use-toast"; import { Button } from "@ui/button"; import { FilePlus, History } from "lucide-react"; import { ConfirmUpdate } from "@components/config/util"; import { ConfirmButton, ShowHideButton } from "@components/util"; export const ResourceSyncInfo = ({ id, titleOther, }: { id: string; titleOther: ReactNode; }) => { const [edits, setEdits] = useLocalStorage>( `sync-${id}-edits`, {} ); const [show, setShow] = useState>({}); const { canWrite } = usePermissions({ type: "ResourceSync", id }); const { toast } = useToast(); const { mutateAsync, isPending } = useWrite("WriteSyncFileContents", { onSuccess: (res) => { toast({ title: res.success ? "Contents written." : "Failed to write contents.", variant: res.success ? undefined : "destructive", }); }, }); const sync = useFullResourceSync(id); const file_on_host = sync?.config?.files_on_host ?? false; const git_repo = sync?.config?.repo || sync?.config?.linked_repo ? true : false; const canEdit = canWrite && (file_on_host || git_repo); const editFileCallback = (keyPath: string) => (contents: string) => setEdits({ ...edits, [keyPath]: contents }); const latest_contents = sync?.info?.remote_contents; const latest_errors = sync?.info?.remote_errors; // Contents will be default hidden if there is more than 2 file editor to show const default_show_contents = !latest_contents || latest_contents.length < 3; return (
{/* Errors */} {latest_errors && latest_errors.length > 0 && latest_errors.map((error) => (
{error.resource_path && ( <>
Folder:
{error.resource_path}
|
)}
Path:
{error.path}
{canEdit && ( } onClick={() => { if (sync) { mutateAsync({ sync: sync.name, resource_path: error.resource_path ?? "", file_path: error.path, contents: "## Add resources to get started\n", }); } }} loading={isPending} disabled={!canEdit} /> )}
            
          
        ))}

      {/* Update latest contents */}
      {latest_contents &&
        latest_contents.length > 0 &&
        latest_contents.map((content) => {
          const keyPath = content.resource_path + "/" + content.path;
          const showContents = show[keyPath] ?? default_show_contents;
          return (
            
              
                
{content.resource_path && ( <>
Folder:
{content.resource_path}
|
)}
File:
{content.path}
{canEdit && ( <> { if (sync) { return await mutateAsync({ sync: sync.name, resource_path: content.resource_path ?? "", file_path: content.path, contents: edits[keyPath]!, }).then(() => setEdits({ ...edits, [keyPath]: undefined }) ); } }} disabled={!edits[keyPath]} language="fancy_toml" loading={isPending} /> )} setShow({ ...show, [keyPath]: val })} />
{showContents && ( )}
); })}
); }; ================================================ FILE: frontend/src/components/resources/resource-sync/pending.tsx ================================================ import { Section } from "@components/layouts"; import { MonacoDiffEditor, MonacoEditor } from "@components/monaco"; import { useExecute, useRead } from "@lib/hooks"; import { Card, CardContent, CardHeader } from "@ui/card"; import { ReactNode } from "react"; import { ResourceLink } from "../common"; import { UsableResource } from "@types"; import { diff_type_intention, text_color_class_by_intention } from "@lib/color"; import { cn, sanitizeOnlySpan } from "@lib/utils"; import { ConfirmButton } from "@components/util"; import { SquarePlay } from "lucide-react"; import { usePermissions } from "@lib/hooks"; import { useFullResourceSync, useResourceSyncTabsView } from "."; import { ResourceDiff } from "komodo_client/dist/types"; export const ResourceSyncPending = ({ id, titleOther, }: { id: string; titleOther: ReactNode; }) => { const syncing = useRead("GetResourceSyncActionState", { sync: id }).data ?.syncing; const sync = useFullResourceSync(id); const { view } = useResourceSyncTabsView(sync); const { canExecute } = usePermissions({ type: "ResourceSync", id }); const { mutate, isPending } = useExecute("RunSync"); const loading = isPending || syncing; return (
{view} Mode:
{view === "Execute" && ( <> Update resources in the
UI
to match the
file changes.
)} {view === "Commit" && ( <> Update resources in the
file
to match the
UI changes.
)}
{/* Pending Error */} {sync?.info?.pending_error && sync.info.pending_error.length ? ( Error
          
        
      ) : undefined}

      {/* Pending Deploy */}
      {view === "Execute" && sync?.info?.pending_deploy?.to_deploy ? (
        
          
            Deploy {sync.info.pending_deploy.to_deploy} Resource
            {sync.info.pending_deploy.to_deploy > 1 ? "s" : ""}
          
          
            
          
        
      ) : undefined}

      {/* Pending Resource Update */}
      {sync?.info?.resource_updates?.map((update) => {
        return (
          
            
              
{view === "Commit" ? reverse_pending_type(update.data.type) : update.data.type}{" "} {update.target.type}
|
{update.data.type === "Create" ? (
{update.data.data.name}
) : ( )}
{canExecute && view === "Execute" && ( } onClick={() => mutate({ sync: id, resource_type: update.target.type, resources: [ update.data.type === "Create" ? update.data.data.name! : update.target.id, ], }) } loading={loading} /> )}
{update.data.type === "Create" && ( )} {update.data.type === "Update" && ( <> {view === "Execute" && ( )} {view === "Commit" && ( )} )} {update.data.type === "Delete" && ( )}
); })} {/* Pending Variable Update */} {sync?.info?.variable_updates?.map((data, i) => { return ( {view === "Commit" ? reverse_pending_type(data.type) : data.type}{" "} Variable {data.type === "Create" && ( )} {data.type === "Update" && ( <> {view === "Execute" && ( )} {view === "Commit" && ( )} )} {data.type === "Delete" && ( )} ); })} {/* Pending User Group Update */} {sync?.info?.user_group_updates?.map((data, i) => { return ( {view === "Commit" ? reverse_pending_type(data.type) : data.type}{" "} User Group {data.type === "Create" && ( )} {data.type === "Update" && ( <> {view === "Execute" && ( )} {view === "Commit" && ( )} )} {data.type === "Delete" && ( )} ); })}
); }; const reverse_pending_type = (type: ResourceDiff["data"]["type"]) => { switch (type) { case "Create": return "Remove"; case "Update": return "Update"; case "Delete": return "Add"; } }; ================================================ FILE: frontend/src/components/resources/resource-sync/table.tsx ================================================ import { DataTable, SortableHeader } from "@ui/data-table"; import { ResourceLink, StandardSource } from "../common"; import { TableTags } from "@components/tags"; import { Types } from "komodo_client"; import { ResourceSyncComponents } from "."; import { useSelectedResources } from "@lib/hooks"; export const ResourceSyncTable = ({ syncs, }: { syncs: Types.ResourceSyncListItem[]; }) => { const [_, setSelectedResources] = useSelectedResources("ResourceSync"); return ( name, onSelect: setSelectedResources, }} columns={[ { header: ({ column }) => ( ), accessorKey: "name", cell: ({ row }) => ( ), size: 200, }, { header: ({ column }) => ( ), accessorKey: "info.repo", cell: ({ row }) => , size: 200, }, { header: ({ column }) => ( ), accessorKey: "info.branch", size: 200, }, { header: ({ column }) => ( ), accessorKey: "info.state", cell: ({ row }) => ( ), size: 120, }, { header: "Tags", cell: ({ row }) => , }, ]} /> ); }; ================================================ FILE: frontend/src/components/resources/server/actions.tsx ================================================ import { ActionWithDialog, ConfirmButton } from "@components/util"; import { useExecute, usePermissions, useRead } from "@lib/hooks"; import { Scissors } from "lucide-react"; import { useServer } from "."; export const Prune = ({ server_id, type, }: { server_id: string; type: "Containers" | "Networks" | "Images" | "Volumes" | "Buildx" | "System"; }) => { const server = useServer(server_id); const { mutate, isPending } = useExecute(`Prune${type}`); const action_state = useRead( "GetServerActionState", { server: server_id }, { refetchInterval: 5000 } ).data; const { canExecute } = usePermissions({ type: "Server", id: server_id }); if (!server) return; const pruningKey = type === "Containers" ? "pruning_containers" : type === "Images" ? "pruning_images" : type === "Networks" ? "pruning_networks" : type === "Volumes" ? "pruning_volumes" : type === "Buildx" ? "pruning_buildx" : type === "System" ? "pruning_system" : ""; const pending = isPending || action_state?.[pruningKey]; if (type === "Images" || type === "Networks" || type === "Buildx") { return ( } onClick={() => mutate({ server: server_id })} loading={pending} disabled={!canExecute || pending} /> ); } else { return ( } onClick={() => mutate({ server: server_id })} loading={pending} disabled={!canExecute || pending} /> ); } }; ================================================ FILE: frontend/src/components/resources/server/config.tsx ================================================ import { Config } from "@components/config"; import { MaintenanceWindows } from "@components/config/maintenance"; import { ConfigList } from "@components/config/util"; import { useInvalidate, useLocalStorage, usePermissions, useRead, useWrite, } from "@lib/hooks"; import { Types } from "komodo_client"; import { ReactNode } from "react"; export const ServerConfig = ({ id, titleOther, }: { id: string; titleOther: ReactNode; }) => { const { canWrite } = usePermissions({ type: "Server", id }); const invalidate = useInvalidate(); const config = useRead("GetServer", { server: id }).data?.config; const global_disabled = useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false; const [update, set] = useLocalStorage>( `server-${id}-update-v1`, {} ); const { mutateAsync } = useWrite("UpdateServer", { onSuccess: () => { // In case of disabling to resolve unreachable alert invalidate(["ListAlerts"]); }, }); if (!config) return null; const disabled = global_disabled || !canWrite; return ( { await mutateAsync({ id, config: update }); }} components={{ "": [ { label: "Enabled", labelHidden: true, components: { enabled: { description: "Whether to attempt to connect to this host / send alerts if offline. Disabling will also convert all attached resource's state to 'Unknown'.", }, }, }, { label: "Address", labelHidden: true, components: { address: { description: "The http/s address of periphery in your network, eg. https://12.34.56.78:8120", placeholder: "https://12.34.56.78:8120", }, external_address: { description: "Optional. The address of the server used in container links, if different than the Address.", placeholder: "https://my.server.int", }, region: { placeholder: "Region. Optional.", description: "Attach a region to the server for visual grouping.", }, }, }, { label: "Timeout", labelHidden: true, components: { timeout_seconds: { description: "The timeout used with the server health check, in seconds.", }, }, }, { label: "Disks", labelHidden: true, components: { ignore_mounts: (values, set) => ( ), }, }, { label: "Monitoring", labelHidden: true, components: { stats_monitoring: { label: "System Stats Monitoring", // boldLabel: true, description: "Whether to store historical CPU, RAM, and disk usage.", }, }, }, { label: "Pruning", labelHidden: true, components: { auto_prune: { label: "Auto Prune Images", // boldLabel: true, description: "Whether to prune unused images every day at UTC 00:00", }, }, }, ], alerts: [ { label: "Unreachable", labelHidden: true, components: { send_unreachable_alerts: { // boldLabel: true, description: "Send an alert if the Periphery agent cannot be reached.", }, }, }, { label: "Version", labelHidden: true, components: { send_version_mismatch_alerts: { label: "Send Version Mismatch Alerts", description: "Send an alert if the Periphery version differs from the Core version.", }, }, }, { label: "CPU", labelHidden: true, components: { send_cpu_alerts: { label: "Send CPU Alerts", // boldLabel: true, description: "Send an alert if the CPU usage is above the configured thresholds.", }, cpu_warning: { description: "Send a 'Warning' alert if the CPU usage in % is above these thresholds", }, cpu_critical: { description: "Send a 'Critical' alert if the CPU usage in % is above these thresholds", }, }, }, { label: "Memory", labelHidden: true, components: { send_mem_alerts: { label: "Send Memory Alerts", // boldLabel: true, description: "Send an alert if the memory usage is above the configured thresholds.", }, mem_warning: { label: "Memory Warning", description: "Send a 'Warning' alert if the memory usage in % is above these thresholds", }, mem_critical: { label: "Memory Critical", description: "Send a 'Critical' alert if the memory usage in % is above these thresholds", }, }, }, { label: "Disk", labelHidden: true, components: { send_disk_alerts: { // boldLabel: true, description: "Send an alert if the Disk Usage (for any mounted disk) is above the configured thresholds.", }, disk_warning: { description: "Send a 'Warning' alert if the disk usage in % is above these thresholds", }, disk_critical: { description: "Send a 'Critical' alert if the disk usage in % is above these thresholds", }, }, }, { label: "Maintenance", boldLabel: false, description: ( <> Configure maintenance windows to temporarily disable alerts during scheduled maintenance periods. When a maintenance window is active, alerts from this server will be suppressed. ), components: { maintenance_windows: (values, set) => { return ( set({ maintenance_windows }) } disabled={disabled} /> ); }, }, }, ], }} /> ); }; ================================================ FILE: frontend/src/components/resources/server/hooks.ts ================================================ import { atomWithStorage } from "@lib/hooks"; import { Types } from "komodo_client"; import { useAtom } from "jotai"; const statsGranularityAtom = atomWithStorage( "stats-granularity-v0", Types.Timelength.FiveMinutes ); export const useStatsGranularity = () => useAtom(statsGranularityAtom); ================================================ FILE: frontend/src/components/resources/server/index.tsx ================================================ import { useExecute, useLocalStorage, useRead, useUser } from "@lib/hooks"; import { cn } from "@lib/utils"; import { Types } from "komodo_client"; import { RequiredResourceComponents } from "@types"; import { Server, Cpu, MemoryStick, Database, Play, RefreshCcw, Pause, Square, AlertCircle, CheckCircle2, } from "lucide-react"; import { Section } from "@components/layouts"; import { Prune } from "./actions"; import { server_state_intention, stroke_color_class_by_intention, } from "@lib/color"; import { ServerConfig } from "./config"; import { DeploymentTable } from "../deployment/table"; import { ServerTable } from "./table"; import { DeleteResource, NewResource, ResourcePageHeader } from "../common"; import { ActionWithDialog, ConfirmButton, StatusBadge } from "@components/util"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/tabs"; import { Card, CardHeader, CardTitle } from "@ui/card"; import { RepoTable } from "../repo/table"; import { DashboardPieChart } from "@pages/home/dashboard"; import { StackTable } from "../stack/table"; import { ResourceComponents } from ".."; import { ServerInfo } from "./info"; import { ServerStats } from "./stats"; import { ServerStatsMini } from "./stats-mini"; import { GroupActions } from "@components/group-actions"; import { ServerTerminals } from "@components/terminal/server"; import { usePermissions } from "@lib/hooks"; import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip"; export const useServer = (id?: string) => useRead("ListServers", {}, { refetchInterval: 10_000 }).data?.find( (d) => d.id === id ); // Helper function to check if server is available for API calls export const useIsServerAvailable = (serverId?: string) => { const server = useServer(serverId); return server?.info.state === Types.ServerState.Ok; }; export const useFullServer = (id: string) => useRead("GetServer", { server: id }, { refetchInterval: 10_000 }).data; // Helper function to check for version mismatch export const useVersionMismatch = (serverId?: string) => { const core_version = useRead("GetVersion", {}).data?.version; const server_version = useServer(serverId)?.info.version; const unknown = !server_version || server_version === "Unknown"; const mismatch = !!server_version && !!core_version && server_version !== core_version; return { unknown, mismatch, hasVersionMismatch: mismatch && !unknown }; }; const Icon = ({ id, size }: { id?: string; size: number }) => { const state = useServer(id)?.info.state; const { hasVersionMismatch } = useVersionMismatch(id); return ( ); }; const ConfigTabs = ({ id }: { id: string }) => { const [view, setView] = useLocalStorage< "Config" | "Stats" | "Docker" | "Resources" | "Terminals" >(`server-${id}-tab`, "Config"); const is_admin = useUser().data?.admin ?? false; const { canWrite } = usePermissions({ type: "Server", id }); const server_info = useServer(id)?.info; const terminals_disabled = server_info?.terminals_disabled ?? true; const container_exec_disabled = server_info?.container_exec_disabled ?? true; const disable_non_admin_create = useRead("GetCoreInfo", {}).data?.disable_non_admin_create ?? true; const deployments = useRead("ListDeployments", {}).data?.filter( (deployment) => deployment.info.server_id === id ) ?? []; const noDeployments = deployments.length === 0; const repos = useRead("ListRepos", {}).data?.filter( (repo) => repo.info.server_id === id ) ?? []; const noRepos = repos.length === 0; const stacks = useRead("ListStacks", {}).data?.filter( (stack) => stack.info.server_id === id ) ?? []; const noStacks = stacks.length === 0; const noResources = noDeployments && noRepos && noStacks; const currentView = view === "Resources" && noResources ? "Config" : view; const tabsList = ( Config Stats Docker Resources {(!terminals_disabled || !container_exec_disabled) && canWrite && ( Terminals )} ); return (
) } >
) } >
) } >
{(!terminals_disabled || !container_exec_disabled) && canWrite && ( )} {terminals_disabled && container_exec_disabled && canWrite && (
Terminals are disabled on this Server.
)} {!canWrite && (
User does not have permission to use Terminals.
)}
); }; export const ServerVersion = ({ id }: { id: string }) => { const core_version = useRead("GetVersion", {}).data?.version; const version = useServer(id)?.info.version; const server_state = useServer(id)?.info.state; const unknown = !version || version === "Unknown"; const mismatch = !!version && !!core_version && version !== core_version; // Don't show version for disabled servers if (server_state === Types.ServerState.Disabled) { return (
Unknown
Server is disabled - version unknown.
); } return (
{unknown ? ( ) : mismatch ? ( ) : ( )} {version ?? "Unknown"}
{unknown ? (
Periphery version is unknown.
) : mismatch ? (
Periphery version mismatch. Expected {core_version}.
) : (
Periphery and Core version match.
)}
); }; export { ServerStatsMini }; export const ServerComponents: RequiredResourceComponents = { list_item: (id) => useServer(id), resource_links: (resource) => (resource.config as Types.ServerConfig).links, Description: () => ( <>Connect servers for alerting, building, and deploying. ), Dashboard: () => { const summary = useRead( "GetServersSummary", {}, { refetchInterval: 15_000 } ).data; return ( ); }, New: () => { const user = useUser().data; if (!user) return null; if (!user.admin && !user.create_server_permissions) return null; return ; }, GroupActions: () => ( ), Table: ({ resources }) => ( ), Icon: ({ id }) => , BigIcon: ({ id }) => , State: ({ id }) => { const state = useServer(id)?.info.state; const { hasVersionMismatch } = useVersionMismatch(id); // Show full version mismatch text const displayState = state === Types.ServerState.Ok && hasVersionMismatch ? "Version Mismatch" : state === Types.ServerState.NotOk ? "Not Ok" : state; return ( ); }, Status: {}, Info: { Version: ServerVersion, Cpu: ({ id }) => { const isServerAvailable = useIsServerAvailable(id); const core_count = useRead( "GetSystemInformation", { server: id }, { enabled: isServerAvailable, refetchInterval: 5000, } ).data?.core_count ?? 0; return (
{core_count || "N/A"} Core{core_count > 1 ? "s" : ""}
); }, LoadAvg: ({ id }) => { const isServerAvailable = useIsServerAvailable(id); const stats = useRead( "GetSystemStats", { server: id }, { enabled: isServerAvailable, refetchInterval: 5000, } ).data; if (!stats?.load_average) return null; const one = stats.load_average?.one; return (
{one.toFixed(2)}
); }, Mem: ({ id }) => { const isServerAvailable = useIsServerAvailable(id); const stats = useRead( "GetSystemStats", { server: id }, { enabled: isServerAvailable, refetchInterval: 5000, } ).data; return (
{stats?.mem_total_gb.toFixed(2) ?? "N/A"} GB
); }, Disk: ({ id }) => { const isServerAvailable = useIsServerAvailable(id); const stats = useRead( "GetSystemStats", { server: id }, { enabled: isServerAvailable, refetchInterval: 5000, } ).data; const disk_total_gb = stats?.disks.reduce( (acc, curr) => acc + curr.total_gb, 0 ); return (
{disk_total_gb?.toFixed(2) ?? "N/A"} GB
); }, }, Actions: { StartAll: ({ id }) => { const server = useServer(id); const { mutate, isPending } = useExecute("StartAllContainers"); const starting = useRead( "GetServerActionState", { server: id }, { refetchInterval: 5000 } ).data?.starting_containers; const dontShow = useRead("ListDockerContainers", { server: id, }).data?.every( (container) => container.state === Types.ContainerStateStatusEnum.Running ) ?? true; if (dontShow) { return null; } const pending = isPending || starting; return ( server && ( } onClick={() => mutate({ server: id })} loading={pending} disabled={pending} /> ) ); }, RestartAll: ({ id }) => { const server = useServer(id); const { mutate, isPending } = useExecute("RestartAllContainers"); const restarting = useRead( "GetServerActionState", { server: id }, { refetchInterval: 5000 } ).data?.restarting_containers; const pending = isPending || restarting; return ( server && ( } onClick={() => mutate({ server: id })} disabled={pending} loading={pending} /> ) ); }, PauseAll: ({ id }) => { const server = useServer(id); const { mutate, isPending } = useExecute("PauseAllContainers"); const pausing = useRead( "GetServerActionState", { server: id }, { refetchInterval: 5000 } ).data?.pausing_containers; const dontShow = useRead("ListDockerContainers", { server: id, }).data?.every( (container) => container.state !== Types.ContainerStateStatusEnum.Running ) ?? true; if (dontShow) { return null; } const pending = isPending || pausing; return ( server && ( } onClick={() => mutate({ server: id })} disabled={pending} loading={pending} /> ) ); }, UnpauseAll: ({ id }) => { const server = useServer(id); const { mutate, isPending } = useExecute("UnpauseAllContainers"); const unpausing = useRead( "GetServerActionState", { server: id }, { refetchInterval: 5000 } ).data?.unpausing_containers; const dontShow = useRead("ListDockerContainers", { server: id, }).data?.every( (container) => container.state !== Types.ContainerStateStatusEnum.Paused ) ?? true; if (dontShow) { return null; } const pending = isPending || unpausing; return ( server && ( } onClick={() => mutate({ server: id })} loading={pending} disabled={pending} /> ) ); }, StopAll: ({ id }) => { const server = useServer(id); const { mutate, isPending } = useExecute("StopAllContainers"); const stopping = useRead( "GetServerActionState", { server: id }, { refetchInterval: 5000 } ).data?.stopping_containers; const pending = isPending || stopping; return ( server && ( } onClick={() => mutate({ server: id })} disabled={pending} loading={pending} /> ) ); }, PruneBuildx: ({ id }) => , PruneSystem: ({ id }) => , }, Page: {}, Config: ConfigTabs, DangerZone: ({ id }) => , ResourcePageHeader: ({ id }) => { const server = useServer(id); const { hasVersionMismatch } = useVersionMismatch(id); // Determine display state for header (longer text is okay in header) const displayState = server?.info.state === Types.ServerState.Ok && hasVersionMismatch ? "Version Mismatch" : server?.info.state === Types.ServerState.NotOk ? "Not Ok" : server?.info.state; return ( } type="Server" id={id} resource={server} state={displayState} status={server?.info.region} /> ); }, }; ================================================ FILE: frontend/src/components/resources/server/info/containers.tsx ================================================ import { DockerContainersSection } from "@components/util"; import { useRead } from "@lib/hooks"; import { Dispatch, ReactNode, SetStateAction } from "react"; export const Containers = ({ id, titleOther, _search, }: { id: string; titleOther: ReactNode; _search: [string, Dispatch>]; }) => { const containers = useRead("ListDockerContainers", { server: id }, { refetchInterval: 10_000 }) .data ?? []; return ( ); }; ================================================ FILE: frontend/src/components/resources/server/info/images.tsx ================================================ import { Section } from "@components/layouts"; import { DockerResourceLink } from "@components/util"; import { format_size_bytes } from "@lib/formatting"; import { useRead } from "@lib/hooks"; import { Badge } from "@ui/badge"; import { DataTable, SortableHeader } from "@ui/data-table"; import { Dispatch, ReactNode, SetStateAction } from "react"; import { Prune } from "../actions"; import { Search } from "lucide-react"; import { Input } from "@ui/input"; import { filterBySplit } from "@lib/utils"; export const Images = ({ id, titleOther, _search, }: { id: string; titleOther: ReactNode; _search: [string, Dispatch>]; }) => { const [search, setSearch] = _search; const images = useRead("ListDockerImages", { server: id }, { refetchInterval: 10_000 }) .data ?? []; const allInUse = images.every((image) => image.in_use); const filtered = filterBySplit(images, search, (image) => image.name); return (
{!allInUse && }
setSearch(e.target.value)} placeholder="search..." className="pl-8 w-[200px] lg:w-[300px]" />
} > ( ), cell: ({ row }) => ( Unused ) } /> ), size: 200, }, { accessorKey: "id", header: ({ column }) => ( ), }, { accessorKey: "size", header: ({ column }) => ( ), cell: ({ row }) => row.original.size ? format_size_bytes(row.original.size) : "Unknown", }, ]} />
); }; ================================================ FILE: frontend/src/components/resources/server/info/index.tsx ================================================ import { Section } from "@components/layouts"; import { ReactNode, useState } from "react"; import { Networks } from "./networks"; import { useServer } from ".."; import { Types } from "komodo_client"; import { useLocalStorage } from "@lib/hooks"; import { Images } from "./images"; import { Containers } from "./containers"; import { Volumes } from "./volumes"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/tabs"; export const ServerInfo = ({ id, titleOther, }: { id: string; titleOther: ReactNode; }) => { const _search = useState(""); const state = useServer(id)?.info.state ?? Types.ServerState.NotOk; const [show2, setShow2] = useLocalStorage< "Containers" | "Networks" | "Volumes" | "Images" >("server-info-show-config-v2", "Containers"); if ([Types.ServerState.NotOk, Types.ServerState.Disabled].includes(state)) { return (

Server unreachable, info is not available

); } const tabsList = ( Containers Networks Volumes Images ); return (
); }; ================================================ FILE: frontend/src/components/resources/server/info/networks.tsx ================================================ import { Section } from "@components/layouts"; import { DockerResourceLink } from "@components/util"; import { useRead } from "@lib/hooks"; import { Badge } from "@ui/badge"; import { DataTable, SortableHeader } from "@ui/data-table"; import { Dispatch, ReactNode, SetStateAction } from "react"; import { Prune } from "../actions"; import { filterBySplit } from "@lib/utils"; import { Search } from "lucide-react"; import { Input } from "@ui/input"; export const Networks = ({ id, titleOther, _search }: { id: string; titleOther: ReactNode; _search: [string, Dispatch>]; }) => { const [search, setSearch] = _search; const networks = useRead("ListDockerNetworks", { server: id }, { refetchInterval: 10_000 }) .data ?? []; const allInUse = networks.every((network) => // this ignores networks that come in with no name, but they should all come in with name !network.name ? true : ["none", "host", "bridge"].includes(network.name) ? true : network.in_use ); const filtered = filterBySplit( networks, search, (network) => network.name ?? "" ); return (
{!allInUse && }
setSearch(e.target.value)} placeholder="search..." className="pl-8 w-[200px] lg:w-[300px]" />
} > ( ), cell: ({ row }) => (
System ) : ( !row.original.in_use && ( Unused ) ) } />
), size: 300, }, { accessorKey: "driver", header: ({ column }) => ( ), }, { accessorKey: "scope", header: ({ column }) => ( ), }, { accessorKey: "attachable", header: ({ column }) => ( ), }, { accessorKey: "ipam_driver", header: ({ column }) => ( ), }, ]} />
); }; ================================================ FILE: frontend/src/components/resources/server/info/volumes.tsx ================================================ import { Section } from "@components/layouts"; import { DockerResourceLink } from "@components/util"; import { useRead } from "@lib/hooks"; import { Badge } from "@ui/badge"; import { DataTable, SortableHeader } from "@ui/data-table"; import { Dispatch, ReactNode, SetStateAction } from "react"; import { Prune } from "../actions"; import { Search } from "lucide-react"; import { Input } from "@ui/input"; import { filterBySplit } from "@lib/utils"; export const Volumes = ({ id, titleOther, _search, }: { id: string; titleOther: ReactNode; _search: [string, Dispatch>]; }) => { const [search, setSearch] = _search; const volumes = useRead("ListDockerVolumes", { server: id }, { refetchInterval: 10_000 }) .data ?? []; const allInUse = volumes.every((volume) => volume.in_use); const filtered = filterBySplit(volumes, search, (volume) => volume.name); return (
{!allInUse && }
setSearch(e.target.value)} placeholder="search..." className="pl-8 w-[200px] lg:w-[300px]" />
} > ( ), cell: ({ row }) => ( Unused ) } /> ), size: 200, }, { accessorKey: "driver", header: ({ column }) => ( ), }, { accessorKey: "scope", header: ({ column }) => ( ), }, ]} />
); }; ================================================ FILE: frontend/src/components/resources/server/monitoring-table.tsx ================================================ import { ResourceLink } from "@components/resources/common"; import { ServerComponents } from "@components/resources/server"; import { DataTable, SortableHeader } from "@ui/data-table"; import { useRead } from "@lib/hooks"; import { useMemo } from "react"; import { useIsServerAvailable } from "."; export const ServerMonitoringTable = ({ search = "" }: { search?: string }) => { const servers = useRead("ListServers", {}).data; const searchSplit = useMemo( () => search.toLowerCase().split(" ").filter((t) => t), [search] ); const filtered = useMemo( () => servers?.filter((s) => searchSplit.length === 0 ? true : searchSplit.every((t) => s.name.toLowerCase().includes(t)) ) ?? [], [servers, searchSplit] ); return (
tableKey="servers-monitoring-v1" data={filtered} columns={[ { accessorKey: "name", size: 250, header: ({ column }) => ( ), cell: ({ row }) => (
), }, { header: "CPU", size: 200, cell: ({ row }) => , }, { header: "Memory", size: 200, cell: ({ row }) => , }, { header: "Disk", size: 200, cell: ({ row }) => , }, { header: "Load Avg", size: 160, cell: ({ row }) => , }, { header: "Net", size: 100, cell: ({ row }) => , }, { header: "Agent", size: 160, cell: ({ row }) => , }, ]} />
); }; const useStats = (id: string) => { const isServerAvailable = useIsServerAvailable(id); return useRead("GetSystemStats", { server: id }, { enabled: isServerAvailable, refetchInterval: 10_000 }).data; }; const useServerThresholds = (id: string) => { const isServerAvailable = useIsServerAvailable(id); const config = useRead("GetServer", { server: id }, { enabled: isServerAvailable }).data?.config as any; return { cpuWarning: config?.cpu_warning ?? 75, cpuCritical: config?.cpu_critical ?? 90, memWarning: config?.mem_warning ?? 75, memCritical: config?.mem_critical ?? 90, diskWarning: config?.disk_warning ?? 75, diskCritical: config?.disk_critical ?? 90, }; }; const Bar = ({ valuePerc, intent }: { valuePerc?: number; intent: "Good" | "Warning" | "Critical" }) => { const w = Math.max(0, Math.min(100, valuePerc ?? 0)) / 100; const color = intent === "Good" ? "bg-green-500" : intent === "Warning" ? "bg-orange-500" : "bg-red-500"; return ( ); }; const CpuCell = ({ id }: { id: string }) => { const stats = useStats(id); const cpu = stats?.cpu_perc ?? 0; const { cpuWarning: warning, cpuCritical: critical } = useServerThresholds(id); const intent: "Good" | "Warning" | "Critical" = cpu < warning ? "Good" : cpu < critical ? "Warning" : "Critical"; return (
{cpu.toFixed(2)}%
); }; const MemCell = ({ id }: { id: string }) => { const stats = useStats(id); const used = stats?.mem_used_gb ?? 0; const total = stats?.mem_total_gb ?? 0; const perc = total > 0 ? (used / total) * 100 : 0; const { memWarning: warning, memCritical: critical } = useServerThresholds(id); const intent: "Good" | "Warning" | "Critical" = perc < warning ? "Good" : perc < critical ? "Warning" : "Critical"; return (
{perc.toFixed(1)}%
); }; const DiskCell = ({ id }: { id: string }) => { const stats = useStats(id); const used = stats?.disks?.reduce((acc, d) => acc + (d.used_gb || 0), 0) ?? 0; const total = stats?.disks?.reduce((acc, d) => acc + (d.total_gb || 0), 0) ?? 0; const perc = total > 0 ? (used / total) * 100 : 0; const { diskWarning: warning, diskCritical: critical } = useServerThresholds(id); const intent: "Good" | "Warning" | "Critical" = perc < warning ? "Good" : perc < critical ? "Warning" : "Critical"; return (
{perc.toFixed(1)}%
); }; const formatRate = (bytes?: number) => { const b = bytes ?? 0; const kb = 1024; const mb = kb * 1024; const gb = mb * 1024; if (b >= gb) return `${(b / gb).toFixed(2)} GB/s`; if (b >= mb) return `${(b / mb).toFixed(2)} MB/s`; if (b >= kb) return `${(b / kb).toFixed(2)} KB/s`; return `${b.toFixed(0)} B/s`; }; const NetCell = ({ id }: { id: string }) => { const stats = useStats(id); const ingress = stats?.network_ingress_bytes ?? 0; const egress = stats?.network_egress_bytes ?? 0; return ( {formatRate(ingress + egress)} ); }; const LoadAvgCell = ({ id }: { id: string }) => { const stats = useStats(id); const one = stats?.load_average?.one; const five = stats?.load_average?.five; const fifteen = stats?.load_average?.fifteen; if (one === undefined || five === undefined || fifteen === undefined) { return (
N/A
); } return (
{one.toFixed(2)} {five.toFixed(2)} {fifteen.toFixed(2)}
); }; ================================================ FILE: frontend/src/components/resources/server/stat-chart.tsx ================================================ import { hex_color_by_intention } from "@lib/color"; import { useRead } from "@lib/hooks"; import { Types } from "komodo_client"; import { useMemo } from "react"; import { useStatsGranularity } from "./hooks"; import { Loader2, OctagonAlert } from "lucide-react"; import { AxisOptions, Chart } from "react-charts"; import { convertTsMsToLocalUnixTsInMs } from "@lib/utils"; import { useTheme } from "@ui/theme"; import { fmt_utc_date } from "@lib/formatting"; type StatType = | "Cpu" | "Memory" | "Disk" | "Network Ingress" | "Network Egress" | "Load Average"; type StatDatapoint = { date: number; value: number }; export const StatChart = ({ server_id, type, className, }: { server_id: string; type: StatType; className?: string; }) => { const [granularity] = useStatsGranularity(); const { data, isPending } = useRead("GetHistoricalServerStats", { server: server_id, granularity, }); const seriesData = useMemo(() => { if (!data?.stats) return [] as { label: string; data: StatDatapoint[] }[]; const records = [...data.stats].reverse(); if (type === "Load Average") { const one = records.map((s) => ({ date: convertTsMsToLocalUnixTsInMs(s.ts), value: s.load_average?.one ?? 0, })); const five = records.map((s) => ({ date: convertTsMsToLocalUnixTsInMs(s.ts), value: s.load_average?.five ?? 0, })); const fifteen = records.map((s) => ({ date: convertTsMsToLocalUnixTsInMs(s.ts), value: s.load_average?.fifteen ?? 0, })); return [ { label: "1m", data: one }, { label: "5m", data: five }, { label: "15m", data: fifteen }, ]; } const single = records.map((stat) => ({ date: convertTsMsToLocalUnixTsInMs(stat.ts), value: getStat(stat, type), })); return [{ label: type, data: single }]; }, [data, type]); return (

{type}

{isPending ? (
) : ( s.data)} seriesData={seriesData} /> )}
); }; const BYTES_PER_GB = 1073741824.0; const BYTES_PER_MB = 1048576.0; const BYTES_PER_KB = 1024.0; export const InnerStatChart = ({ type, stats, seriesData, }: { type: StatType; stats: StatDatapoint[] | undefined; seriesData?: { label: string; data: StatDatapoint[] }[]; }) => { const { currentTheme } = useTheme(); const min = stats?.[0]?.date ?? 0; const max = stats?.[stats.length - 1]?.date ?? 0; const diff = max - min; const timeAxis = useMemo((): AxisOptions => { return { getValue: (datum) => new Date(datum.date), hardMax: new Date(max + diff * 0.02), hardMin: new Date(min - diff * 0.02), tickCount: 6, formatters: { // scale: (value?: Date) => fmt_date(value ?? new Date()), tooltip: (value?: Date) => (
{fmt_utc_date(value ?? new Date())}
), cursor: (_value?: Date) => false, }, }; }, [min, max, diff]); // Determine the dynamic scaling for network-related types const allValues = (seriesData ?? [{ data: stats ?? [] }]).flatMap((s) => s.data.map((d) => d.value) ); const maxStatValue = Math.max(...(allValues.length ? allValues : [0])); const { unit, maxUnitValue } = useMemo(() => { if (type === "Network Ingress" || type === "Network Egress") { if (maxStatValue <= BYTES_PER_KB) { return { unit: "KB", maxUnitValue: BYTES_PER_KB }; } else if (maxStatValue <= BYTES_PER_MB) { return { unit: "MB", maxUnitValue: BYTES_PER_MB }; } else if (maxStatValue <= BYTES_PER_GB) { return { unit: "GB", maxUnitValue: BYTES_PER_GB }; } else { return { unit: "TB", maxUnitValue: BYTES_PER_GB * 1024 }; // Larger scale for high values } } if (type === "Load Average") { // Leave unitless; set max slightly above observed return { unit: "", maxUnitValue: maxStatValue === 0 ? 1 : maxStatValue * 1.2, }; } return { unit: "", maxUnitValue: 100 }; // Default for CPU, memory, disk }, [type, maxStatValue]); const valueAxis = useMemo( (): AxisOptions[] => [ { getValue: (datum) => datum.value, elementType: type === "Load Average" ? "line" : "area", stacked: type !== "Load Average", min: 0, max: maxUnitValue, formatters: { tooltip: (value?: number) => (
{(type === "Network Ingress" || type === "Network Egress") && unit ? `${(value ?? 0) / (maxUnitValue / 1024)} ${unit}` : type === "Load Average" ? `${(value ?? 0).toFixed(2)}` : `${value?.toFixed(2)}%`}
), }, }, ], [type, maxUnitValue, unit] ); if ((seriesData?.[0]?.data.length ?? 0) < 2) { return (

Not enough data yet, choose a smaller interval.

); } return ( false, // }, }} /> ); }; const getStat = (stat: Types.SystemStatsRecord, type: StatType) => { if (type === "Cpu") return stat.cpu_perc || 0; if (type === "Memory") return (100 * stat.mem_used_gb) / stat.mem_total_gb; if (type === "Disk") return (100 * stat.disk_used_gb) / stat.disk_total_gb; if (type === "Network Ingress") return stat.network_ingress_bytes || 0; if (type === "Network Egress") return stat.network_egress_bytes || 0; return 0; }; const getColor = (type: StatType) => { if (type === "Cpu") return hex_color_by_intention("Good"); if (type === "Memory") return hex_color_by_intention("Warning"); if (type === "Disk") return hex_color_by_intention("Neutral"); if (type === "Network Ingress") return hex_color_by_intention("Good"); if (type === "Network Egress") return hex_color_by_intention("Critical"); return hex_color_by_intention("Unknown"); }; ================================================ FILE: frontend/src/components/resources/server/stats-mini.tsx ================================================ import { useRead } from "@lib/hooks"; import { cn } from "@lib/utils"; import { Progress } from "@ui/progress"; import { ServerState } from "komodo_client/dist/types"; import { Cpu, Database, MemoryStick, LucideIcon } from "lucide-react"; import { useMemo } from "react"; interface ServerStatsMiniProps { id: string; className?: string; enabled?: boolean; } interface StatItemProps { icon: LucideIcon; label: string; percentage: number; type: "cpu" | "memory" | "disk"; isUnreachable: boolean; getTextColor: (percentage: number, type: "cpu" | "memory" | "disk") => string; } const StatItem = ({ icon: Icon, label, percentage, type, isUnreachable, getTextColor }: StatItemProps) => (
); export const ServerStatsMini = ({ id, className, enabled = true }: ServerStatsMiniProps) => { const calculatePercentage = (value: number) => Number((value ?? 0).toFixed(2)); const servers = useRead("ListServers", {}).data; const server = servers?.find((s) => s.id === id); const isServerAvailable = server && server.info.state !== ServerState.Disabled && server.info.state !== ServerState.NotOk; const serverDetails = useRead("GetServer", { server: id }, { enabled: enabled && isServerAvailable }).data; const cpuWarning = serverDetails?.config?.cpu_warning ?? 75; const cpuCritical = serverDetails?.config?.cpu_critical ?? 90; const memWarning = serverDetails?.config?.mem_warning ?? 75; const memCritical = serverDetails?.config?.mem_critical ?? 90; const diskWarning = serverDetails?.config?.disk_warning ?? 75; const diskCritical = serverDetails?.config?.disk_critical ?? 90; const getTextColor = (percentage: number, type: "cpu" | "memory" | "disk") => { const warning = type === "cpu" ? cpuWarning : type === "memory" ? memWarning : diskWarning; const critical = type === "cpu" ? cpuCritical : type === "memory" ? memCritical : diskCritical; if (percentage >= critical) return "text-red-600"; if (percentage >= warning) return "text-yellow-600"; return "text-green-600"; }; const stats = useRead( "GetSystemStats", { server: id }, { enabled: enabled && isServerAvailable, refetchInterval: 15_000, staleTime: 5_000, }, ).data; if (!server) { return null; } const calculations = useMemo(() => { const cpuPercentage = stats ? calculatePercentage(stats.cpu_perc) : 0; const memoryPercentage = stats && stats.mem_total_gb > 0 ? calculatePercentage((stats.mem_used_gb / stats.mem_total_gb) * 100) : 0; const diskUsed = stats ? stats.disks.reduce((acc, disk) => acc + disk.used_gb, 0) : 0; const diskTotal = stats ? stats.disks.reduce((acc, disk) => acc + disk.total_gb, 0) : 0; const diskPercentage = diskTotal > 0 ? calculatePercentage((diskUsed / diskTotal) * 100) : 0; const isUnreachable = !stats || server.info.state === ServerState.NotOk; const isDisabled = server.info.state === ServerState.Disabled; return { cpuPercentage, memoryPercentage, diskPercentage, isUnreachable, isDisabled }; }, [stats, server.info.state]); const { cpuPercentage, memoryPercentage, diskPercentage, isUnreachable, isDisabled } = calculations; const overlayClass = (isUnreachable || isDisabled) ? "opacity-50" : ""; const statItems = useMemo(() => [ { icon: Cpu, label: "CPU", percentage: cpuPercentage, type: "cpu" as const }, { icon: MemoryStick, label: "Memory", percentage: memoryPercentage, type: "memory" as const }, { icon: Database, label: "Disk", percentage: diskPercentage, type: "disk" as const }, ], [cpuPercentage, memoryPercentage, diskPercentage]); return (
{statItems.map((item) => ( ))} {isDisabled && (
Disabled
)} {isUnreachable && !isDisabled && (
Unreachable
)}
); }; ================================================ FILE: frontend/src/components/resources/server/stats.tsx ================================================ import { Section } from "@components/layouts"; import { Card, CardContent, CardHeader, CardTitle } from "@ui/card"; import { Progress } from "@ui/progress"; import { Cpu, Database, Loader2, MemoryStick, Search } from "lucide-react"; import { useLocalStorage, usePermissions, useRead } from "@lib/hooks"; import { Types } from "komodo_client"; import { DataTable, SortableHeader } from "@ui/data-table"; import { ReactNode, useMemo, useState } from "react"; import { Input } from "@ui/input"; import { StatChart } from "./stat-chart"; import { useStatsGranularity } from "./hooks"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { DockerResourceLink, ShowHideButton } from "@components/util"; import { filterBySplit } from "@lib/utils"; import { useIsServerAvailable } from "."; export const ServerStats = ({ id, titleOther, }: { id: string; titleOther?: ReactNode; }) => { const [interval, setInterval] = useStatsGranularity(); const { specific } = usePermissions({ type: "Server", id }); const isServerAvailable = useIsServerAvailable(id); const stats = useRead( "GetSystemStats", { server: id }, { enabled: isServerAvailable, refetchInterval: 10_000 } ).data; const info = useRead("GetSystemInformation", { server: id }, { enabled: isServerAvailable }).data; // Get all the containers with stats const containers = useRead("ListDockerContainers", { server: id, }, { enabled: isServerAvailable }).data?.filter((c) => c.stats); const [showContainers, setShowContainers] = useLocalStorage( "stats-show-container-table-v1", true ); const [containerSearch, setContainerSearch] = useState(""); const filteredContainers = filterBySplit( containers, containerSearch, (container) => container.name ); const [showDisks, setShowDisks] = useLocalStorage( "stats-show-disks-table-v1", true ); const disk_used = stats?.disks.reduce( (acc, curr) => (acc += curr.used_gb), 0 ); const disk_total = stats?.disks.reduce( (acc, curr) => (acc += curr.total_gb), 0 ); return (
{/* System Info */}
`${core_count} Core${(core_count || 0) > 1 ? "s" : ""}`, }, { header: "Total Memory", accessorFn: ({ mem_total }) => `${mem_total?.toFixed(2)} GB`, }, { header: "Total Disk Size", accessorFn: ({ disk_total }) => `${disk_total?.toFixed(2)} GB`, }, ]} />
{/* Current Overview */}
{/* Container Breakdown */}
setContainerSearch(e.target.value)} placeholder="search..." className="pl-8 w-[200px] lg:w-[300px]" />
} > {showContainers && ( ( ), cell: ({ row }) => ( ), }, { accessorKey: "stats.cpu_perc", size: 100, header: ({ column }) => ( ), }, { accessorKey: "stats.mem_perc", size: 200, header: ({ column }) => ( ), cell: ({ row }) => (
{row.original.stats?.mem_perc}
({row.original.stats?.mem_usage})
), }, { accessorKey: "stats.net_io", size: 150, header: ({ column }) => ( ), }, { accessorKey: "stats.block_io", size: 150, header: ({ column }) => ( ), }, { accessorKey: "stats.pids", size: 100, header: ({ column }) => ( ), }, ]} /> )}
{/* Current Disk Breakdown */}
Used:
{disk_used?.toFixed(2)} GB
Total:
{disk_total?.toFixed(2)} GB
} > {showDisks && ( ({ ...disk, percentage: 100 * (disk.used_gb / disk.total_gb), })) ?? [] } columns={[ { header: "Path", cell: ({ row }) => (
{row.original.mount}
), }, { accessorKey: "used_gb", header: ({ column }) => ( ), cell: ({ row }) => <>{row.original.used_gb.toFixed(2)} GB, }, { accessorKey: "total_gb", header: ({ column }) => ( ), cell: ({ row }) => <>{row.original.total_gb.toFixed(2)} GB, }, { accessorKey: "percentage", header: ({ column }) => ( ), cell: ({ row }) => ( <>{row.original.percentage.toFixed(2)}% Full ), }, ]} /> )}
{specific.includes(Types.SpecificPermission.Processes) && ( )} {/* Historical Charts */}
{/* Granularity Dropdown */}
Interval:
} >
); }; const Processes = ({ id }: { id: string }) => { const [show, setShow] = useState(false); const [search, setSearch] = useState(""); const searchSplit = search.toLowerCase().split(" "); return (
setSearch(e.target.value)} placeholder="search..." className="pl-8 w-[200px] lg:w-[300px]" />
} > {show && }
); }; const ProcessesInner = ({ id, searchSplit, }: { id: string; searchSplit: string[]; }) => { const { data: processes, isPending } = useRead("ListSystemProcesses", { server: id, }); const filtered = useMemo( () => processes?.filter((process) => { if (searchSplit.length === 0) return true; const name = process.name.toLowerCase(); return searchSplit.every((search) => name.includes(search)); }), [processes, searchSplit] ); if (isPending) return (
); if (!processes) return null; return ( (
{row.original.exe}
), }, { accessorKey: "cpu_perc", header: ({ column }) => ( ), cell: ({ row }) => <>{row.original.cpu_perc.toFixed(2)}%, }, { accessorKey: "mem_mb", header: ({ column }) => ( ), cell: ({ row }) => ( <> {row.original.mem_mb > 1000 ? `${(row.original.mem_mb / 1024).toFixed(2)} GB` : `${row.original.mem_mb.toFixed(2)} MB`} ), }, ]} /> ); }; const StatBar = ({ title, icon, percentage, }: { title: string; icon: ReactNode; percentage: number | undefined; }) => { return ( {title}
{percentage?.toFixed(2)}%
{icon}
); }; const CPU = ({ stats }: { stats: Types.SystemStats | undefined }) => { return ( } percentage={stats?.cpu_perc} /> ); }; const LOAD_AVERAGE = ({ id, stats, }: { id: string; stats: Types.SystemStats | undefined; }) => { if (!stats?.load_average) return null; const { one = 0, five = 0, fifteen = 0 } = stats.load_average || {}; const isServerAvailable = useIsServerAvailable(id); const cores = useRead("GetSystemInformation", { server: id }, { enabled: isServerAvailable }).data?.core_count; const pct = (load: number) => cores && cores > 0 ? Math.min((load / cores) * 100, 100) : undefined; const textColor = (load: number) => { const p = pct(load); if (p === undefined) return "text-muted-foreground"; return p <= 50 ? "text-green-600" : p <= 80 ? "text-yellow-600" : "text-red-600"; }; return (
Load Average
{/* Current Load */}
{one.toFixed(2)} {cores && cores > 0 ? `${(pct(one) ?? 0).toFixed(0)}% of ${cores} cores` : "N/A"}
{/* Time Intervals */}
{[ ["1m", one], ["5m", five], ["15m", fifteen], ].map(([label, value]) => (
{label} {(value as number).toFixed(2)}
))}
); }; const RAM = ({ stats }: { stats: Types.SystemStats | undefined }) => { const used = stats?.mem_used_gb; const total = stats?.mem_total_gb; return ( } percentage={((used ?? 0) / (total ?? 0)) * 100} /> ); }; const DISK = ({ stats }: { stats: Types.SystemStats | undefined }) => { const used = stats?.disks.reduce((acc, curr) => (acc += curr.used_gb), 0); const total = stats?.disks.reduce((acc, curr) => (acc += curr.total_gb), 0); return ( } percentage={((used ?? 0) / (total ?? 0)) * 100} /> ); }; const formatBytes = (bytes: number) => { const BYTES_PER_KB = 1024; const BYTES_PER_MB = 1024 * BYTES_PER_KB; const BYTES_PER_GB = 1024 * BYTES_PER_MB; if (bytes >= BYTES_PER_GB) { return { value: bytes / BYTES_PER_GB, unit: "GB" }; } else if (bytes >= BYTES_PER_MB) { return { value: bytes / BYTES_PER_MB, unit: "MB" }; } else if (bytes >= BYTES_PER_KB) { return { value: bytes / BYTES_PER_KB, unit: "KB" }; } else { return { value: bytes, unit: "bytes" }; } }; const NETWORK = ({ stats }: { stats: Types.SystemStats | undefined }) => { const ingress = stats?.network_ingress_bytes ?? 0; const egress = stats?.network_egress_bytes ?? 0; const formattedIngress = formatBytes(ingress); const formattedEgress = formatBytes(egress); return ( Network Usage

Ingress

{formattedIngress.value.toFixed(2)} {formattedIngress.unit}

Egress

{formattedEgress.value.toFixed(2)} {formattedEgress.unit}
); }; ================================================ FILE: frontend/src/components/resources/server/table.tsx ================================================ import { TableTags } from "@components/tags"; import { useRead, useSelectedResources } from "@lib/hooks"; import { DataTable, SortableHeader } from "@ui/data-table"; import { ServerComponents, ServerVersion } from "."; import { ResourceLink } from "../common"; import { Types } from "komodo_client"; import { useCallback } from "react"; export const ServerTable = ({ servers, }: { servers: Types.ServerListItem[]; }) => { const [_, setSelectedResources] = useSelectedResources("Server"); const deployments = useRead("ListDeployments", {}).data; const stacks = useRead("ListStacks", {}).data; const repos = useRead("ListRepos", {}).data; const resourcesCount = useCallback( (id: string) => { return ( (deployments?.filter((d) => d.info.server_id === id).length || 0) + (stacks?.filter((d) => d.info.server_id === id).length || 0) + (repos?.filter((d) => d.info.server_id === id).length || 0) ); }, [deployments, stacks, repos] ); return ( name, onSelect: setSelectedResources, }} columns={[ { size: 250, accessorKey: "name", header: ({ column }) => ( ), cell: ({ row }) => ( ), }, { size: 100, accessorKey: "id", sortingFn: (a, b) => { const sa = resourcesCount(a.original.id); const sb = resourcesCount(b.original.id); if (!sa && !sb) return 0; if (!sa) return 1; if (!sb) return -1; if (sa > sb) return 1; else if (sa < sb) return -1; else return 0; }, header: ({ column }) => ( ), cell: ({ row }) => { return <>{resourcesCount(row.original.id)}; }, }, { size: 200, accessorKey: "info.region", header: ({ column }) => ( ), }, { size: 150, accessorKey: "info.version", header: ({ column }) => ( ), cell: ({ row }) => , }, { size: 150, accessorKey: "info.state", header: ({ column }) => ( ), cell: ({ row }) => , }, { header: "Tags", cell: ({ row }) => , }, ]} /> ); }; ================================================ FILE: frontend/src/components/resources/stack/actions.tsx ================================================ import { ActionWithDialog, ConfirmButton } from "@components/util"; import { useExecute, useRead } from "@lib/hooks"; import { Download, Pause, Play, RefreshCcw, Rocket, Square, Trash, } from "lucide-react"; import { useStack } from "."; import { Types } from "komodo_client"; export const DeployStack = ({ id, service, }: { id: string; service?: string; }) => { const stack = useStack(id); const state = stack?.info.state; const { mutate: deploy, isPending } = useExecute("DeployStack"); const deploying = useRead( "GetStackActionState", { stack: id }, { refetchInterval: 5000 } ).data?.deploying; const services = useRead("ListStackServices", { stack: id }).data; const container_state = (service ? services?.find((s) => s.service === service)?.container?.state : undefined) ?? Types.ContainerStateStatusEnum.Empty; if (!stack || state === Types.StackState.Unknown) { return null; } const deployed = state !== undefined && (service !== undefined ? container_state !== Types.ContainerStateStatusEnum.Empty : [ Types.StackState.Running, Types.StackState.Paused, Types.StackState.Stopped, Types.StackState.Restarting, Types.StackState.Unhealthy, ].includes(state)); if (deployed) { return ( } onClick={() => deploy({ stack: id, services: service ? [service] : [] }) } disabled={isPending} loading={isPending || deploying} /> ); } return ( } onClick={() => deploy({ stack: id, services: service ? [service] : [] })} disabled={isPending} loading={isPending || deploying} /> ); }; export const DestroyStack = ({ id, service, }: { id: string; service?: string; }) => { const stack = useStack(id); const state = stack?.info.state; const { mutate: destroy, isPending } = useExecute("DestroyStack"); const destroying = useRead( "GetStackActionState", { stack: id }, { refetchInterval: 5000 } ).data?.destroying; const services = useRead("ListStackServices", { stack: id }).data; const container_state = (service ? services?.find((s) => s.service === service)?.container?.state : undefined) ?? Types.ContainerStateStatusEnum.Empty; if ( !stack || service !== undefined ? container_state === Types.ContainerStateStatusEnum.Empty : state === undefined || [Types.StackState.Unknown, Types.StackState.Down].includes(state!) ) { return null; } return ( } onClick={() => destroy({ stack: id, services: service ? [service] : [] })} disabled={isPending} loading={isPending || destroying} /> ); }; export const PullStack = ({ id, service, }: { id: string; service?: string; }) => { const stack = useStack(id); const { mutate: pull, isPending: pullPending } = useExecute("PullStack"); const action_state = useRead( "GetStackActionState", { stack: id }, { refetchInterval: 5000 } ).data; if (!stack || (stack?.info.missing_files.length ?? 0) > 0) { return null; } return ( } onClick={() => pull({ stack: id, services: service ? [service] : [] })} disabled={pullPending} loading={pullPending || action_state?.pulling} /> ); }; export const RestartStack = ({ id, service, }: { id: string; service?: string; }) => { const stack = useStack(id); const state = stack?.info.state; const { mutate: restart, isPending: restartPending } = useExecute("RestartStack"); const action_state = useRead( "GetStackActionState", { stack: id }, { refetchInterval: 5000 } ).data; const services = useRead("ListStackServices", { stack: id }).data; const container_state = (service ? services?.find((s) => s.service === service)?.container?.state : undefined) ?? Types.ContainerStateStatusEnum.Empty; if ( !stack || stack?.info.project_missing || (service && container_state !== Types.ContainerStateStatusEnum.Running) || // Only show if running or unhealthy (state !== Types.StackState.Running && state !== Types.StackState.Unhealthy) ) { return null; } return ( } onClick={() => restart({ stack: id, services: service ? [service] : [] })} disabled={restartPending} loading={restartPending || action_state?.restarting} /> ); }; export const StartStopStack = ({ id, service, }: { id: string; service?: string; }) => { const stack = useStack(id); const state = stack?.info.state ?? Types.StackState.Unknown; const { mutate: start, isPending: startPending } = useExecute("StartStack"); const { mutate: stop, isPending: stopPending } = useExecute("StopStack"); const action_state = useRead( "GetStackActionState", { stack: id }, { refetchInterval: 5000 } ).data; const services = useRead("ListStackServices", { stack: id }).data; const container_state = (service ? services?.find((s) => s.service === service)?.container?.state : undefined) ?? Types.ContainerStateStatusEnum.Empty; if ( !stack || [Types.StackState.Down, Types.StackState.Unknown].includes(state) ) { return null; } const showStart = service ? ((container_state && container_state !== Types.ContainerStateStatusEnum.Running) ?? false) : state !== Types.StackState.Running; const showStop = service ? ((container_state && container_state !== Types.ContainerStateStatusEnum.Exited) ?? false) : state !== Types.StackState.Stopped; return ( <> {showStart && ( } onClick={() => start({ stack: id, services: service ? [service] : [] }) } disabled={startPending} loading={startPending || action_state?.starting} /> )} {showStop && ( } onClick={() => stop({ stack: id, services: service ? [service] : [] }) } disabled={stopPending} loading={stopPending || action_state?.stopping} /> )} ); }; export const PauseUnpauseStack = ({ id, service, }: { id: string; service?: string; }) => { const stack = useStack(id); const state = stack?.info.state; const { mutate: unpause, isPending: unpausePending } = useExecute("UnpauseStack"); const { mutate: pause, isPending: pausePending } = useExecute("PauseStack"); const action_state = useRead( "GetStackActionState", { stack: id }, { refetchInterval: 5000 } ).data; const services = useRead("ListStackServices", { stack: id }).data; const container_state = (service ? services?.find((s) => s.service === service)?.container?.state : undefined) ?? Types.ContainerStateStatusEnum.Empty; if (!stack || stack?.info.project_missing) { return null; } if ( (service && container_state === Types.ContainerStateStatusEnum.Paused) || state === Types.StackState.Paused ) { return ( } onClick={() => unpause({ stack: id, services: service ? [service] : [] }) } disabled={unpausePending} loading={unpausePending || action_state?.unpausing} /> ); } if ( (service && container_state === Types.ContainerStateStatusEnum.Running) || state === Types.StackState.Running ) { return ( } onClick={() => pause({ stack: id, services: service ? [service] : [] })} disabled={pausePending} loading={pausePending || action_state?.pausing} /> ); } }; ================================================ FILE: frontend/src/components/resources/stack/config.tsx ================================================ import { Config, ConfigComponent } from "@components/config"; import { AccountSelectorConfig, AddExtraArgMenu, ConfigItem, ConfigList, ConfigSwitch, InputList, ProviderSelectorConfig, SystemCommand, WebhookBuilder, } from "@components/config/util"; import { Types } from "komodo_client"; import { getWebhookIntegration, useInvalidate, useLocalStorage, usePermissions, useRead, useWebhookIdOrName, useWebhookIntegrations, useWrite, } from "@lib/hooks"; import { ReactNode, useState } from "react"; import { CopyWebhook, ResourceLink, ResourceSelector } from "../common"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { SecretsSearch } from "@components/config/env_vars"; import { ConfirmButton, ShowHideButton } from "@components/util"; import { MonacoEditor } from "@components/monaco"; import { useToast } from "@ui/use-toast"; import { text_color_class_by_intention } from "@lib/color"; import { Ban, ChevronsUpDown, CirclePlus, MinusCircle, PlusCircle, SearchX, X, } from "lucide-react"; import { LinkedRepoConfig } from "@components/config/linked_repo"; import { Button } from "@ui/button"; import { Input } from "@ui/input"; import { useStack } from "."; import { filterBySplit } from "@lib/utils"; import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@ui/command"; import { Checkbox } from "@ui/checkbox"; type StackMode = "UI Defined" | "Files On Server" | "Git Repo" | undefined; const STACK_MODES: StackMode[] = ["UI Defined", "Files On Server", "Git Repo"]; function getStackMode( update: Partial, config: Types.StackConfig ): StackMode { if (update.files_on_host ?? config.files_on_host) return "Files On Server"; if ( (update.linked_repo ?? config.linked_repo) || (update.repo ?? config.repo) ) return "Git Repo"; if (update.file_contents ?? config.file_contents) return "UI Defined"; return undefined; } export const StackConfig = ({ id, titleOther, }: { id: string; titleOther: ReactNode; }) => { const [show, setShow] = useLocalStorage(`stack-${id}-show`, { file: true, env: true, git: true, webhooks: true, }); const { canWrite } = usePermissions({ type: "Stack", id }); const stack = useRead("GetStack", { stack: id }).data; const config = stack?.config; const name = stack?.name; const webhooks = useRead("GetStackWebhooksEnabled", { stack: id }).data; const global_disabled = useRead("GetCoreInfo", {}).data?.ui_write_disabled ?? false; const [update, set] = useLocalStorage>( `stack-${id}-update-v1`, {} ); const { mutateAsync } = useWrite("UpdateStack"); const { integrations } = useWebhookIntegrations(); const [id_or_name] = useWebhookIdOrName(); if (!config) return null; const disabled = global_disabled || !canWrite; const run_build = update.run_build ?? config.run_build; const mode = getStackMode(update, config); const git_provider = update.git_provider ?? config.git_provider; const webhook_integration = getWebhookIntegration(integrations, git_provider); const setMode = (mode: StackMode) => { if (mode === "Files On Server") { set({ ...update, files_on_host: true }); } else if (mode === "Git Repo") { set({ ...update, files_on_host: false, repo: update.repo || config.repo || "namespace/repo", }); } else if (mode === "UI Defined") { set({ ...update, files_on_host: false, repo: "", file_contents: update.file_contents || config.file_contents || DEFAULT_STACK_FILE_CONTENTS, }); } else if (mode === undefined) { set({ ...update, files_on_host: false, repo: "", file_contents: "", }); } }; let components: Record< string, false | ConfigComponent[] | undefined > = {}; const server_component: ConfigComponent = { label: "Server", labelHidden: true, components: { server_id: (server_id, set) => { return ( Server: ) : ( "Select Server" ) } description="Select the Server to deploy on." > set({ server_id })} disabled={disabled} align="start" /> ); }, }, }; const choose_mode: ConfigComponent = { label: "Choose Mode", labelHidden: true, components: { server_id: () => { return ( ); }, }, }; const environment: ConfigComponent = { label: "Environment", description: "Pass these variables to the compose command", actions: ( setShow({ ...show, env })} /> ), contentHidden: !show.env, components: { environment: (env, set) => (
set({ environment })} language="key_value" readOnly={disabled} />
), env_file_path: { description: "The path to write the file to, relative to the 'Run Directory'.", placeholder: ".env", }, additional_env_files: (mode === "Files On Server" || mode === "Git Repo") && ((values, set) => ( )), }, }; const config_files: ConfigComponent = { label: "Config Files", description: "Add other config files to associate with the Stack, and edit in the UI. Relative to 'Run Directory'.", components: { config_files: (value, set) => ( ), }, }; const auto_update = update.auto_update ?? config.auto_update ?? false; const general_common: ConfigComponent[] = [ { label: "Auto Update", components: { poll_for_updates: (poll, set) => { return ( set({ poll_for_updates })} disabled={disabled || auto_update} /> ); }, auto_update: { description: "Trigger a redeploy if a newer image is found.", }, auto_update_all_services: (value, set) => { return ( set({ auto_update_all_services }) } disabled={disabled || !auto_update} /> ); }, }, }, { label: "Links", labelHidden: true, components: { links: (values, set) => ( ), }, }, ]; const advanced: ConfigComponent[] = [ { label: "Project Name", labelHidden: true, components: { project_name: { placeholder: "Compose project name", boldLabel: true, description: "Optionally set a different compose project name. If importing existing stack, this should match the compose project name on your host.", }, }, }, { label: "Pre Deploy", description: "Execute a shell command before running docker compose up. The 'path' is relative to the Run Directory", components: { pre_deploy: (value, set) => ( set({ pre_deploy: value })} disabled={disabled} /> ), }, }, { label: "Post Deploy", description: "Execute a shell command after running docker compose up. The 'path' is relative to the Run Directory", components: { post_deploy: (value, set) => ( set({ post_deploy: value })} disabled={disabled} /> ), }, }, { label: "Extra Args", labelHidden: true, components: { extra_args: (value, set) => ( {!disabled && ( set({ extra_args: [ ...(update.extra_args ?? config.extra_args ?? []), suggestion, ], }) } disabled={disabled} /> )} ), }, }, { label: "Ignore Services", labelHidden: true, components: { ignore_services: (values, set) => ( ), }, }, { label: "Pull Images", labelHidden: true, components: { registry_provider: (provider, set) => { return ( set({ registry_provider })} /> ); }, registry_account: (value, set) => { const server_id = update.server_id || config.server_id; const provider = update.registry_provider ?? config.registry_provider; if (!provider) { return null; } return ( set({ registry_account })} disabled={disabled} placeholder="None" /> ); }, auto_pull: { label: "Pre Pull Images", description: "Ensure 'docker compose pull' is run before redeploying the Stack. Otherwise, use 'pull_policy' in docker compose file.", }, }, }, { label: "Build Images", labelHidden: true, components: { run_build: { label: "Pre Build Images", description: "Ensure 'docker compose build' is run before redeploying the Stack. Otherwise, can use '--build' as an Extra Arg.", }, build_extra_args: (value, set) => run_build && ( {!disabled && ( set({ build_extra_args: [ ...(update.build_extra_args ?? config.build_extra_args ?? []), suggestion, ], }) } disabled={disabled} /> )} ), }, }, { label: "Destroy", labelHidden: true, components: { destroy_before_deploy: { label: "Destroy Before Deploy", description: "Ensure 'docker compose down' is run before redeploying the Stack.", }, }, }, ]; if (mode === undefined) { components = { "": [server_component, choose_mode], }; } else if (mode === "Files On Server") { components = { "": [ server_component, { label: "Files", components: { run_directory: { label: "Run Directory", description: `Set the working directory when running the 'compose up' command. Can be absolute path, or relative to $PERIPHERY_STACK_DIR/${stack.name}`, placeholder: "/path/to/folder", }, file_paths: (value, set) => ( ), }, }, environment, config_files, ...general_common, ], advanced, }; } else if (mode === "Git Repo") { const repo_linked = !!(update.linked_repo ?? config.linked_repo); components = { "": [ server_component, { label: "Source", contentHidden: !show.git, actions: ( setShow({ ...show, git })} /> ), components: { linked_repo: (linked_repo, set) => ( ), ...(!repo_linked ? { git_provider: (provider, set) => { const https = update.git_https ?? config.git_https; return ( set({ git_provider })} https={https} onHttpsSwitch={() => set({ git_https: !https })} /> ); }, git_account: (value, set) => { const server_id = update.server_id || config.server_id; return ( set({ git_account })} disabled={disabled} placeholder="None" /> ); }, repo: { placeholder: "Enter repo", description: "The repo path on the provider. {namespace}/{repo_name}", }, branch: { placeholder: "Enter branch", description: "Select a custom branch, or default to 'main'.", }, commit: { label: "Commit Hash", placeholder: "Input commit hash", description: "Optional. Switch to a specific commit hash after cloning the branch.", }, clone_path: { placeholder: "/clone/path/on/host", description: (
Explicitly specify the folder on the host to clone the repo in.
If relative (no leading '/'), relative to{" "} {"$root_directory/stacks/" + stack.name}
), }, } : {}), reclone: { description: "Delete the repo folder and clone it again, instead of using 'git pull'.", }, }, }, { label: "Files", components: { run_directory: { description: "Set the working directory when running the compose up command, relative to the root of the repo.", placeholder: "path/to/folder", }, file_paths: (value, set) => ( ), }, }, environment, config_files, ...general_common, { label: "Webhooks", description: `Copy the webhook given here, and configure your ${webhook_integration}-style repo provider to send webhooks to Komodo`, actions: ( setShow({ ...show, webhooks })} /> ), contentHidden: !show.webhooks, components: { ["Guard" as any]: () => { if (update.branch ?? config.branch) { return null; } return (
Must configure Branch before webhooks will work.
); }, ["Builder" as any]: () => ( ), ["Deploy" as any]: () => (update.branch ?? config.branch) && ( ), webhook_force_deploy: { description: "Usually the Stack won't deploy unless there are changes to the files. Use this to force deploy.", }, webhook_enabled: !!(update.branch ?? config.branch) && webhooks !== undefined && !webhooks.managed, webhook_secret: { description: "Provide a custom webhook secret for this resource, or use the global default.", placeholder: "Input custom secret", }, ["managed" as any]: () => { const inv = useInvalidate(); const { toast } = useToast(); const { mutate: createWebhook, isPending: createPending } = useWrite("CreateStackWebhook", { onSuccess: () => { toast({ title: "Webhook Created" }); inv(["GetStackWebhooksEnabled", { stack: id }]); }, }); const { mutate: deleteWebhook, isPending: deletePending } = useWrite("DeleteStackWebhook", { onSuccess: () => { toast({ title: "Webhook Deleted" }); inv(["GetStackWebhooksEnabled", { stack: id }]); }, }); if ( !(update.branch ?? config.branch) || !webhooks || !webhooks.managed ) { return null; } return ( {webhooks.deploy_enabled && (
Incoming webhook is{" "}
ENABLED
and will trigger
DEPLOY
} variant="destructive" onClick={() => deleteWebhook({ stack: id, action: Types.StackWebhookAction.Deploy, }) } loading={deletePending} disabled={disabled || deletePending} />
)} {!webhooks.deploy_enabled && webhooks.refresh_enabled && (
Incoming webhook is{" "}
ENABLED
and will trigger
REFRESH
} variant="destructive" onClick={() => deleteWebhook({ stack: id, action: Types.StackWebhookAction.Refresh, }) } loading={deletePending} disabled={disabled || deletePending} />
)} {!webhooks.deploy_enabled && !webhooks.refresh_enabled && (
Incoming webhook is{" "}
DISABLED
} onClick={() => createWebhook({ stack: id, action: Types.StackWebhookAction.Deploy, }) } loading={createPending} disabled={disabled || createPending} /> } onClick={() => createWebhook({ stack: id, action: Types.StackWebhookAction.Refresh, }) } loading={createPending} disabled={disabled || createPending} />
)}
); }, }, }, ], advanced, }; } else if (mode === "UI Defined") { components = { "": [ server_component, { label: "Compose File", description: "Manage the compose file contents here.", actions: ( setShow({ ...show, file })} /> ), contentHidden: !show.file, components: { file_contents: (file_contents, set) => { const show_default = !file_contents && update.file_contents === undefined && !(update.repo ?? config.repo); return (
set({ file_contents })} language="yaml" readOnly={disabled} />
); }, }, }, environment, ...general_common, ], advanced, }; } return ( { await mutateAsync({ id, config: update }); }} components={components} file_contents_language="yaml" /> ); }; export const DEFAULT_STACK_FILE_CONTENTS = `## Add your compose file here services: hello_world: image: hello-world # networks: # - default # ports: # - 3000:3000 # volumes: # - data:/data # networks: # default: {} # volumes: # data: `; const ConfigFiles = ({ id, value, set, disabled, }: { id: string; value: Types.StackFileDependency[] | undefined; set: (value: Partial) => void; disabled: boolean; }) => { const values = value ?? []; return ( {!disabled && ( )} {values.length > 0 && (
{values.map(({ path, services, requires }, i) => (
{ values[i] = { ...values[i], path: e.target.value }; set({ config_files: [...values] }); }} disabled={disabled} className="w-[400px] max-w-full" /> {!disabled && ( )} { values[i] = { ...values[i], services }; set({ config_files: [...values] }); }} disabled={disabled} /> { values[i] = { ...values[i], requires }; set({ config_files: [...values] }); }} disabled={disabled} />
))}
)}
); }; const ServicesSelector = ({ id, selected_services, set, disabled, }: { id: string; selected_services: string[]; set: (services: string[]) => void; disabled: boolean; }) => { const services = useStack(id)?.info.services.map((s) => s.service) ?? []; const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const filtered = filterBySplit(services, search, (i) => i).sort(); return ( No services found {filtered.map((service) => ( { if (selected_services.includes(service)) { set(selected_services.filter((s) => s !== service)); } else { set([...selected_services, service].sort()); } // setOpen(false); }} className="flex items-center gap-2 cursor-pointer" >
{service}
))} {!search && selected_services.length > 0 && ( { set([]); setOpen(false); }} className="flex items-center gap-2 cursor-pointer" disabled={services.length === 0} >
Clear
)}
); }; const RequiresSelector = ({ requires, set, disabled, }: { requires: Types.StackFileRequires; set: (requires: Types.StackFileRequires) => void; disabled: boolean; }) => { return ( ); }; ================================================ FILE: frontend/src/components/resources/stack/index.tsx ================================================ import { useInvalidate, useLocalStorage, usePermissions, useRead, useWrite, } from "@lib/hooks"; import { RequiredResourceComponents } from "@types"; import { Card } from "@ui/card"; import { CircleArrowUp, Layers, Loader2, RefreshCcw, Server, } from "lucide-react"; import { DeleteResource, NewResource, ResourceLink, ResourcePageHeader, StandardSource, } from "../common"; import { StackTable } from "./table"; import { border_color_class_by_intention, stack_state_intention, stroke_color_class_by_intention, } from "@lib/color"; import { cn } from "@lib/utils"; import { useServer } from "../server"; import { Types } from "komodo_client"; import { DeployStack, DestroyStack, PauseUnpauseStack, PullStack, RestartStack, StartStopStack, } from "./actions"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/tabs"; import { StackInfo } from "./info"; import { Badge } from "@ui/badge"; import { Button } from "@ui/button"; import { useToast } from "@ui/use-toast"; import { StackServices } from "./services"; import { DashboardPieChart } from "@pages/home/dashboard"; import { StatusBadge } from "@components/util"; import { StackConfig } from "./config"; import { GroupActions } from "@components/group-actions"; import { StackLogs } from "./log"; import { Tooltip, TooltipTrigger, TooltipContent } from "@ui/tooltip"; export const useStack = (id?: string) => useRead("ListStacks", {}, { refetchInterval: 10_000 }).data?.find( (d) => d.id === id ); export const useFullStack = (id: string) => useRead("GetStack", { stack: id }, { refetchInterval: 10_000 }).data; const StackIcon = ({ id, size }: { id?: string; size: number }) => { const state = useStack(id)?.info.state; const color = stroke_color_class_by_intention(stack_state_intention(state)); return ; }; const ConfigInfoServicesLog = ({ id }: { id: string }) => { const [_view, setView] = useLocalStorage< "Config" | "Info" | "Services" | "Log" >("stack-tabs-v1", "Config"); const info = useStack(id)?.info; const { specific } = usePermissions({ type: "Stack", id }); const state = info?.state; const hideInfo = !info?.files_on_host && !info?.repo && !info?.linked_repo; const hideServices = state === undefined || state === Types.StackState.Unknown || state === Types.StackState.Down; const hideLogs = hideServices || !specific.includes(Types.SpecificPermission.Logs); const view = (_view === "Info" && hideInfo) || (_view === "Services" && hideServices) || (_view === "Log" && hideLogs) ? "Config" : _view; const title = ( Config Info Services {specific.includes(Types.SpecificPermission.Logs) && ( Log )} ); return ( ); }; export const StackComponents: RequiredResourceComponents = { list_item: (id) => useStack(id), resource_links: (resource) => (resource.config as Types.StackConfig).links, Description: () => <>Deploy docker compose files., Dashboard: () => { const summary = useRead("GetStacksSummary", {}).data; const all = [ summary?.running ?? 0, summary?.stopped ?? 0, summary?.unhealthy ?? 0, summary?.unknown ?? 0, ]; const [running, stopped, unhealthy, unknown] = all; return ( item === 0) && { title: "Down", intention: "Neutral", value: summary?.down ?? 0, }, { intention: "Good", value: running, title: "Running" }, { intention: "Warning", value: stopped, title: "Stopped", }, { intention: "Critical", value: unhealthy, title: "Unhealthy", }, { intention: "Unknown", value: unknown, title: "Unknown", }, ]} /> ); }, GroupActions: () => ( ), New: ({ server_id: _server_id }) => { const servers = useRead("ListServers", {}).data; const server_id = _server_id ? _server_id : servers && servers.length === 1 ? servers[0].id : undefined; return ; }, Table: ({ resources }) => ( ), Icon: ({ id }) => , BigIcon: ({ id }) => , State: ({ id }) => { const state = useStack(id)?.info.state ?? Types.StackState.Unknown; return ; }, Info: { Server: ({ id }) => { const info = useStack(id)?.info; const server = useServer(info?.server_id); return server?.id ? ( ) : (
Unknown Server
); }, Source: ({ id }) => { const info = useStack(id)?.info; return ; }, // Branch: ({ id }) => { // const config = useFullStack(id)?.config; // const file_contents = config?.file_contents; // if (file_contents || !config?.branch) return null; // return ( //
// // {config.branch} //
// ); // }, Services: ({ id }) => { const info = useStack(id)?.info; return (
{info?.services.length}
Service{(info?.services.length ?? 0 > 1) ? "s" : ""}
); }, }, Status: { NoConfig: ({ id }) => { const config = useFullStack(id)?.config; if ( !config || config?.files_on_host || config?.file_contents || config?.linked_repo || config?.repo ) { return null; } return (
Config Missing
No configuration provided for stack. Cannot get stack state. Either paste the compose file contents into the UI, or configure a git repo containing your files.
); }, ProjectMissing: ({ id }) => { const info = useStack(id)?.info; const state = info?.state ?? Types.StackState.Unknown; if ( !info || !info?.project_missing || [Types.StackState.Down, Types.StackState.Unknown].includes(state) ) { return null; } return (
Project Missing
The compose project is not on the host. If the compose stack is running, the 'Project Name' needs to be set. This can be found with 'docker compose ls'.
); }, RemoteErrors: ({ id }) => { const info = useFullStack(id)?.info; const errors = info?.remote_errors; if (!info || !errors || errors.length === 0) { return null; } return (
Remote Error
There are errors reading the remote file contents. See{" "} Info tab for details.
); }, UpdateAvailable: ({ id }) => , Hash: ({ id }) => { const info = useStack(id)?.info; const fullInfo = useFullStack(id)?.info; const state = info?.state; const stackDown = state === undefined || state === Types.StackState.Unknown || state === Types.StackState.Down; if ( stackDown || info?.project_missing || !info?.latest_hash || !fullInfo ) { return null; } const out_of_date = info.deployed_hash && info.deployed_hash !== info.latest_hash; return (
{info.deployed_hash ? "deployed" : "latest"}:{" "} {info.deployed_hash || info.latest_hash}
message {fullInfo.deployed_message || fullInfo.latest_message} {out_of_date && ( <> latest
{info.latest_hash} : {fullInfo.latest_message}
)}
); }, Refresh: ({ id }) => { const { toast } = useToast(); const inv = useInvalidate(); const { mutate, isPending } = useWrite("RefreshStackCache", { onSuccess: () => { inv(["ListStacks"], ["GetStack", { stack: id }]); toast({ title: "Refreshed stack status cache" }); }, }); return ( ); }, }, Actions: { DeployStack, PullStack, RestartStack, PauseUnpauseStack, StartStopStack, DestroyStack, }, Page: {}, Config: ConfigInfoServicesLog, DangerZone: ({ id }) => , ResourcePageHeader: ({ id }) => { const stack = useStack(id); return ( } type="Stack" id={id} resource={stack} state={stack?.info.state} status={ stack?.info.state === Types.StackState.Unhealthy ? stack?.info.status : undefined } /> ); }, }; export const UpdateAvailable = ({ id, small = false, }: { id: string; small?: boolean; }) => { const info = useStack(id)?.info; const state = info?.state ?? Types.StackState.Unknown; if ( !info || !!info?.services.every((service) => !service.update_available) || [Types.StackState.Down, Types.StackState.Unknown].includes(state) ) { return null; } return (
{!small && (
Update {(info?.services.filter((s) => s.update_available).length ?? 0) > 1 ? "s" : ""}{" "} Available
)}
{info?.services .filter((service) => service.update_available) .map((s) => (
{s.service}
-
{s.image}
))}
); }; ================================================ FILE: frontend/src/components/resources/stack/info.tsx ================================================ import { Section } from "@components/layouts"; import { ReactNode, useState } from "react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@ui/card"; import { useFullStack, useStack } from "."; import { cn, updateLogToHtml } from "@lib/utils"; import { language_from_path, MonacoEditor } from "@components/monaco"; import { usePermissions } from "@lib/hooks"; import { ConfirmUpdate } from "@components/config/util"; import { useLocalStorage, useWrite } from "@lib/hooks"; import { Button } from "@ui/button"; import { FilePlus, History } from "lucide-react"; import { useToast } from "@ui/use-toast"; import { ConfirmButton, ShowHideButton, CopyButton } from "@components/util"; import { DEFAULT_STACK_FILE_CONTENTS } from "./config"; import { Types } from "komodo_client"; export const StackInfo = ({ id, titleOther, }: { id: string; titleOther: ReactNode; }) => { const [edits, setEdits] = useLocalStorage>( `stack-${id}-edits`, {} ); const [show, setShow] = useState>({}); const { canWrite } = usePermissions({ type: "Stack", id }); const { toast } = useToast(); const { mutateAsync, isPending } = useWrite("WriteStackFileContents", { onSuccess: (res) => { toast({ title: res.success ? "Contents written." : "Failed to write contents.", variant: res.success ? undefined : "destructive", }); }, }); const not_down = useStack(id)?.info.state !== Types.StackState.Down; const stack = useFullStack(id); // const state = useStack(id)?.info.state ?? Types.StackState.Unknown; // const is_down = [Types.StackState.Down, Types.StackState.Unknown].includes( // state // ); const file_on_host = stack?.config?.files_on_host ?? false; const git_repo = !!(stack?.config?.repo || stack?.config?.linked_repo); const canEdit = canWrite && (file_on_host || git_repo); const editFileCallback = (path: string) => (contents: string) => setEdits({ ...edits, [path]: contents }); // Collect deployed / latest contents, joining // them by path. // Only unmatched latest contents end up in latest_contents. // const deployed_contents: { // path: string; // deployed: string; // modified: string | undefined; // }[] = []; // if (!is_down) { // for (const content of stack?.info?.deployed_contents ?? []) { // const latest = stack?.info?.remote_contents?.find( // (latest) => latest.path === content.path // ); // const modified = // latest?.contents && // (latest.contents !== content.contents ? latest.contents : undefined); // deployed_contents.push({ // path: content.path, // deployed: content.contents, // modified, // }); // } // } const latest_contents = stack?.info?.remote_contents; const latest_errors = stack?.info?.remote_errors; // Contents will be default hidden if there is more than 2 file editor to show const default_show_contents = !latest_contents || latest_contents.length < 3; return (
{/* Errors */} {latest_errors && latest_errors.length > 0 && latest_errors.map((error) => (
Path:
{error.path}
{canEdit && ( } onClick={() => { if (stack) { mutateAsync({ stack: stack.name, file_path: error.path, contents: DEFAULT_STACK_FILE_CONTENTS, }); } }} loading={isPending} /> )}
            
          
        ))}

      {/* Update deployed contents with diff */}
      {/* {!is_down && deployed_contents.length > 0 && (
        
          
            deployed contents:{" "}
          
          
            {deployed_contents.map((content) => {
              return (
                
                  
path: {content.path}
{canEdit && (
{ if (stack) { mutateAsync({ stack: stack.name, file_path: content.path, contents: edits[content.path]!, }).then(() => setEdits({ ...edits, [content.path]: undefined, }) ); } }} disabled={!edits[content.path]} />
)}
{content.modified ? ( ) : ( )}
); })}
)} */} {/* Update latest contents */} {latest_contents && latest_contents.length > 0 && latest_contents.map((content) => { const showContents = show[content.path] ?? default_show_contents; const handleToggleShow = () => { setShow((show) => ({ ...show, [content.path]: !(show[content.path] ?? default_show_contents), })); }; return ( { if ( (e.key === "Enter" || e.key === " ") && e.target === e.currentTarget ) { if (e.key === " ") e.preventDefault(); handleToggleShow(); } }} >
File: {content.path} e.stopPropagation()} data-copy-button>
{canEdit && ( <> e.stopPropagation()}> { if (stack) { return await mutateAsync({ stack: stack.name, file_path: content.path, contents: edits[content.path]!, }).then(() => setEdits({ ...edits, [content.path]: undefined, }) ); } }} disabled={!edits[content.path]} language="yaml" loading={isPending} /> )} {}} />
{showContents && ( )}
); })} {stack?.info?.deployed_config && not_down && ( Deployed config: Output of 'docker compose config' when Stack was last deployed. )}
); }; ================================================ FILE: frontend/src/components/resources/stack/log.tsx ================================================ import { LocalStorageSetter, useLocalStorage, useRead } from "@lib/hooks"; import { Types } from "komodo_client"; import { ReactNode } from "react"; import { useStack } from "."; import { Log, LogSection } from "@components/log"; import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger, } from "@ui/dropdown-menu"; import { CaretSortIcon } from "@radix-ui/react-icons"; export const StackLogs = ({ id, titleOther, }: { id: string; titleOther: ReactNode; }) => { const stackInfo = useStack(id)?.info; const [selectedServices, setServices] = useLocalStorage( `stack-${id}-log-services`, [] ); if ( stackInfo === undefined || stackInfo.state === Types.StackState.Unknown || stackInfo.state === Types.StackState.Down ) { return null; } return ( ({ service: s.service, selected: selectedServices.includes(s.service), }))} setServices={setServices} /> ); }; const StackLogsInner = ({ id, titleOther, services, setServices, }: { id: string; titleOther: ReactNode; services: Array<{ service: string; selected: boolean }>; setServices: (state: string[] | LocalStorageSetter) => void; }) => { const selected = services.filter((s) => s.selected); return ( NoSearchLogs( id, services.filter((s) => s.selected).map((s) => s.service), tail, timestamps, stream, poll ) } search_logs={(timestamps, terms, invert, poll) => SearchLogs( id, services.filter((s) => s.selected).map((s) => s.service), terms, invert, timestamps, poll ) } titleOther={titleOther} extraParams={
Services:
{" "} {selected.length === 0 ? "All" : selected.map((s) => s.service).join(", ")}
{services.map((s) => { return ( { e.preventDefault(); if (s.selected) { setServices((services) => services.filter((service) => service !== s.service) ); } else { setServices((services) => [...services, s.service]); } }} > {s.service} ); })}
} /> ); }; const NoSearchLogs = ( id: string, services: string[], tail: number, timestamps: boolean, stream: string, poll: boolean ) => { const { data: log, refetch } = useRead( "GetStackLog", { stack: id, services, tail, timestamps, }, { refetchInterval: poll ? 3000 : false } ); return { Log: (
), refetch, stderr: !!log?.stderr, }; }; const SearchLogs = ( id: string, services: string[], terms: string[], invert: boolean, timestamps: boolean, poll: boolean ) => { const { data: log, refetch } = useRead( "SearchStackLog", { stack: id, services, terms, combinator: Types.SearchCombinator.And, invert, timestamps, }, { refetchInterval: poll ? 10000 : false } ); return { Log: (
), refetch, stderr: !!log?.stderr, }; }; ================================================ FILE: frontend/src/components/resources/stack/services.tsx ================================================ import { Section } from "@components/layouts"; import { container_state_intention, stroke_color_class_by_intention, } from "@lib/color"; import { useRead } from "@lib/hooks"; import { cn } from "@lib/utils"; import { DataTable, SortableHeader } from "@ui/data-table"; import { useStack } from "."; import { Types } from "komodo_client"; import { Fragment, ReactNode } from "react"; import { Link } from "react-router-dom"; import { Button } from "@ui/button"; import { Layers2 } from "lucide-react"; import { ContainerPortsTableView, DockerResourceLink, StatusBadge, } from "@components/util"; export const StackServices = ({ id, titleOther, }: { id: string; titleOther: ReactNode; }) => { const info = useStack(id)?.info; const server_id = info?.server_id; const state = info?.state ?? Types.StackState.Unknown; const services = useRead( "ListStackServices", { stack: id }, { refetchInterval: 10_000 } ).data; if ( !services || services.length === 0 || [Types.StackState.Unknown, Types.StackState.Down].includes(state) ) { return null; } return (
( ), cell: ({ row }) => { const state = row.original.container?.state; const color = stroke_color_class_by_intention( container_state_intention(state) ); return ( e.stopPropagation()} > ); }, }, { accessorKey: "container.state", size: 160, header: ({ column }) => ( ), cell: ({ row }) => { const state = row.original.container?.state; return ( ); }, }, { accessorKey: "container.image", size: 300, header: ({ column }) => ( ), cell: ({ row }) => server_id && ( ), // size: 200, }, { accessorKey: "container.networks.0", size: 200, header: ({ column }) => ( ), cell: ({ row }) => (row.original.container?.networks?.length ?? 0) > 0 ? (
{server_id && row.original.container?.networks?.map((network, i) => ( {i !== row.original.container!.networks!.length - 1 && (
|
)}
))}
) : ( server_id && row.original.container?.network_mode && ( ) ), }, { accessorKey: "container.ports.0", size: 200, header: ({ column }) => ( ), cell: ({ row }) => ( ), }, ]} />
); }; ================================================ FILE: frontend/src/components/resources/stack/table.tsx ================================================ import { useRead, useSelectedResources } from "@lib/hooks"; import { DataTable, SortableHeader } from "@ui/data-table"; import { ResourceLink, StandardSource } from "../common"; import { TableTags } from "@components/tags"; import { StackComponents, UpdateAvailable } from "."; import { Types } from "komodo_client"; import { useCallback } from "react"; export const StackTable = ({ stacks }: { stacks: Types.StackListItem[] }) => { const servers = useRead("ListServers", {}).data; const serverName = useCallback( (id: string) => servers?.find((server) => server.id === id)?.name, [servers] ); const [_, setSelectedResources] = useSelectedResources("Stack"); return ( name, onSelect: setSelectedResources, }} columns={[ { header: ({ column }) => ( ), accessorKey: "name", cell: ({ row }) => { return (
); }, size: 200, }, { header: ({ column }) => ( ), accessorKey: "info.repo", cell: ({ row }) => , size: 200, }, { header: ({ column }) => ( ), accessorKey: "info.server_id", sortingFn: (a, b) => { const sa = serverName(a.original.info.server_id); const sb = serverName(b.original.info.server_id); if (!sa && !sb) return 0; if (!sa) return 1; if (!sb) return -1; if (sa > sb) return 1; else if (sa < sb) return -1; else return 0; }, cell: ({ row }) => ( ), size: 200, }, { accessorKey: "info.state", header: ({ column }) => ( ), cell: ({ row }) => , size: 120, }, { header: "Tags", cell: ({ row }) => , }, ]} /> ); }; ================================================ FILE: frontend/src/components/sidebar.tsx ================================================ import { SIDEBAR_RESOURCES, cn, usableResourcePath } from "@lib/utils"; import { Button } from "@ui/button"; import { AlertTriangle, Bell, Box, Boxes, CalendarDays, LayoutDashboard, Settings, } from "lucide-react"; import { Link, useLocation } from "react-router-dom"; import { ResourceComponents } from "./resources"; import { Separator } from "@ui/separator"; import { ReactNode } from "react"; import { useAtom } from "jotai"; import { homeViewAtom } from "@main"; export const Sidebar = () => { const [view, setView] = useAtom(homeViewAtom); return (
} onClick={() => setView("Dashboard")} highlighted={view === "Dashboard"} /> } onClick={() => setView("Resources")} highlighted={view === "Resources"} /> } />

Resources

{SIDEBAR_RESOURCES.map((type) => { const RTIcon = ResourceComponents[type].Icon; const name = type === "ResourceSync" ? "Sync" : type; return ( } /> ); })}

Notifications

} /> } /> } /> } /> {/* */}
); }; const SidebarLink = ({ to, icon, label, onClick, highlighted, }: { to: string; icon: ReactNode; label: string; onClick?: () => void; highlighted?: boolean; }) => { const location = useLocation(); const hl = "/" + location.pathname.split("/")[1] === to && (highlighted ?? true); return ( ); }; ================================================ FILE: frontend/src/components/tags/index.tsx ================================================ import { useTags, useInvalidate, useRead, useShiftKeyListener, useWrite, } from "@lib/hooks"; import { cn, filterBySplit } from "@lib/utils"; import { Types } from "komodo_client"; import { Badge } from "@ui/badge"; import { Button } from "@ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover"; import { useToast } from "@ui/use-toast"; import { MinusCircle, PlusCircle, SearchX, Tag, X } from "lucide-react"; import { ReactNode, useEffect, useState } from "react"; import { tag_background_class } from "@lib/color"; type TargetExcludingSystem = Exclude; export const TagsFilter = () => { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const { tags, add_tag, remove_tag, clear_tags } = useTags(); const all_tags = useRead("ListTags", {}).data; const filtered = filterBySplit(all_tags, search, (item) => item.name); useShiftKeyListener("T", () => setOpen(true)); useShiftKeyListener("C", () => clear_tags()); return (
{tags.length > 0 && ( )} { setSearch(""); setOpen(open); }} > No Tags Found {filtered ?.filter((tag) => !tags.includes(tag._id!.$oid)) .map((tag) => ( { add_tag(tag._id!.$oid); setSearch(""); setOpen(false); }} className="flex items-center justify-between cursor-pointer" >
{tag.name}
))}
); }; export const TagsFilterTags = ({ tag_ids, onBadgeClick, }: { tag_ids?: string[]; onBadgeClick?: (tag_id: string) => void; }) => { const all_tags = useRead("ListTags", {}).data; const get_tag = (tag_id: string) => all_tags?.find((t) => t._id?.$oid === tag_id); return ( <> {tag_ids?.map((tag_id) => { const tag = get_tag(tag_id); const color = tag_background_class(tag?.color); return ( onBadgeClick && onBadgeClick(tag_id)} > {tag?.name ?? "unknown"} ); })} ); }; export const ResourceTags = ({ target, click_to_delete, className, disabled, }: { target: TargetExcludingSystem; click_to_delete?: boolean; className?: string; disabled?: boolean; }) => { const { toast } = useToast(); const inv = useInvalidate(); const { type, id } = target; const resource = useRead(`List${type}s`, {}).data?.find((d) => d.id === id); const { mutate } = useWrite("UpdateResourceMeta", { onSuccess: () => { inv([`List${type}s`]); toast({ title: "Removed tag" }); }, }); return ( { if (!click_to_delete) return; if (disabled) return; mutate({ target, tags: resource!.tags.filter((tag) => tag !== tag_id), }); }} className={className} icon={!disabled && click_to_delete && } /> ); }; export const TagsWithBadge = ({ tag_ids, onBadgeClick, className, icon, }: { tag_ids?: string[]; onBadgeClick?: (tag_id: string) => void; className?: string; icon?: ReactNode; }) => { const all_tags = useRead("ListTags", {}).data; const get_tag = (tag_id: string) => all_tags?.find((t) => t._id?.$oid === tag_id); return ( <> {tag_ids?.map((tag_id) => { const tag = get_tag(tag_id); const color = tag_background_class(tag?.color); return ( onBadgeClick && onBadgeClick(tag_id)} > {tag?.name ?? "unknown"} {icon} ); })} ); }; export const TableTags = ({ tag_ids }: { tag_ids: string[] }) => { const { toggle_tag } = useTags(); return (
); }; export const AddTags = ({ target }: { target: TargetExcludingSystem }) => { const { toast } = useToast(); const { type, id } = target; const resource = useRead(`List${type}s`, {}).data?.find((d) => d.id === id); const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); useShiftKeyListener("T", () => setOpen(true)); const all_tags = useRead("ListTags", {}).data ?? []; const all_tag_names = all_tags.map((tag) => tag.name); const inv = useInvalidate(); const { mutate: update } = useWrite("UpdateResourceMeta", { onSuccess: () => { inv([`List${type}s`]); toast({ title: `Added tag ${search}` }); setOpen(false); }, }); const { mutateAsync: create } = useWrite("CreateTag", { onSuccess: () => inv([`ListTags`]), }); useEffect(() => { if (open) setSearch(""); }, [open]); const create_tag = async () => { if (!search) return toast({ title: "Must provide tag name in input" }); const tag = await create({ name: search }); update({ target, tags: [...(resource?.tags ?? []), tag._id!.$oid], }); setOpen(false); }; if (!resource) return null; const filtered = filterBySplit(all_tags, search, (item) => item.name)?.sort( (a, b) => { if (a.name > b.name) { return 1; } else if (a.name < b.name) { return -1; } else { return 0; } } ); return ( {filtered ?.filter((tag) => !resource?.tags.includes(tag._id!.$oid)) .map((tag) => ( update({ target, tags: [...(resource?.tags ?? []), tag._id!.$oid], }) } className="cursor-pointer flex items-center justify-between gap-2" >
{tag.name}
))} {search && !all_tag_names.includes(search) && (
Create Tag
)} ); }; ================================================ FILE: frontend/src/components/terminal/container.tsx ================================================ import { Section } from "@components/layouts"; import { komodo_client, useLocalStorage } from "@lib/hooks"; import { Button } from "@ui/button"; import { CardTitle } from "@ui/card"; import { Input } from "@ui/input"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { RefreshCcw } from "lucide-react"; import { ReactNode, useCallback, useState } from "react"; import { Terminal } from "."; import { ConnectExecQuery, TerminalCallbacks } from "komodo_client"; const BASE_SHELLS = ["sh", "bash"]; export const ContainerTerminal = ({ query: { type, query }, titleOther, }: { query: ConnectExecQuery; titleOther?: ReactNode; }) => { const [_reconnect, _setReconnect] = useState(false); const triggerReconnect = () => _setReconnect((r) => !r); const [_clear, _setClear] = useState(false); const storageKey = type === "container" ? `server-${query.server}-${query.container}-shell-v1` : type === "deployment" ? `deployment-${query.deployment}-shell-v1` : `stack-${query.stack}-${query.service}-shell-v1`; const [shell, setShell] = useLocalStorage(storageKey, "sh"); const [otherShell, setOtherShell] = useState(""); const make_ws = useCallback( (callbacks: TerminalCallbacks) => komodo_client().connect_exec({ query: { type, query: { ...query, shell } } as any, ...callbacks, }), [query, shell] ); return (
docker exec -it container setOtherShell(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { setShell(otherShell); setOtherShell(""); } else { e.stopPropagation(); } }} />
} >
); }; ================================================ FILE: frontend/src/components/terminal/index.tsx ================================================ import { cn } from "@lib/utils"; import { useTheme } from "@ui/theme"; import { FitAddon } from "@xterm/addon-fit"; import { ITheme } from "@xterm/xterm"; import { TerminalCallbacks } from "komodo_client"; import { useEffect, useMemo, useRef } from "react"; import { useXTerm, UseXTermProps } from "react-xtermjs"; const LIGHT_THEME: ITheme = { background: "#f7f8f9", foreground: "#24292e", cursor: "#24292e", selectionBackground: "#c8d9fa", }; const DARK_THEME: ITheme = { background: "#151b25", foreground: "#f6f8fa", cursor: "#ffffff", selectionBackground: "#6e778a", }; export const Terminal = ({ make_ws, selected, _reconnect, _clear, }: { make_ws: (callbacks: TerminalCallbacks) => WebSocket; selected: boolean; _reconnect: boolean; _clear?: boolean; }) => { const { currentTheme } = useTheme(); const theme = currentTheme === "dark" ? DARK_THEME : LIGHT_THEME; const wsRef = useRef(null); const fitRef = useRef(new FitAddon()); const resize = () => { fitRef.current.fit(); if (term) { if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { const json = JSON.stringify({ rows: term.rows, cols: term.cols, }); const buf = new Uint8Array(json.length + 1); buf[0] = 0xff; // resize prefix for (let i = 0; i < json.length; i++) buf[i + 1] = json.charCodeAt(i); wsRef.current.send(buf); } term.focus(); } }; const onStdin = (data: string) => { // This is data user writes to stdin if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return; const buf = new Uint8Array(data.length + 1); buf[0] = 0x00; // data prefix for (let i = 0; i < data.length; i++) buf[i + 1] = data.charCodeAt(i); wsRef.current.send(buf); }; useEffect(resize, [selected]); const params: UseXTermProps = useMemo( () => ({ options: { convertEol: false, cursorBlink: true, cursorStyle: "block", fontFamily: "monospace", scrollback: 5000, // This is handled in ws on_message handler scrollOnUserInput: false, theme, }, listeners: { onResize: resize, onData: onStdin, }, addons: [fitRef.current], }), [theme] ); const { instance: term, ref: termRef } = useXTerm(params); const viewport = (term as any)?._core?.viewport?._viewportElement as | HTMLDivElement | undefined; useEffect(() => { if (!term || !viewport) return; let delta = 0; term.attachCustomWheelEventHandler((e) => { e.preventDefault(); // This is used to make touchpad and mousewheel more similar delta += Math.sign(e.deltaY) * Math.sqrt(Math.abs(e.deltaY)) * 20; return false; }); const int = setInterval(() => { if (Math.abs(delta) < 1) return; viewport.scrollTop += delta; delta = 0; }, 100); return () => clearInterval(int); }, [term, termRef.current]); useEffect(() => { if (!selected || !term) return; term.clear(); let debounce = -1; const callbacks: TerminalCallbacks = { on_login: () => { // console.log("logged in terminal"); }, on_open: resize, on_message: (e: MessageEvent) => { term.write(new Uint8Array(e.data as ArrayBuffer), () => { if (viewport) { viewport.scrollTop = viewport.scrollHeight - viewport.clientHeight; } clearTimeout(debounce); debounce = setTimeout(() => { if (!viewport) return; viewport.scrollTop = viewport.scrollHeight - viewport.clientHeight; }, 500); }); }, on_close: () => { term.writeln("\r\n\x1b[33m[connection closed]\x1b[0m"); }, }; const ws = make_ws(callbacks); wsRef.current = ws; return () => { ws.close(); wsRef.current = null; }; }, [term, viewport, make_ws, selected, _reconnect]); useEffect(() => term?.clear(), [_clear]); return (
); }; ================================================ FILE: frontend/src/components/terminal/server.tsx ================================================ import { Section } from "@components/layouts"; import { ReactNode, useCallback, useState } from "react"; import { komodo_client, useLocalStorage, useRead, useWrite } from "@lib/hooks"; import { Card, CardContent, CardHeader } from "@ui/card"; import { Badge } from "@ui/badge"; import { Button } from "@ui/button"; import { Loader2, Plus, RefreshCcw, X } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover"; import { Command, CommandGroup, CommandInput, CommandItem, CommandList, } from "@ui/command"; import { filterBySplit } from "@lib/utils"; import { useServer } from "@components/resources/server"; import { Terminal } from "."; import { TerminalCallbacks } from "komodo_client"; export const ServerTerminals = ({ id, titleOther, }: { id: string; titleOther?: ReactNode; }) => { const { data: terminals, refetch: refetchTerminals } = useRead( "ListTerminals", { server: id, fresh: true, }, { refetchInterval: 5000, } ); const { mutateAsync: create_terminal, isPending: create_pending } = useWrite("CreateTerminal"); const { mutateAsync: delete_terminal } = useWrite("DeleteTerminal"); const [_selected, setSelected] = useLocalStorage<{ selected: string | undefined; }>(`server-${id}-selected-terminal-v1`, { selected: undefined }); const terminals_disabled = useServer(id)?.info.terminals_disabled ?? true; const selected = _selected.selected ?? terminals?.[0]?.name; const [_reconnect, _setReconnect] = useState(false); const triggerReconnect = () => _setReconnect((r) => !r); const create = async (command: string) => { if (!terminals || terminals_disabled) return; const name = next_terminal_name( command, terminals.map((t) => t.name) ); await create_terminal({ server: id, name, command, }); refetchTerminals(); setTimeout(() => { setSelected({ selected: name, }); }, 100); }; return (
{terminals?.map(({ name: terminal, stored_size_kb }) => ( setSelected({ selected: terminal })} >
{terminal} {/*
{command}
*/}
{stored_size_kb.toFixed()} KiB
))} {terminals && !terminals_disabled && ( )}
{terminals?.map(({ name: terminal }) => ( ))}
); }; const ServerTerminal = ({ server, terminal, selected, _reconnect, }: { server: string; terminal: string; selected: boolean; _reconnect: boolean; }) => { const make_ws = useCallback( (callbacks: TerminalCallbacks) => komodo_client().connect_terminal({ query: { server, terminal }, ...callbacks, }), [server, terminal] ); return ( ); }; const BASE_SHELLS = ["bash", "sh"]; const NewTerminal = ({ create, pending, }: { create: (shell: string) => Promise; pending: boolean; }) => { const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const [shells, setShells] = useLocalStorage("server-shells-v1", BASE_SHELLS); const filtered = filterBySplit(shells, search, (item) => item); return ( {filtered.map((shell) => ( { create(shell); setOpen(false); }} className="flex items-center justify-between cursor-pointer" >
{shell}
{!BASE_SHELLS.includes(shell) && ( )}
))} {filtered.length === 0 && ( { setShells((shells) => [...shells, search]); create(search); setOpen(false); }} className="flex items-center justify-between cursor-pointer" >
{search}
)}
); }; const next_terminal_name = (command: string, terminal_names: string[]) => { const shell = command.split(" ")[0]; for (let i = 1; i <= terminal_names.length + 1; i++) { const name = i > 1 ? `${shell} ${i}` : shell; if (!terminal_names.includes(name)) { return name; } } return shell; }; ================================================ FILE: frontend/src/components/topbar/components.tsx ================================================ import { LOGIN_TOKENS, useManageUser, useRead, useResourceParamType, useUser, useUserInvalidate, } from "@lib/hooks"; import { ResourceComponents } from "../resources"; import { AlertTriangle, ArrowLeftRight, Bell, Box, Boxes, Calendar, CalendarDays, Check, Circle, FileQuestion, FolderTree, Keyboard, LayoutDashboard, Loader2, LogOut, Plus, Settings, User, Users, X, } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@ui/dropdown-menu"; import { Button } from "@ui/button"; import { Link } from "react-router-dom"; import { cn, RESOURCE_TARGETS, usableResourcePath, version_is_none, } from "@lib/utils"; import { useAtom } from "jotai"; import { ReactNode, useState } from "react"; import { HomeView, homeViewAtom } from "@main"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, } from "@ui/dialog"; import { Badge } from "@ui/badge"; import { ConfirmButton } from "../util"; import { Types } from "komodo_client"; import { UpdateDetails, UpdateUser } from "@components/updates/details"; import { fmt_date, fmt_operation, fmt_version } from "@lib/formatting"; import { ResourceLink, ResourceNameSimple } from "@components/resources/common"; import { UsableResource } from "@types"; import { AlertLevel } from "@components/alert"; import { AlertDetailsDialogContent } from "@components/alert/details"; import { Separator } from "@ui/separator"; export const MobileDropdown = () => { const type = useResourceParamType(); const Components = type && ResourceComponents[type]; const [view, setView] = useAtom(homeViewAtom); const [icon, title] = Components ? [, (type === "ResourceSync" ? "Sync" : type) + "s"] : location.pathname === "/" && view === "Dashboard" ? [, "Dashboard"] : location.pathname === "/" && view === "Resources" ? [, "Resources"] : location.pathname === "/" && view === "Tree" ? [, "Tree"] : location.pathname === "/containers" ? [, "Containers"] : location.pathname === "/settings" ? [, "Settings"] : location.pathname === "/schedules" ? [, "Schedules"] : location.pathname === "/alerts" ? [, "Alerts"] : location.pathname === "/updates" ? [, "Updates"] : location.pathname.split("/")[1] === "user-groups" ? [, "User Groups"] : location.pathname.split("/")[1] === "users" ? [, "Users"] : [, "Unknown"]; return ( } to="/" onClick={() => setView("Dashboard")} /> } to="/" onClick={() => setView("Resources")} /> } to="/containers" /> {RESOURCE_TARGETS.map((type) => { const RTIcon = ResourceComponents[type].Icon; const name = type === "ResourceSync" ? "Sync" : type; return ( } to={`/${usableResourcePath(type)}`} /> ); })} } to="/alerts" /> } to="/updates" /> } to="/schedules" /> } to="/settings" /> ); }; const DropdownLinkItem = ({ label, icon, to, onClick, }: { label: string; icon: ReactNode; to: string; onClick?: () => void; }) => { return ( {icon} {label} ); }; export const UserDropdown = () => { const [_, setRerender] = useState(false); const rerender = () => setRerender((r) => !r); const [viewLogout, setViewLogout] = useState(false); const [open, _setOpen] = useState(false); const setOpen = (open: boolean) => { _setOpen(open); if (open) { setViewLogout(false); } }; const user = useUser().data; const userInvalidate = useUserInvalidate(); const accounts = LOGIN_TOKENS.accounts(); return (
Switch accounts
{accounts.map((login) => ( ))} {viewLogout && ( } variant="destructive" className="flex gap-2 items-center justify-center w-full max-w-full" onClick={() => { LOGIN_TOKENS.remove_all(); userInvalidate(); }} /> )}
); }; const Account = ({ login, current_id, setOpen, rerender, viewLogout, }: { login: Types.JwtResponse; current_id?: string; setOpen: (open: boolean) => void; rerender: () => void; viewLogout: boolean; }) => { const res = useRead("GetUsername", { user_id: login.user_id }); if (!res.data) return; const selected = login.user_id === current_id; return (
{viewLogout && ( )}
); }; const UsernameView = ({ username, avatar, full, }: { username: string | undefined; avatar: string | undefined; full?: boolean; }) => { return ( <> {avatar ? : }
{username}
); }; export const TopbarUpdates = () => { const updates = useRead("ListUpdates", {}).data; const last_opened = useUser().data?.last_update_view; const unseen_update = updates?.updates.some( (u) => u.start_ts > (last_opened ?? Number.MAX_SAFE_INTEGER) ); const userInvalidate = useUserInvalidate(); const { mutate } = useManageUser("SetLastSeenUpdate", { onSuccess: userInvalidate, }); return ( o && mutate({})}> {updates?.updates.map((update) => ( ))} ); }; const SingleUpdate = ({ update }: { update: Types.UpdateListItem }) => { const Components = update.target.type !== "System" ? ResourceComponents[update.target.type] : null; const Icon = () => { if (update.status === Types.UpdateStatus.Complete) { if (update.success) return ; else return ; } else return ; }; return (
{fmt_operation(update.operation)}
{!version_is_none(update.version) && fmt_version(update.version)}
{Components && ( <> )} {!Components && ( <> System )}
{update.status === Types.UpdateStatus.InProgress ? "ongoing" : fmt_date(new Date(update.start_ts))}
); }; export const TopbarAlerts = () => { const { data } = useRead( "ListAlerts", { query: { resolved: false } }, { refetchInterval: 3_000 } ); const [open, setOpen] = useState(false); // If this is set, details will open. const [alert, setAlert] = useState(); if (!data || data.alerts.length === 0) { return null; } return ( <> {data?.alerts.map((alert) => ( setAlert(alert)} >
setOpen(false)} />

{alert.data.type}

))}
setAlert(undefined)} /> ); }; const AlertDetails = ({ alert, onClose, }: { alert: Types.Alert | undefined; onClose: () => void; }) => ( <> {alert && ( !o && onClose()}> )} ); export const Docs = () => ( ); export const Version = () => { const version = useRead("GetVersion", {}, { refetchInterval: 30_000 }).data ?.version; if (!version) return null; return ( ); }; export const KeyboardShortcuts = () => { return ( Keyboard Shortcuts
); }; const KeyboardShortcut = ({ label, keys, divider = true, }: { label: string; keys: string[]; divider?: boolean; }) => { return ( <>
{label}
{keys.map((key) => ( {key} ))}
{divider && (
)} ); }; ================================================ FILE: frontend/src/components/topbar/index.tsx ================================================ import { useShiftKeyListener } from "@lib/hooks"; import { Link } from "react-router-dom"; import { OmniSearch, OmniDialog } from "../omnibar"; import { WsStatusIndicator } from "@lib/socket"; import { ThemeToggle } from "@ui/theme"; import { useState } from "react"; import { Docs, KeyboardShortcuts, MobileDropdown, TopbarAlerts, TopbarUpdates, UserDropdown, Version, } from "./components"; export const Topbar = () => { const [omniOpen, setOmniOpen] = useState(false); useShiftKeyListener("S", () => setOmniOpen(true)); return (
{/* Logo */}
KOMODO
{/* Searchbar */}
{/* Shortcuts */}
); }; ================================================ FILE: frontend/src/components/updates/details.tsx ================================================ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger, } from "@ui/sheet"; import { Calendar, Clock, Link2, Loader2, Milestone, Settings, User, } from "lucide-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@ui/card"; import { ReactNode, useEffect, useState } from "react"; import { useRead } from "@lib/hooks"; import { ResourceComponents } from "@components/resources"; import { Link } from "react-router-dom"; import { fmt_duration, fmt_operation, fmt_version } from "@lib/formatting"; import { cn, updateLogToHtml, usableResourcePath, version_is_none, } from "@lib/utils"; import { UsableResource } from "@types"; import { CopyButton, UserAvatar } from "@components/util"; import { ResourceNameSimple } from "@components/resources/common"; import { useWebsocketMessages } from "@lib/socket"; import { MonacoDiffEditor } from "@components/monaco"; export const UpdateUser = ({ user_id, className, iconSize = 4, defaultAvatar, muted, }: { user_id: string; className?: string; iconSize?: number; defaultAvatar?: boolean; muted?: boolean; }) => { const res = useRead("GetUsername", { user_id }).data; const username = res?.username; const avatar = res?.avatar; return (
{defaultAvatar ? ( ) : ( )} {username || "Unknown"}
); }; export const UpdateDetails = ({ id, children, }: { id: string; children: ReactNode; }) => { const [open, setOpen] = useState(false); return ( ); }; export const UpdateDetailsInner = ({ id, children, open, setOpen, }: { id: string; open: boolean; setOpen: React.Dispatch>; children?: ReactNode; }) => { return ( {children} ); }; export const UpdateDetailsContent = ({ id, open, setOpen, }: { id: string; open?: boolean; setOpen: React.Dispatch>; }) => { const { data: update, refetch } = useRead( "GetUpdate", { id }, { enabled: false } ); useEffect(() => { // handle open state change loading if (open) { refetch(); } }, [open]); // Since auto refetching is disabled, listen for updates on the update id and refetch useWebsocketMessages("update-details", (update) => { if (update.id === id) refetch(); }); if (!update) return (
); const Components = update.target.type === "System" ? null : ResourceComponents[update.target.type]; return ( <> {fmt_operation(update.operation)}{" "} {!version_is_none(update.version) && fmt_version(update.version)}
{Components ? (
setOpen?.(false)} >
) : (
System
)} {update.version && (
{fmt_version(update.version)}
)}
{new Date(update.start_ts).toLocaleString()}
{update.end_ts ? fmt_duration(update.start_ts, update.end_ts) : "ongoing"}
} label={"shareable link"} />
{update.prev_toml && update.current_toml && ( Changes made )} {update.logs?.map((log, i) => ( {log.stage} Stage {i + 1} of {update.logs.length} | {fmt_duration(log.start_ts, log.end_ts)} {log.command && (
command
                    {log.command}
                  
)} {log.stdout && (
stdout
                
)} {log.stderr && (
stderr
                
)}
))}
); }; ================================================ FILE: frontend/src/components/updates/resource.tsx ================================================ import { useRead } from "@lib/hooks"; import { Button } from "@ui/button"; import { Bell, ExternalLink, Calendar, Check, X, Loader2, Milestone, } from "lucide-react"; import { Link } from "react-router-dom"; import { Types } from "komodo_client"; import { Section } from "@components/layouts"; import { UpdateDetails, UpdateUser } from "./details"; import { UpdateStatus } from "komodo_client/dist/types"; import { fmt_date, fmt_operation, fmt_version } from "@lib/formatting"; import { getUpdateQuery, usableResourcePath, version_is_none, } from "@lib/utils"; import { Card } from "@ui/card"; import { UsableResource } from "@types"; const UpdateCard = ({ update, smallHidden, }: { update: Types.UpdateListItem; smallHidden?: boolean; }) => { const Icon = () => { if (update.status === UpdateStatus.Complete) { if (update.success) return ; else return ; } else return ; }; return (
{fmt_operation(update.operation)}
{!version_is_none(update.version) && (
{fmt_version(update.version)}
)}
{fmt_date(new Date(update.start_ts))}
); }; export const AllUpdates = () => { const updates = useRead("ListUpdates", {}).data; return (
} actions={ } >
{updates?.updates.slice(0, 3).map((update, i) => ( 1} /> ))}
); }; export const ResourceUpdates = ({ type, id }: Types.ResourceTarget) => { const deployments = useRead("ListDeployments", {}).data; const updates = useRead("ListUpdates", { query: getUpdateQuery({ type, id }, deployments), }).data; return (
} actions={ } >
{updates?.updates.slice(0, 3).map((update, i) => ( 1} /> ))}
); }; ================================================ FILE: frontend/src/components/updates/table.tsx ================================================ import { fmt_date_with_minutes, fmt_operation } from "@lib/formatting"; import { Types } from "komodo_client"; import { DataTable } from "@ui/data-table"; import { useState } from "react"; import { UpdateDetailsInner, UpdateUser } from "./details"; import { ResourceLink } from "@components/resources/common"; import { Settings } from "lucide-react"; import { StatusBadge } from "@components/util"; export const UpdatesTable = ({ updates, showTarget, }: { updates: Types.UpdateListItem[]; showTarget?: boolean; }) => { const [id, setId] = useState(""); return ( <> { const more = row.original.status === Types.UpdateStatus.InProgress ? "in progress" : row.original.status === Types.UpdateStatus.Queued ? "queued" : undefined; return (
{fmt_operation(row.original.operation)}{" "} {more && (
{more}
)}
); }, }, showTarget && { header: "Target", cell: ({ row }) => row.original.target.type === "System" ? (
System
) : ( ), }, { header: "Result", cell: ({ row }) => { const { success, status } = row.original; return ( ); }, }, { header: "Start Time", accessorFn: ({ start_ts }) => fmt_date_with_minutes(new Date(start_ts)), }, { header: "Operator", accessorKey: "operator", cell: ({ row }) => , }, ]} onRowClick={(row) => setId(row.id)} /> setId("")} /> ); }; ================================================ FILE: frontend/src/components/users/delete-user-group.tsx ================================================ import { ActionWithDialog } from "@components/util"; import { useInvalidate, useWrite } from "@lib/hooks"; import { useToast } from "@ui/use-toast"; import { Types } from "komodo_client"; import { Trash } from "lucide-react"; import { useNavigate } from "react-router-dom"; export const DeleteUserGroup = ({ group }: { group: Types.UserGroup }) => { const nav = useNavigate(); const inv = useInvalidate(); const { toast } = useToast(); const { mutate, isPending } = useWrite("DeleteUserGroup", { onSuccess: () => { inv( ["ListUserGroups"], ["GetUserGroup", { user_group: group._id?.$oid! }] ); toast({ title: `Deleted User Group ${group.name}` }); nav("/settings"); }, }); return ( } variant="destructive" onClick={() => mutate({ id: group._id?.$oid! })} disabled={isPending} loading={isPending} /> ); }; ================================================ FILE: frontend/src/components/users/hooks.ts ================================================ import { useRead } from "@lib/hooks"; import { Types } from "komodo_client"; import { UsableResource } from "@types"; export const useUserTargetPermissions = (user_target: Types.UserTarget) => { const permissions = useRead("ListUserTargetPermissions", { user_target, }).data; const servers = useRead("ListServers", {}).data; const stacks = useRead("ListStacks", {}).data; const deployments = useRead("ListDeployments", {}).data; const builds = useRead("ListBuilds", {}).data; const repos = useRead("ListRepos", {}).data; const procedures = useRead("ListProcedures", {}).data; const builders = useRead("ListBuilders", {}).data; const alerters = useRead("ListAlerters", {}).data; const syncs = useRead("ListResourceSyncs", {}).data; const perms: (Types.Permission & { name: string })[] = []; addPerms(user_target, permissions, "Server", servers, perms); addPerms(user_target, permissions, "Stack", stacks, perms); addPerms(user_target, permissions, "Deployment", deployments, perms); addPerms(user_target, permissions, "Build", builds, perms); addPerms(user_target, permissions, "Repo", repos, perms); addPerms(user_target, permissions, "Procedure", procedures, perms); addPerms(user_target, permissions, "Builder", builders, perms); addPerms(user_target, permissions, "Alerter", alerters, perms); addPerms(user_target, permissions, "ResourceSync", syncs, perms); return perms; }; function addPerms( user_target: Types.UserTarget, permissions: Types.Permission[] | undefined, resource_type: UsableResource, resources: Types.ResourceListItem[] | undefined, perms: (Types.Permission & { name: string })[] ) { resources?.forEach((resource) => { const perm = permissions?.find( (p) => p.resource_target.type === resource_type && p.resource_target.id === resource.id ); if (perm) { perms.push({ ...perm, name: resource.name }); } else { perms.push({ user_target, name: resource.name, level: Types.PermissionLevel.None, resource_target: { type: resource_type, id: resource.id }, }); } }); } ================================================ FILE: frontend/src/components/users/new.tsx ================================================ import { NewLayout } from "@components/layouts"; import { useInvalidate, useWrite } from "@lib/hooks"; import { Input } from "@ui/input"; import { useToast } from "@ui/use-toast"; import { useState } from "react"; export const NewUserGroup = () => { const { toast } = useToast(); const inv = useInvalidate(); const { mutateAsync } = useWrite("CreateUserGroup", { onSuccess: () => { inv(["ListUserGroups"]); toast({ title: "Created User Group" }); }, }); const [name, setName] = useState(""); return ( mutateAsync({ name })} enabled={!!name} onOpenChange={() => setName("")} >
Name setName(e.target.value)} />
); }; export const NewServiceUser = () => { const { toast } = useToast(); const inv = useInvalidate(); const { mutateAsync } = useWrite("CreateServiceUser", { onSuccess: () => { inv(["ListUsers"]); toast({ title: "Created Service User" }); }, }); const [username, setUsername] = useState(""); return ( mutateAsync({ username, description: "" })} enabled={!!username} onOpenChange={() => setUsername("")} >
Username setUsername(e.target.value)} />
); }; export const NewLocalUser = () => { const { toast } = useToast(); const inv = useInvalidate(); const { mutateAsync } = useWrite("CreateLocalUser", { onSuccess: () => { inv(["ListUsers"]); toast({ title: "Created Local User" }); }, }); const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState(""); return ( { if ( username.length === 0 || password.length === 0 || password !== passwordConfirm ) { toast({ title: "Invalid user info", variant: "destructive" }); } return await mutateAsync({ username, password }); }} enabled={!!username && !!password && password === passwordConfirm} onOpenChange={() => { setUsername(""); setPassword(""); setPasswordConfirm(""); }} >
Username setUsername(e.target.value)} /> Password setPassword(e.target.value)} /> Confirm Password setPasswordConfirm(e.target.value)} className={ !password ? undefined : password === passwordConfirm ? "border-green-500" : "border-red-500" } />
); }; ================================================ FILE: frontend/src/components/users/permissions-selector.tsx ================================================ import { useState } from "react"; import { UsableResource } from "@types"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { Types } from "komodo_client"; import { Button } from "@ui/button"; import { filterBySplit } from "@lib/utils"; import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover"; import { fmt_upper_camelcase } from "@lib/formatting"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@ui/command"; import { SearchX } from "lucide-react"; import { Checkbox } from "@ui/checkbox"; export const PermissionLevelSelector = ({ level, onSelect, disabled, }: { level: Types.PermissionLevel; onSelect: (level: Types.PermissionLevel) => void; disabled?: boolean; }) => { return ( ); }; const ALL_PERMISSIONS_BY_TYPE: { [type: string]: Types.SpecificPermission[] | undefined; } = { Server: [ Types.SpecificPermission.Attach, Types.SpecificPermission.Inspect, Types.SpecificPermission.Logs, Types.SpecificPermission.Terminal, Types.SpecificPermission.Processes, ], Stack: [ Types.SpecificPermission.Inspect, Types.SpecificPermission.Logs, Types.SpecificPermission.Terminal, ], Deployment: [ Types.SpecificPermission.Inspect, Types.SpecificPermission.Logs, Types.SpecificPermission.Terminal, ], Build: [Types.SpecificPermission.Attach], Repo: [Types.SpecificPermission.Attach], Builder: [Types.SpecificPermission.Attach], }; export const SpecificPermissionSelector = ({ open, onOpenChange, type, specific, onSelect, disabled, }: { open?: boolean; onOpenChange?: (open: boolean) => void; type: UsableResource; specific: Types.SpecificPermission[]; onSelect: (permission: Types.SpecificPermission) => void; disabled?: boolean; }) => { const [search, setSearch] = useState(""); const all_permissions = ALL_PERMISSIONS_BY_TYPE[type]; // These resources don't have any specific permissions to add if (!all_permissions) { return ( ); } const filtered = filterBySplit(all_permissions, search, (item) => item); return ( No Permissions Found {filtered.map((permission) => ( onSelect(permission)} className="flex items-center justify-between cursor-pointer" >
{fmt_upper_camelcase(permission)}
))}
); }; ================================================ FILE: frontend/src/components/users/permissions-table.tsx ================================================ import { useInvalidate, useRead, useWrite } from "@lib/hooks"; import { Types } from "komodo_client"; import { UsableResource } from "@types"; import { useToast } from "@ui/use-toast"; import { useState } from "react"; import { useUserTargetPermissions } from "./hooks"; import { Section } from "@components/layouts"; import { Input } from "@ui/input"; import { ResourceComponents } from "@components/resources"; import { Label } from "@ui/label"; import { Switch } from "@ui/switch"; import { DataTable, SortableHeader } from "@ui/data-table"; import { filterBySplit, level_to_number, resource_name, RESOURCE_TARGETS, } from "@lib/utils"; import { ResourceLink } from "@components/resources/common"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { PermissionLevelSelector, SpecificPermissionSelector, } from "./permissions-selector"; export const PermissionsTableTabs = ({ user_target, }: { user_target: Types.UserTarget; }) => { return ( <> ); }; const SpecificPermissionsTable = ({ user_target, }: { user_target: Types.UserTarget; }) => { const { toast } = useToast(); const [showAll, setShowAll] = useState(false); const [resourceType, setResourceType] = useState( "All" ); const [search, setSearch] = useState(""); const searchSplit = search.toLowerCase().split(" "); const inv = useInvalidate(); const permissions = useUserTargetPermissions(user_target); const { mutate } = useWrite("UpdatePermissionOnTarget", { onSuccess: () => { toast({ title: "Updated permission" }); inv(["ListUserTargetPermissions"]); }, }); const tableData = permissions?.filter( (permission) => (resourceType === "All" ? true : permission.resource_target.type === resourceType) && (showAll ? true : permission.level !== Types.PermissionLevel.None) && searchSplit.every( (search) => permission.name.toLowerCase().includes(search) || permission.resource_target.type.toLowerCase().includes(search) ) ) ?? []; return (
setSearch(e.target.value)} className="w-[300px]" />
setShowAll((showAll) => !showAll)} >
} > ( ), cell: ({ row }) => { const Components = ResourceComponents[ row.original.resource_target.type as UsableResource ]; return (
{row.original.resource_target.type}
); }, }, { accessorKey: "resource_target", size: 250, sortingFn: (a, b) => { const ra = resource_name( a.original.resource_target.type as UsableResource, a.original.resource_target.id ); const rb = resource_name( b.original.resource_target.type as UsableResource, b.original.resource_target.id ); if (!ra && !rb) return 0; if (!ra) return -1; if (!rb) return 1; if (ra > rb) return 1; else if (ra < rb) return -1; else return 0; }, header: ({ column }) => ( ), cell: ({ row: { original: { resource_target }, }, }) => { return ( ); }, }, { accessorKey: "level", size: 150, sortingFn: (a, b) => { const al = level_to_number(a.original.level); const bl = level_to_number(b.original.level); const dif = al - bl; return dif === 0 ? 0 : dif / Math.abs(dif); }, header: ({ column }) => ( ), cell: ({ row: { original: permission } }) => ( mutate({ ...permission, user_target, permission: { level: value, specific: permission.specific ?? [], }, }) } /> ), }, { header: "Specific", size: 300, cell: ({ row: { original: permission } }) => { return ( { const _specific = permission.specific ?? []; const specific = ( _specific.includes(specific_permission) ? _specific.filter((p) => p !== specific_permission) : [..._specific, specific_permission] ).sort(); mutate({ ...permission, user_target, permission: { level: permission.level ?? Types.PermissionLevel.None, specific, }, }); }} /> ); }, }, ]} /> ); }; type UpdateFn = ( resource_type: Types.ResourceTarget["type"], permission: Types.PermissionLevelAndSpecifics ) => void; const BasePermissionsTableInner = ({ all, update, }: { all: Types.User["all"]; update: UpdateFn; }) => { const [showAll, setShowAll] = useState(false); const [search, setSearch] = useState(""); const permissions = RESOURCE_TARGETS.map((type) => { const permission = all?.[type] ?? Types.PermissionLevel.None; return { type, level: typeof permission === "string" ? permission : permission.level, specific: typeof permission === "string" ? [] : permission.specific, }; }).filter( (item) => showAll || item.level !== Types.PermissionLevel.None || item.specific.length !== 0 ); const filtered = filterBySplit(permissions, search, (p) => p.type); return (
setSearch(e.target.value)} className="w-[300px]" />
setShowAll((s) => !s)} >
} > ( ), cell: ({ row }) => { const Components = ResourceComponents[row.original.type as UsableResource]; return (
{row.original.type}
); }, }, { accessorKey: "level", size: 150, sortingFn: (a, b) => { const al = level_to_number(a.original.level); const bl = level_to_number(b.original.level); const dif = al - bl; return dif === 0 ? 0 : dif / Math.abs(dif); }, header: ({ column }) => ( ), cell: ({ row }) => ( { update(row.original.type, { level, specific: row.original.specific, }); }} /> ), }, { header: "Specific", size: 300, cell: ({ row }) => { return ( { const _specific = row.original.specific ?? []; const specific = ( _specific.includes(specific_permission) ? _specific.filter((p) => p !== specific_permission) : [..._specific, specific_permission] ).sort(); update(row.original.type, { level: row.original.level, specific, }); }} /> ); }, }, ]} /> ); }; const BasePermissionsTable = ({ user_target, }: { user_target: Types.UserTarget; }) => { const { toast } = useToast(); const inv = useInvalidate(); const { mutate } = useWrite("UpdatePermissionOnResourceType", { onSuccess: () => { toast({ title: "Updated permissions on target" }); if (user_target.type === "User") { inv(["FindUser", { user: user_target.id }]); } else if (user_target.type === "UserGroup") { inv(["GetUserGroup", { user_group: user_target.id }]); } }, }); const update: UpdateFn = (resource_type, permission) => mutate({ user_target, resource_type, permission }); if (user_target.type === "User") { return ( ); } else if (user_target.type === "UserGroup") { return ( ); } }; const UserBasePermissionsTable = ({ user_id, update, }: { user_id: string; update: UpdateFn; }) => { const user = useRead("FindUser", { user: user_id }).data; return ; }; const UserGroupBasePermissionsTable = ({ group_id, update, }: { group_id: string; update: UpdateFn; }) => { const group = useRead("GetUserGroup", { user_group: group_id }).data; return ; }; ================================================ FILE: frontend/src/components/users/service-api-key.tsx ================================================ import { ConfirmButton, CopyButton } from "@components/util"; import { useInvalidate, useWrite } from "@lib/hooks"; import { Button } from "@ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from "@ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuTrigger, } from "@ui/dropdown-menu"; import { Input } from "@ui/input"; import { useToast } from "@ui/use-toast"; import { Check, Loader2, PlusCircle, Trash } from "lucide-react"; import { useState } from "react"; const ONE_DAY_MS = 1000 * 60 * 60 * 24; type ExpiresOptions = "90 days" | "180 days" | "1 year" | "never"; export const CreateKeyForServiceUser = ({ user_id }: { user_id: string }) => { const [open, setOpen] = useState(false); const [name, setName] = useState(""); const [expires, setExpires] = useState("never"); const [submitted, setSubmitted] = useState<{ key: string; secret: string }>(); const invalidate = useInvalidate(); const { mutate, isPending } = useWrite("CreateApiKeyForServiceUser", { onSuccess: ({ key, secret }) => { invalidate(["ListApiKeysForServiceUser"]); setSubmitted({ key, secret }); }, }); const now = Date.now(); const expiresOptions: Record = { "90 days": now + ONE_DAY_MS * 90, "180 days": now + ONE_DAY_MS * 180, "1 year": now + ONE_DAY_MS * 365, never: 0, }; const submit = () => mutate({ user_id, name, expires: expiresOptions[expires] }); const onOpenChange = (open: boolean) => { setOpen(open); if (!open) { setName(""); setExpires("never"); setSubmitted(undefined); } }; return ( {submitted ? ( <> Api Key Created
Key
Secret
) : ( <> Create Api Key
Name setName(e.target.value)} />
Expiry {Object.keys(expiresOptions) .filter((option) => option !== expires) .map((option) => ( setExpires(option as any)} > {option} ))}
)}
); }; export const DeleteKeyForServiceUser = ({ api_key }: { api_key: string }) => { const inv = useInvalidate(); const { toast } = useToast(); const { mutate, isPending } = useWrite("DeleteApiKeyForServiceUser", { onSuccess: () => { inv(["ListApiKeysForServiceUser"]); toast({ title: "Api Key Deleted" }); }, onError: () => { toast({ title: "Failed to delete api key", variant: "destructive" }); }, }); return ( } onClick={(e) => { e.stopPropagation(); mutate({ key: api_key }); }} loading={isPending} /> ); }; ================================================ FILE: frontend/src/components/users/table.tsx ================================================ import { text_color_class_by_intention } from "@lib/color"; import { Types } from "komodo_client"; import { DataTable } from "@ui/data-table"; import { useNavigate } from "react-router-dom"; import { ColumnDef } from "@tanstack/react-table"; import { MinusCircle } from "lucide-react"; import { ConfirmButton } from "@components/util"; import { useUser } from "@lib/hooks"; export const UserTable = ({ users, onUserRemove, onUserDelete, userDeleteDisabled, onSelfClick, }: { users: Types.User[]; onUserRemove?: (user_id: string) => void; onUserDelete?: (user_id: string) => void; userDeleteDisabled?: (user_id: string) => boolean; onSelfClick?: () => void; }) => { const user = useUser().data; const nav = useNavigate(); const columns: ColumnDef[] = [ { header: "Username", accessorKey: "username" }, { header: "Type", accessorKey: "config.type" }, { header: "Level", accessorFn: (user) => user.admin ? (user.super_admin ? "Super Admin" : "Admin") : "User", }, { header: "Enabled", cell: ({ row }) => { const enabledClass = row.original.enabled ? text_color_class_by_intention("Good") : text_color_class_by_intention("Critical"); return (
{row.original.enabled ? "Enabled" : "Disabled"}
); }, }, ]; if (onUserRemove) { columns.push({ header: "Remove", cell: ({ row }) => ( } onClick={(e) => { e.stopPropagation(); onUserRemove(row.original._id?.$oid!); }} /> ), }); } if (onUserDelete) { columns.push({ header: "Delete", cell: ({ row }) => ( } onClick={(e) => { e.stopPropagation(); onUserDelete(row.original._id?.$oid!); }} disabled={ row.original._id?.$oid ? userDeleteDisabled?.(row.original._id.$oid) ?? true : true } /> ), }); } return ( row._id?.$oid === user?._id?.$oid ? onSelfClick?.() : nav(`/users/${row._id!.$oid}`) } /> ); }; ================================================ FILE: frontend/src/components/util.tsx ================================================ import { Dispatch, FocusEventHandler, Fragment, MouseEventHandler, ReactNode, SetStateAction, forwardRef, useEffect, useRef, useState, } from "react"; import { Button } from "../ui/button"; import { Box, Check, CheckCircle, ChevronDown, ChevronLeft, ChevronsUpDown, ChevronUp, Copy, Database, EthernetPort, FolderGit, HardDrive, LinkIcon, Loader2, Network, Search, SearchX, Settings, Tags, User, } from "lucide-react"; import { Input } from "../ui/input"; import { Dialog, DialogHeader, DialogTitle, DialogTrigger, DialogContent, DialogFooter, } from "@ui/dialog"; import { toast, useToast } from "@ui/use-toast"; import { cn, filterBySplit, usableResourcePath } from "@lib/utils"; import { Link, useNavigate } from "react-router-dom"; import { Textarea } from "@ui/textarea"; import { Card } from "@ui/card"; import { fmt_port_mount, fmt_resource_type, fmt_utc_offset, snake_case_to_upper_space_case, } from "@lib/formatting"; import { ColorIntention, container_state_intention, hex_color_by_intention, stroke_color_class_by_intention, text_color_class_by_intention, } from "@lib/color"; import { Types } from "komodo_client"; import { Badge } from "@ui/badge"; import { Section } from "./layouts"; import { DataTable, SortableHeader } from "@ui/data-table"; import { useContainerPortsMap, useRead, useTemplatesQueryBehavior, usePromptHotkeys, } from "@lib/hooks"; import { Prune } from "./resources/server/actions"; import { MonacoEditor, MonacoLanguage } from "./monaco"; import { UsableResource } from "@types"; import { ResourceComponents } from "./resources"; import { Popover, PopoverContent, PopoverTrigger } from "@ui/popover"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from "@ui/command"; import { Tooltip, TooltipContent, TooltipTrigger } from "@ui/tooltip"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { useServer } from "./resources/server"; export const ActionButton = forwardRef< HTMLButtonElement, { variant?: | "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | undefined; size?: "default" | "sm" | "lg" | "icon" | null | undefined; title: string; icon: ReactNode; disabled?: boolean; className?: string; onClick?: MouseEventHandler; onBlur?: FocusEventHandler; loading?: boolean; "data-confirm-button"?: boolean; } >( ( { variant, size, title, icon, disabled, className, loading, onClick, onBlur, "data-confirm-button": dataConfirmButton, }, ref ) => ( ) ); export const ActionWithDialog = ({ name, title, icon, disabled, loading, onClick, additional, targetClassName, variant, forceConfirmDialog, }: { name: string; title: string; icon: ReactNode; disabled?: boolean; loading?: boolean; onClick?: () => void; additional?: ReactNode; targetClassName?: string; variant?: | "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | undefined; /** * For some ops (Delete), force confirm dialog * even if disabled. */ forceConfirmDialog?: boolean; }) => { const disable_confirm_dialog = useRead("GetCoreInfo", {}).data?.disable_confirm_dialog ?? false; const [open, setOpen] = useState(false); const [input, setInput] = useState(""); const confirmButtonRef = useRef(null); // Add prompt hotkeys for better UX when dialog is open usePromptHotkeys({ onConfirm: () => { if (name === input && !disabled) { onClick && onClick(); setOpen(false); } }, onCancel: () => setOpen(false), enabled: open, confirmDisabled: disabled || name !== input, }); // If confirm dialogs are disabled and this isn't forced, use ConfirmButton directly if (!forceConfirmDialog && disable_confirm_dialog) { return ( ); } return ( { setOpen(open); setInput(""); }} > setOpen(true)} loading={loading} variant={variant} /> Confirm {title}

{ navigator.clipboard.writeText(name); toast({ title: `Copied "${name}" to clipboard!` }); }} className="cursor-pointer" > Please enter {name} below to confirm this action.
You may click the name in bold to copy it

setInput(e.target.value)} /> {additional}
{ onClick && onClick(); setOpen(false); }} />
); }; export const ConfirmButton = forwardRef< HTMLButtonElement, { variant?: | "link" | "default" | "destructive" | "outline" | "secondary" | "ghost" | null | undefined; size?: "default" | "sm" | "lg" | "icon" | null | undefined; title: string; icon: ReactNode; onClick?: MouseEventHandler; loading?: boolean; disabled?: boolean; className?: string; } >(({ variant, size, title, icon, disabled, loading, onClick, className, }, ref) => { const [confirmed, set] = useState(false); return ( : icon} disabled={disabled} onClick={ confirmed ? (e) => { e.stopPropagation(); onClick && onClick(e); set(false); } : (e) => { e.stopPropagation(); set(true); } } onBlur={() => set(false)} loading={loading} className={className} data-confirm-button={true} /> ); }); export const UserSettings = () => ( ); export const CopyButton = ({ content, className, icon = , label = "selection", }: { content: string | undefined; className?: string; icon?: ReactNode; label?: string; }) => { const { toast } = useToast(); const [copied, set] = useState(false); useEffect(() => { if (copied) { toast({ title: "Copied " + label }); const timeout = setTimeout(() => set(false), 3000); return () => { clearTimeout(timeout); }; } }, [content, copied, toast]); return ( ); }; export const TextUpdateMenuMonaco = ({ title, titleRight, value = "", triggerClassName, onUpdate, placeholder, confirmButton, disabled, fullWidth, open, setOpen, triggerHidden, language, triggerChild, }: { title: string; titleRight?: ReactNode; value: string | undefined; onUpdate: (value: string) => void; triggerClassName?: string; placeholder?: string; confirmButton?: boolean; disabled?: boolean; fullWidth?: boolean; open?: boolean; setOpen?: (open: boolean) => void; triggerHidden?: boolean; language?: MonacoLanguage; triggerChild?: ReactNode; }) => { const [_open, _setOpen] = useState(false); const [__open, __setOpen] = [open ?? _open, setOpen ?? _setOpen]; const [_value, setValue] = useState(value); useEffect(() => setValue(value), [value]); const onClick = () => { onUpdate(_value); __setOpen(false); }; return ( {triggerChild ?? (
{value.split("\n")[0] || placeholder}
)}
{titleRight && (
{title} {titleRight}
)} {!titleRight && ( {title} )} {!disabled && ( {confirmButton ? ( } onClick={onClick} /> ) : ( )} )}
); }; export const UserAvatar = ({ avatar, size = 4, }: { avatar: string | undefined; size?: number; }) => avatar ? ( Avatar ) : ( ); export const StatusBadge = ({ text, intent, }: { text: string | undefined; intent: ColorIntention; }) => { if (!text) return null; const color = text_color_class_by_intention(intent); const background = hex_color_by_intention(intent) + "25"; const _text = text === Types.ServerState.NotOk ? "Not Ok" : text; const displayText = snake_case_to_upper_space_case(_text).toUpperCase(); // Special handling for "VERSION MISMATCH" with flex layout for responsive design if (displayText === "VERSION MISMATCH") { return (
VERSION MISMATCH
); } return (

{displayText}

); }; export const DockerOptions = ({ options, }: { options: Record | undefined; }) => { if (!options) return null; const entries = Object.entries(options); if (entries.length === 0) return null; return (
{entries.map(([key, value]) => ( {key} = {value} ))}
); }; export const DockerLabelsSection = ({ labels, }: { labels: Record | undefined; }) => { if (!labels) return null; const entries = Object.entries(labels); if (entries.length === 0) return null; return (
}>
{entries.map(([key, value]) => ( {key} = {value} ))}
); }; export const ShowHideButton = ({ show, setShow, }: { show: boolean; setShow: (show: boolean) => void; }) => { return ( ); }; type DockerResourceType = "container" | "network" | "image" | "volume"; export const DOCKER_LINK_ICONS: { [type in DockerResourceType]: React.FC<{ server_id: string; name: string | undefined; size?: number; }>; } = { container: ({ server_id, name, size = 4 }) => { const state = useRead("ListDockerContainers", { server: server_id }).data?.find( (container) => container.name === name )?.state ?? Types.ContainerStateStatusEnum.Empty; return ( ); }, network: ({ server_id, name, size = 4 }) => { const containers = useRead("ListDockerContainers", { server: server_id }).data ?? []; const no_containers = !name ? false : containers.every((container) => !container.networks?.includes(name)); return ( ); }, image: ({ server_id, name, size = 4 }) => { const containers = useRead("ListDockerContainers", { server: server_id }).data ?? []; const no_containers = !name ? false : containers.every((container) => container.image_id !== name); return ( ); }, volume: ({ server_id, name, size = 4 }) => { const containers = useRead("ListDockerContainers", { server: server_id }).data ?? []; const no_containers = !name ? false : containers.every((container) => !container.volumes?.includes(name)); return ( ); }, }; export const DockerResourceLink = ({ server_id, name, id, type, extra, muted, }: { server_id: string; name: string | undefined; id?: string; type: "container" | "network" | "image" | "volume"; extra?: ReactNode; muted?: boolean; }) => { if (!name) return "Unknown"; const Icon = DOCKER_LINK_ICONS[type]; return (
{name}
{extra &&
{extra}
} ); }; export const DockerResourcePageName = ({ name: _name }: { name?: string }) => { const name = _name ?? "Unknown"; return (

{name}

); }; export const DockerContainersSection = ({ server_id, containers, show = true, setShow, pruneButton, titleOther, forceTall, _search, }: { server_id: string; containers: Types.ListDockerContainersResponse; show?: boolean; setShow?: (show: boolean) => void; pruneButton?: boolean; titleOther?: ReactNode; forceTall?: boolean; _search?: [string, Dispatch>]; }) => { const allRunning = useRead("ListDockerContainers", { server: server_id, }).data?.every( (container) => container.state === Types.ContainerStateStatusEnum.Running ); const filtered = _search ? filterBySplit(containers, _search[0], (container) => container.name) : containers; return (
: undefined} actions={
{pruneButton && !allRunning && ( )} {_search && (
_search[1](e.target.value)} placeholder="search..." className="pl-8 w-[200px] lg:w-[300px]" />
)} {setShow && }
} > {show && ( ( ), cell: ({ row }) => ( ), }, { accessorKey: "state", size: 160, header: ({ column }) => ( ), cell: ({ row }) => { const state = row.original?.state; return ( ); }, }, { accessorKey: "image", size: 300, header: ({ column }) => ( ), cell: ({ row }) => ( ), }, { accessorKey: "networks.0", size: 200, header: ({ column }) => ( ), cell: ({ row }) => (row.original.networks?.length ?? 0) > 0 ? (
{row.original.networks?.map((network, i) => ( {i !== row.original.networks!.length - 1 && (
|
)}
))}
) : ( row.original.network_mode && ( ) ), }, { accessorKey: "ports.0", size: 200, sortingFn: (a, b) => { const getMinHostPort = (row: typeof a) => { const ports = row.original.ports ?? []; if (!ports.length) return Number.POSITIVE_INFINITY; const nums = ports .map((p) => p.PublicPort) .filter((p): p is number => typeof p === "number") .map((n) => Number(n)); if (!nums.length || nums.some((n) => Number.isNaN(n))) { return Number.POSITIVE_INFINITY; } return Math.min(...nums); }; const pa = getMinHostPort(a); const pb = getMinHostPort(b); return pa === pb ? 0 : pa > pb ? 1 : -1; }, header: ({ column }) => ( ), cell: ({ row }) => ( ), }, ]} /> )}
); }; export const TextUpdateMenuSimple = ({ title, titleRight, value = "", triggerClassName, onUpdate, placeholder, confirmButton, disabled, open, setOpen, }: { title: string; titleRight?: ReactNode; value: string | undefined; onUpdate: (value: string) => void; triggerClassName?: string; placeholder?: string; confirmButton?: boolean; disabled?: boolean; open?: boolean; setOpen?: (open: boolean) => void; }) => { const [_open, _setOpen] = useState(false); const [__open, __setOpen] = [open ?? _open, setOpen ?? _setOpen]; const [_value, setValue] = useState(value); useEffect(() => setValue(value), [value]); const onClick = () => { onUpdate(_value); __setOpen(false); }; return (
{value.split("\n")[0] || placeholder}
{titleRight && (
{title} {titleRight}
)} {!titleRight && ( {title} )}
); } ================================================ FILE: docsite/src/components/HomepageFeatures/index.tsx ================================================ import clsx from 'clsx'; import Heading from '@theme/Heading'; import styles from './styles.module.css'; type FeatureItem = { title: string; description: JSX.Element; }; const FeatureList: FeatureItem[] = [ { title: "Automated builds 🛠️", description: ( <> Build auto versioned docker images from git repos, trigger builds on git push ), }, { title: "Deploy docker containers 🚀", description: ( <> Deploy containers, deploy docker compose, see uptime and logs across all your servers ), }, { title: "Powered by Rust 🦀", description: <>The core API and periphery agent are written in Rust, }, ]; function Feature({title, description}: FeatureItem) { return (
{title}

{description}

); } export default function HomepageFeatures(): JSX.Element { return (
{FeatureList.map((props, idx) => ( ))}
); } ================================================ FILE: docsite/src/components/HomepageFeatures/styles.module.css ================================================ .features { display: flex; align-items: center; padding: 4rem 0; width: 100%; } .featureSvg { height: 200px; width: 200px; } ================================================ FILE: docsite/src/components/KomodoLogo.tsx ================================================ import React from "react"; export default function KomodoLogo({ width = "4rem" }) { return ( monitor-lizard ); } ================================================ FILE: docsite/src/components/RemoteCodeFile.tsx ================================================ import React, { useEffect, useState } from "react"; import CodeBlock from "@theme/CodeBlock"; async function fetch_text_set(url: string, set: (text: string) => void) { const res = await fetch(url); const text = await res.text(); set(text); } export default function RemoteCodeFile({ url, language, title, }: { url: string; language?: string; title?: string; }) { const [file, setFile] = useState(""); useEffect(() => { fetch_text_set(url, setFile); }, []); return ( {file} ); } ================================================ FILE: docsite/src/components/SummaryImg.tsx ================================================ import React from "react"; export default function SummaryImg() { return (
monitor-summary
); } ================================================ FILE: docsite/src/css/custom.css ================================================ /** * Any CSS included here will be global. The classic template * bundles Infima by default. Infima is a CSS framework designed to * work well for content-centric websites. */ /* You can override the default Infima variables here. */ :root { --ifm-color-primary: #2e8555; --ifm-color-primary-dark: #29784c; --ifm-color-primary-darker: #277148; --ifm-color-primary-darkest: #205d3b; --ifm-color-primary-light: #33925d; --ifm-color-primary-lighter: #359962; --ifm-color-primary-lightest: #3cad6e; --ifm-code-font-size: 95%; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); } /* For readability concerns, you should choose a lighter palette in dark mode. */ [data-theme='dark'] { --ifm-color-primary: #25c2a0; --ifm-color-primary-dark: #21af90; --ifm-color-primary-darker: #1fa588; --ifm-color-primary-darkest: #1a8870; --ifm-color-primary-light: #29d5b0; --ifm-color-primary-lighter: #32d8b4; --ifm-color-primary-lightest: #4fddbf; --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); } ================================================ FILE: docsite/src/pages/index.module.css ================================================ /** * CSS files with the .module.css suffix will be treated as CSS modules * and scoped locally. */ .heroBanner { padding: 4rem 0; text-align: center; position: relative; overflow: hidden; } @media screen and (max-width: 996px) { .heroBanner { padding: 2rem; } } .buttons { display: grid; gap: 1rem; grid-template-columns: 1fr 1fr; width: fit-content; } ================================================ FILE: docsite/src/pages/index.tsx ================================================ import clsx from "clsx"; import Link from "@docusaurus/Link"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import Layout from "@theme/Layout"; import HomepageFeatures from "@site/src/components/HomepageFeatures"; import styles from "./index.module.css"; import KomodoLogo from "../components/KomodoLogo"; function HomepageHeader() { const { siteConfig } = useDocusaurusContext(); return (

Komodo

{siteConfig.tagline}

Docs Github Screenshots Demo
); } export default function Home(): JSX.Element { const { siteConfig } = useDocusaurusContext(); return (
); } ================================================ FILE: docsite/static/.nojekyll ================================================ ================================================ FILE: docsite/tsconfig.json ================================================ { // This file is not used in compilation. It is here just for a nice editor experience. "extends": "@docusaurus/tsconfig", "compilerOptions": { "baseUrl": "." } } ================================================ FILE: example/alerter/Cargo.toml ================================================ [package] name = "alerter" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true homepage.workspace = true repository.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] # local komodo_client.workspace = true logger.workspace = true # external tokio.workspace = true tracing.workspace = true axum.workspace = true anyhow.workspace = true serde.workspace = true dotenvy.workspace = true envy.workspace = true ================================================ FILE: example/alerter/Dockerfile ================================================ FROM rust:1.89.0 as builder WORKDIR /builder COPY . . RUN cargo build -p alert_logger --release FROM gcr.io/distroless/debian-cc COPY --from=builder /builder/target/release/alert_logger / EXPOSE 7000 CMD ["./alert_logger"] ================================================ FILE: example/alerter/README.md ================================================ # Alerter This crate sets up a basic axum server that listens for incoming alert POSTs. It can be used as a Komodo alerting endpoint, and serves as a template for other custom alerter implementations. ================================================ FILE: example/alerter/src/main.rs ================================================ #[macro_use] extern crate tracing; use std::{net::SocketAddr, str::FromStr}; use anyhow::Context; use axum::{routing::post, Json, Router}; use komodo_client::entities::alert::{Alert, SeverityLevel}; use serde::Deserialize; /// Entrypoint for handling each incoming alert. async fn handle_incoming_alert(Json(alert): Json) { if alert.resolved { info!("Alert Resolved!: {alert:?}"); return; } match alert.level { SeverityLevel::Ok => info!("{alert:?}"), SeverityLevel::Warning => warn!("{alert:?}"), SeverityLevel::Critical => error!("{alert:?}"), } } /// ======================== /// Http server boilerplate. /// ======================== #[derive(Deserialize)] struct Env { #[serde(default = "default_port")] port: u16, } fn default_port() -> u16 { 7000 } async fn app() -> anyhow::Result<()> { dotenvy::dotenv().ok(); logger::init(&Default::default())?; let Env { port } = envy::from_env().context("failed to parse env")?; let socket_addr = SocketAddr::from_str(&format!("0.0.0.0:{port}")) .context("invalid socket addr")?; info!("v {} | {socket_addr}", env!("CARGO_PKG_VERSION")); let app = Router::new().route("/", post(handle_incoming_alert)); let listener = tokio::net::TcpListener::bind(socket_addr) .await .context("failed to bind tcp listener")?; axum::serve(listener, app).await.context("server crashed") } #[tokio::main] async fn main() -> anyhow::Result<()> { let mut term_signal = tokio::signal::unix::signal( tokio::signal::unix::SignalKind::terminate(), )?; let app = tokio::spawn(app()); tokio::select! { res = app => return res?, _ = term_signal.recv() => {}, } Ok(()) } ================================================ FILE: example/update_logger/Cargo.toml ================================================ [package] name = "update_logger" version.workspace = true edition.workspace = true authors.workspace = true license.workspace = true homepage.workspace = true repository.workspace = true # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] # local komodo_client.workspace = true logger.workspace = true # external tokio.workspace = true tracing.workspace = true anyhow.workspace = true ================================================ FILE: example/update_logger/Dockerfile ================================================ FROM rust:1.89.0 as builder WORKDIR /builder COPY . . RUN cargo build -p update_logger --release FROM gcr.io/distroless/debian-cc COPY --from=builder /builder/target/release/update_logger / EXPOSE 7000 CMD ["./update_logger"] ================================================ FILE: example/update_logger/src/main.rs ================================================ #[macro_use] extern crate tracing; use komodo_client::{ws::UpdateWsMessage, KomodoClient}; /// Entrypoint for handling each incoming update. async fn handle_incoming_update(update: UpdateWsMessage) { info!("{update:?}"); } /// ======================== /// Ws Listener boilerplate. /// ======================== async fn app() -> anyhow::Result<()> { logger::init(&Default::default())?; info!("v {}", env!("CARGO_PKG_VERSION")); let komodo = KomodoClient::new_from_env()?.with_healthcheck().await?; let (mut rx, _) = komodo.subscribe_to_updates()?; loop { let update = match rx.recv().await { Ok(msg) => msg, Err(e) => { error!("🚨 recv error | {e:?}"); break; } }; handle_incoming_update(update).await } Ok(()) } #[tokio::main] async fn main() -> anyhow::Result<()> { let mut term_signal = tokio::signal::unix::signal( tokio::signal::unix::SignalKind::terminate(), )?; let app = tokio::spawn(app()); tokio::select! { res = app => return res?, _ = term_signal.recv() => {}, } Ok(()) } ================================================ FILE: expose.compose.yaml ================================================ services: core: ports: - 9120:9120 environment: KOMODO_FIRST_SERVER: http://periphery:8120 ================================================ FILE: frontend/.eslintrc.cjs ================================================ module.exports = { root: true, env: { browser: true, es2020: true }, extends: [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react-hooks/recommended", ], ignorePatterns: ["dist", ".eslintrc.cjs"], parser: "@typescript-eslint/parser", plugins: ["react-refresh"], rules: { "react-refresh/only-export-components": [ "warn", { allowConstantExport: true }, ], "@typescript-eslint/no-explicit-any": "off", }, }; ================================================ FILE: frontend/.gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? ================================================ FILE: frontend/Dockerfile ================================================ FROM node:20.12-alpine AS builder WORKDIR /builder COPY ./frontend ./frontend COPY ./client/core/ts ./client # Optionally specify a specific Komodo host. ARG VITE_KOMODO_HOST="" ENV VITE_KOMODO_HOST=$VITE_KOMODO_HOST # Build and link the client RUN cd client && yarn && yarn build && yarn link RUN cd frontend && yarn link komodo_client && yarn && yarn build # Copy just the static frontend to scratch image FROM scratch COPY --from=builder /builder/frontend/dist /frontend LABEL org.opencontainers.image.source=https://github.com/moghtech/komodo LABEL org.opencontainers.image.description="Komodo Frontend" LABEL org.opencontainers.image.licenses=GPL-3.0 ================================================ FILE: frontend/README.md ================================================ # Komodo Frontend Komodo JS stack uses Yarn + Vite + React + Tailwind + shadcn/ui ## Setup Dev Environment The frontend depends on the local package `komodo_client` located at `/client/core/ts`. This must first be built and prepared for yarn link. The following command should setup everything up (run with /frontend as working directory): ```sh cd ../client/core/ts && yarn && yarn build && yarn link && \ cd ../../../frontend && yarn link komodo_client && yarn ``` You can make a new file `.env.development` (gitignored) which holds: ```sh VITE_KOMODO_HOST=https://demo.komo.do ``` You can point it to any Komodo host you like, including the demo. Now you can start the dev frontend server: ```sh yarn dev ``` ================================================ FILE: frontend/components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": false, "tsx": true, "tailwind": { "config": "tailwind.config.js", "css": "src/globals.css", "baseColor": "slate", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/", "utils": "@lib/utils" } } ================================================ FILE: frontend/index.html ================================================ Komodo
================================================ FILE: frontend/package.json ================================================ { "name": "frontend", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite --host", "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", "build-client": "cd ../client/core/ts && yarn && yarn build && yarn link" }, "dependencies": { "@floating-ui/react": "0.27.9", "@monaco-editor/react": "4.7.0", "@radix-ui/react-checkbox": "1.3.2", "@radix-ui/react-dialog": "1.1.14", "@radix-ui/react-dropdown-menu": "2.1.15", "@radix-ui/react-hover-card": "1.1.14", "@radix-ui/react-icons": "1.3.2", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-popover": "1.1.14", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-select": "2.2.5", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.5", "@radix-ui/react-tabs": "1.1.12", "@radix-ui/react-toast": "1.2.14", "@radix-ui/react-toggle": "1.1.9", "@radix-ui/react-toggle-group": "1.1.10", "@tanstack/react-query": "5.77.2", "@tanstack/react-table": "8.21.3", "@xterm/addon-fit": "0.10.0", "@xterm/xterm": "5.5.0", "ansi-to-html": "0.7.2", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", "jotai": "2.12.5", "lucide-react": "0.511.0", "monaco-editor": "0.52.2", "monaco-yaml": "5.4.0", "prettier": "3.5.3", "react": "19.1.0", "react-charts": "3.0.0-beta.57", "react-dom": "19.1.0", "react-minimal-pie-chart": "9.1.0", "react-router-dom": "7.6.1", "react-xtermjs": "1.0.10", "sanitize-html": "2.17.0", "tailwind-merge": "2.6.0", "tailwindcss-animate": "1.0.7", "shell-quote": "1.8.1" }, "devDependencies": { "@types/react": "19.1.6", "@types/react-dom": "19.1.5", "@types/sanitize-html": "2.16.0", "@typescript-eslint/eslint-plugin": "8.33.0", "@typescript-eslint/parser": "8.33.0", "@vitejs/plugin-react": "4.5.0", "autoprefixer": "10.4.21", "eslint": "9.27.0", "eslint-plugin-react-hooks": "5.2.0", "eslint-plugin-react-refresh": "0.4.20", "postcss": "8.5.3", "tailwindcss": "3.4.17", "typescript": "5.8.3", "vite": "6.0.7", "vite-tsconfig-paths": "5.1.4" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } ================================================ FILE: frontend/postcss.config.js ================================================ export default { plugins: { tailwindcss: {}, autoprefixer: {}, }, } ================================================ FILE: frontend/public/client/lib.d.ts ================================================ import { AuthResponses, ExecuteResponses, ReadResponses, UserResponses, WriteResponses } from "./responses.js"; import { ConnectExecQuery, ExecuteExecBody, TerminalCallbacks } from "./terminal.js"; import { AuthRequest, ConnectTerminalQuery, ExecuteRequest, ExecuteTerminalBody, ReadRequest, Update, UpdateListItem, UserRequest, WriteRequest } from "./types.js"; export * as Types from "./types.js"; export type { ConnectExecQuery, ExecuteExecBody, TerminalCallbacks }; export type InitOptions = { type: "jwt"; params: { jwt: string; }; } | { type: "api-key"; params: { key: string; secret: string; }; }; export declare class CancelToken { cancelled: boolean; constructor(); cancel(): void; } export type ClientState = { jwt: string | undefined; key: string | undefined; secret: string | undefined; }; /** Initialize a new client for Komodo */ export declare function KomodoClient(url: string, options: InitOptions): { /** * Call the `/auth` api. * * ``` * const login_options = await komodo.auth("GetLoginOptions", {}); * ``` * * https://docs.rs/komodo_client/latest/komodo_client/api/auth/index.html */ auth: >(type: T, params: Req["params"]) => Promise; /** * Call the `/user` api. * * ``` * const { key, secret } = await komodo.user("CreateApiKey", { * name: "my-api-key" * }); * ``` * * https://docs.rs/komodo_client/latest/komodo_client/api/user/index.html */ user: >(type: T, params: Req["params"]) => Promise; /** * Call the `/read` api. * * ``` * const stack = await komodo.read("GetStack", { * stack: "my-stack" * }); * ``` * * https://docs.rs/komodo_client/latest/komodo_client/api/read/index.html */ read: >(type: T, params: Req["params"]) => Promise; /** * Call the `/write` api. * * ``` * const build = await komodo.write("UpdateBuild", { * id: "my-build", * config: { * version: "1.0.4" * } * }); * ``` * * https://docs.rs/komodo_client/latest/komodo_client/api/write/index.html */ write: >(type: T, params: Req["params"]) => Promise; /** * Call the `/execute` api. * * ``` * const update = await komodo.execute("DeployStack", { * stack: "my-stack" * }); * ``` * * NOTE. These calls return immediately when the update is created, NOT when the execution task finishes. * To have the call only return when the task finishes, use [execute_and_poll_until_complete]. * * https://docs.rs/komodo_client/latest/komodo_client/api/execute/index.html */ execute: >(type: T, params: Req["params"]) => Promise; /** * Call the `/execute` api, and poll the update until the task has completed. * * ``` * const update = await komodo.execute_and_poll("DeployStack", { * stack: "my-stack" * }); * ``` * * https://docs.rs/komodo_client/latest/komodo_client/api/execute/index.html */ execute_and_poll: >(type: T, params: Req["params"]) => Promise; /** * Poll an Update (returned by the `execute` calls) until the `status` is `Complete`. * https://docs.rs/komodo_client/latest/komodo_client/entities/update/struct.Update.html#structfield.status. */ poll_update_until_complete: (update_id: string) => Promise; /** Returns the version of Komodo Core the client is calling to. */ core_version: () => Promise; /** * Connects to update websocket, performs login and attaches handlers, * and returns the WebSocket handle. */ get_update_websocket: ({ on_update, on_login, on_open, on_close, }: { on_update: (update: UpdateListItem) => void; on_login?: () => void; on_open?: () => void; on_close?: () => void; }) => WebSocket; /** * Subscribes to the update websocket with automatic reconnect loop. * * Note. Awaiting this method will never finish. */ subscribe_to_update_websocket: ({ on_update, on_open, on_login, on_close, retry, retry_timeout_ms, cancel, on_cancel, }: { on_update: (update: UpdateListItem) => void; on_login?: () => void; on_open?: () => void; on_close?: () => void; retry?: boolean; retry_timeout_ms?: number; cancel?: CancelToken; on_cancel?: () => void; }) => Promise; /** * Subscribes to terminal io over websocket message, * for use with xtermjs. */ connect_terminal: ({ query, on_message, on_login, on_open, on_close, }: { query: ConnectTerminalQuery; } & TerminalCallbacks) => WebSocket; /** * Executes a command on a given Server / terminal, * and gives a callback to handle the output as it comes in. * * ```ts * await komodo.execute_terminal( * { * server: "my-server", * terminal: "name", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }, * { * onLine: (line) => console.log(line), * onFinish: (code) => console.log("Finished:", code), * } * ); * ``` */ execute_terminal: (request: ExecuteTerminalBody, callbacks?: import("./terminal.js").ExecuteCallbacks) => Promise; /** * Executes a command on a given Server / terminal, * and returns a stream to process the output as it comes in. * * Note. The final line of the stream will usually be * `__KOMODO_EXIT_CODE__:0`. The number * is the exit code of the command. * * If this line is NOT present, it means the stream * was terminated early, ie like running `exit`. * * ```ts * const stream = await komodo.execute_terminal_stream({ * server: "my-server", * terminal: "name", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }); * * for await (const line of stream) { * console.log(line); * } * ``` */ execute_terminal_stream: (request: ExecuteTerminalBody) => Promise>; /** * Subscribes to container exec io over websocket message, * for use with xtermjs. Can connect to container on a Server, * or associated with a Deployment or Stack. * Terminal permission on connecting resource required. */ connect_exec: ({ query: { type, query }, on_message, on_login, on_open, on_close, }: { query: ConnectExecQuery; } & TerminalCallbacks) => WebSocket; /** * Subscribes to container exec io over websocket message, * for use with xtermjs. Can connect to Container on a Server. * Server Terminal permission required. */ connect_container_exec: ({ query, ...callbacks }: { query: import("./types.js").ConnectContainerExecQuery; } & TerminalCallbacks) => WebSocket; /** * Executes a command on a given container, * and gives a callback to handle the output as it comes in. * * ```ts * await komodo.execute_container_exec( * { * server: "my-server", * container: "name", * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }, * { * onLine: (line) => console.log(line), * onFinish: (code) => console.log("Finished:", code), * } * ); * ``` */ execute_container_exec: (body: import("./types.js").ExecuteContainerExecBody, callbacks?: import("./terminal.js").ExecuteCallbacks) => Promise; /** * Executes a command on a given container, * and returns a stream to process the output as it comes in. * * Note. The final line of the stream will usually be * `__KOMODO_EXIT_CODE__:0`. The number * is the exit code of the command. * * If this line is NOT present, it means the stream * was terminated early, ie like running `exit`. * * ```ts * const stream = await komodo.execute_container_exec_stream({ * server: "my-server", * container: "name", * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }); * * for await (const line of stream) { * console.log(line); * } * ``` */ execute_container_exec_stream: (body: import("./types.js").ExecuteContainerExecBody) => Promise>; /** * Subscribes to deployment container exec io over websocket message, * for use with xtermjs. Can connect to Deployment container. * Deployment Terminal permission required. */ connect_deployment_exec: ({ query, ...callbacks }: { query: import("./types.js").ConnectDeploymentExecQuery; } & TerminalCallbacks) => WebSocket; /** * Executes a command on a given deployment container, * and gives a callback to handle the output as it comes in. * * ```ts * await komodo.execute_deployment_exec( * { * deployment: "my-deployment", * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }, * { * onLine: (line) => console.log(line), * onFinish: (code) => console.log("Finished:", code), * } * ); * ``` */ execute_deployment_exec: (body: import("./types.js").ExecuteDeploymentExecBody, callbacks?: import("./terminal.js").ExecuteCallbacks) => Promise; /** * Executes a command on a given deployment container, * and returns a stream to process the output as it comes in. * * Note. The final line of the stream will usually be * `__KOMODO_EXIT_CODE__:0`. The number * is the exit code of the command. * * If this line is NOT present, it means the stream * was terminated early, ie like running `exit`. * * ```ts * const stream = await komodo.execute_deployment_exec_stream({ * deployment: "my-deployment", * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }); * * for await (const line of stream) { * console.log(line); * } * ``` */ execute_deployment_exec_stream: (body: import("./types.js").ExecuteDeploymentExecBody) => Promise>; /** * Subscribes to container exec io over websocket message, * for use with xtermjs. Can connect to Stack service container. * Stack Terminal permission required. */ connect_stack_exec: ({ query, ...callbacks }: { query: import("./types.js").ConnectStackExecQuery; } & TerminalCallbacks) => WebSocket; /** * Executes a command on a given stack service container, * and gives a callback to handle the output as it comes in. * * ```ts * await komodo.execute_stack_exec( * { * stack: "my-stack", * service: "database" * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }, * { * onLine: (line) => console.log(line), * onFinish: (code) => console.log("Finished:", code), * } * ); * ``` */ execute_stack_exec: (body: import("./types.js").ExecuteStackExecBody, callbacks?: import("./terminal.js").ExecuteCallbacks) => Promise; /** * Executes a command on a given stack service container, * and returns a stream to process the output as it comes in. * * Note. The final line of the stream will usually be * `__KOMODO_EXIT_CODE__:0`. The number * is the exit code of the command. * * If this line is NOT present, it means the stream * was terminated early, ie like running `exit`. * * ```ts * const stream = await komodo.execute_stack_exec_stream({ * stack: "my-stack", * service: "service1", * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }); * * for await (const line of stream) { * console.log(line); * } * ``` */ execute_stack_exec_stream: (body: import("./types.js").ExecuteStackExecBody) => Promise>; }; ================================================ FILE: frontend/public/client/lib.js ================================================ import { terminal_methods, } from "./terminal.js"; import { UpdateStatus, } from "./types.js"; export * as Types from "./types.js"; export class CancelToken { cancelled; constructor() { this.cancelled = false; } cancel() { this.cancelled = true; } } /** Initialize a new client for Komodo */ export function KomodoClient(url, options) { const state = { jwt: options.type === "jwt" ? options.params.jwt : undefined, key: options.type === "api-key" ? options.params.key : undefined, secret: options.type === "api-key" ? options.params.secret : undefined, }; const request = (path, type, params) => new Promise(async (res, rej) => { try { let response = await fetch(`${url}${path}/${type}`, { method: "POST", body: JSON.stringify(params), headers: { ...(state.jwt ? { authorization: state.jwt, } : state.key && state.secret ? { "x-api-key": state.key, "x-api-secret": state.secret, } : {}), "content-type": "application/json", }, }); if (response.status === 200) { const body = await response.json(); res(body); } else { try { const result = await response.json(); rej({ status: response.status, result }); } catch (error) { rej({ status: response.status, result: { error: "Failed to get response body", trace: [JSON.stringify(error)], }, error, }); } } } catch (error) { rej({ status: 1, result: { error: "Request failed with error", trace: [JSON.stringify(error)], }, error, }); } }); const auth = async (type, params) => await request("/auth", type, params); const user = async (type, params) => await request("/user", type, params); const read = async (type, params) => await request("/read", type, params); const write = async (type, params) => await request("/write", type, params); const execute = async (type, params) => await request("/execute", type, params); const execute_and_poll = async (type, params) => { const res = await execute(type, params); // Check if its a batch of updates or a single update; if (Array.isArray(res)) { const batch = res; return await Promise.all(batch.map(async (item) => { if (item.status === "Err") { return item; } return await poll_update_until_complete(item.data._id?.$oid); })); } else { // it is a single update const update = res; if (update.status === UpdateStatus.Complete || !update._id?.$oid) { return update; } return await poll_update_until_complete(update._id?.$oid); } }; const poll_update_until_complete = async (update_id) => { while (true) { await new Promise((resolve) => setTimeout(resolve, 1000)); const update = await read("GetUpdate", { id: update_id }); if (update.status === UpdateStatus.Complete) { return update; } } }; const core_version = () => read("GetVersion", {}).then((res) => res.version); const get_update_websocket = ({ on_update, on_login, on_open, on_close, }) => { const ws = new WebSocket(url.replace("http", "ws") + "/ws/update"); // Handle login on websocket open ws.addEventListener("open", () => { on_open?.(); const login_msg = options.type === "jwt" ? { type: "Jwt", params: { jwt: options.params.jwt, }, } : { type: "ApiKeys", params: { key: options.params.key, secret: options.params.secret, }, }; ws.send(JSON.stringify(login_msg)); }); ws.addEventListener("message", ({ data }) => { if (data == "LOGGED_IN") return on_login?.(); on_update(JSON.parse(data)); }); if (on_close) { ws.addEventListener("close", on_close); } return ws; }; const subscribe_to_update_websocket = async ({ on_update, on_open, on_login, on_close, retry = true, retry_timeout_ms = 5_000, cancel = new CancelToken(), on_cancel, }) => { while (true) { if (cancel.cancelled) { on_cancel?.(); return; } try { const ws = get_update_websocket({ on_open, on_login, on_update, on_close, }); // This while loop will end when the socket is closed while (ws.readyState !== WebSocket.CLOSING && ws.readyState !== WebSocket.CLOSED) { if (cancel.cancelled) ws.close(); // Sleep for a bit before checking for websocket closed await new Promise((resolve) => setTimeout(resolve, 500)); } if (retry) { // Sleep for a bit before retrying connection to avoid spam. await new Promise((resolve) => setTimeout(resolve, retry_timeout_ms)); } else { return; } } catch (error) { console.error(error); if (retry) { // Sleep for a bit before retrying, maybe Komodo Core is down temporarily. await new Promise((resolve) => setTimeout(resolve, retry_timeout_ms)); } else { return; } } } }; const { connect_terminal, execute_terminal, execute_terminal_stream, connect_exec, connect_container_exec, execute_container_exec, execute_container_exec_stream, connect_deployment_exec, execute_deployment_exec, execute_deployment_exec_stream, connect_stack_exec, execute_stack_exec, execute_stack_exec_stream, } = terminal_methods(url, state); return { /** * Call the `/auth` api. * * ``` * const login_options = await komodo.auth("GetLoginOptions", {}); * ``` * * https://docs.rs/komodo_client/latest/komodo_client/api/auth/index.html */ auth, /** * Call the `/user` api. * * ``` * const { key, secret } = await komodo.user("CreateApiKey", { * name: "my-api-key" * }); * ``` * * https://docs.rs/komodo_client/latest/komodo_client/api/user/index.html */ user, /** * Call the `/read` api. * * ``` * const stack = await komodo.read("GetStack", { * stack: "my-stack" * }); * ``` * * https://docs.rs/komodo_client/latest/komodo_client/api/read/index.html */ read, /** * Call the `/write` api. * * ``` * const build = await komodo.write("UpdateBuild", { * id: "my-build", * config: { * version: "1.0.4" * } * }); * ``` * * https://docs.rs/komodo_client/latest/komodo_client/api/write/index.html */ write, /** * Call the `/execute` api. * * ``` * const update = await komodo.execute("DeployStack", { * stack: "my-stack" * }); * ``` * * NOTE. These calls return immediately when the update is created, NOT when the execution task finishes. * To have the call only return when the task finishes, use [execute_and_poll_until_complete]. * * https://docs.rs/komodo_client/latest/komodo_client/api/execute/index.html */ execute, /** * Call the `/execute` api, and poll the update until the task has completed. * * ``` * const update = await komodo.execute_and_poll("DeployStack", { * stack: "my-stack" * }); * ``` * * https://docs.rs/komodo_client/latest/komodo_client/api/execute/index.html */ execute_and_poll, /** * Poll an Update (returned by the `execute` calls) until the `status` is `Complete`. * https://docs.rs/komodo_client/latest/komodo_client/entities/update/struct.Update.html#structfield.status. */ poll_update_until_complete, /** Returns the version of Komodo Core the client is calling to. */ core_version, /** * Connects to update websocket, performs login and attaches handlers, * and returns the WebSocket handle. */ get_update_websocket, /** * Subscribes to the update websocket with automatic reconnect loop. * * Note. Awaiting this method will never finish. */ subscribe_to_update_websocket, /** * Subscribes to terminal io over websocket message, * for use with xtermjs. */ connect_terminal, /** * Executes a command on a given Server / terminal, * and gives a callback to handle the output as it comes in. * * ```ts * await komodo.execute_terminal( * { * server: "my-server", * terminal: "name", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }, * { * onLine: (line) => console.log(line), * onFinish: (code) => console.log("Finished:", code), * } * ); * ``` */ execute_terminal, /** * Executes a command on a given Server / terminal, * and returns a stream to process the output as it comes in. * * Note. The final line of the stream will usually be * `__KOMODO_EXIT_CODE__:0`. The number * is the exit code of the command. * * If this line is NOT present, it means the stream * was terminated early, ie like running `exit`. * * ```ts * const stream = await komodo.execute_terminal_stream({ * server: "my-server", * terminal: "name", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }); * * for await (const line of stream) { * console.log(line); * } * ``` */ execute_terminal_stream, /** * Subscribes to container exec io over websocket message, * for use with xtermjs. Can connect to container on a Server, * or associated with a Deployment or Stack. * Terminal permission on connecting resource required. */ connect_exec, /** * Subscribes to container exec io over websocket message, * for use with xtermjs. Can connect to Container on a Server. * Server Terminal permission required. */ connect_container_exec, /** * Executes a command on a given container, * and gives a callback to handle the output as it comes in. * * ```ts * await komodo.execute_container_exec( * { * server: "my-server", * container: "name", * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }, * { * onLine: (line) => console.log(line), * onFinish: (code) => console.log("Finished:", code), * } * ); * ``` */ execute_container_exec, /** * Executes a command on a given container, * and returns a stream to process the output as it comes in. * * Note. The final line of the stream will usually be * `__KOMODO_EXIT_CODE__:0`. The number * is the exit code of the command. * * If this line is NOT present, it means the stream * was terminated early, ie like running `exit`. * * ```ts * const stream = await komodo.execute_container_exec_stream({ * server: "my-server", * container: "name", * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }); * * for await (const line of stream) { * console.log(line); * } * ``` */ execute_container_exec_stream, /** * Subscribes to deployment container exec io over websocket message, * for use with xtermjs. Can connect to Deployment container. * Deployment Terminal permission required. */ connect_deployment_exec, /** * Executes a command on a given deployment container, * and gives a callback to handle the output as it comes in. * * ```ts * await komodo.execute_deployment_exec( * { * deployment: "my-deployment", * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }, * { * onLine: (line) => console.log(line), * onFinish: (code) => console.log("Finished:", code), * } * ); * ``` */ execute_deployment_exec, /** * Executes a command on a given deployment container, * and returns a stream to process the output as it comes in. * * Note. The final line of the stream will usually be * `__KOMODO_EXIT_CODE__:0`. The number * is the exit code of the command. * * If this line is NOT present, it means the stream * was terminated early, ie like running `exit`. * * ```ts * const stream = await komodo.execute_deployment_exec_stream({ * deployment: "my-deployment", * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }); * * for await (const line of stream) { * console.log(line); * } * ``` */ execute_deployment_exec_stream, /** * Subscribes to container exec io over websocket message, * for use with xtermjs. Can connect to Stack service container. * Stack Terminal permission required. */ connect_stack_exec, /** * Executes a command on a given stack service container, * and gives a callback to handle the output as it comes in. * * ```ts * await komodo.execute_stack_exec( * { * stack: "my-stack", * service: "database" * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }, * { * onLine: (line) => console.log(line), * onFinish: (code) => console.log("Finished:", code), * } * ); * ``` */ execute_stack_exec, /** * Executes a command on a given stack service container, * and returns a stream to process the output as it comes in. * * Note. The final line of the stream will usually be * `__KOMODO_EXIT_CODE__:0`. The number * is the exit code of the command. * * If this line is NOT present, it means the stream * was terminated early, ie like running `exit`. * * ```ts * const stream = await komodo.execute_stack_exec_stream({ * stack: "my-stack", * service: "service1", * shell: "bash", * command: 'for i in {1..3}; do echo "$i"; sleep 1; done', * }); * * for await (const line of stream) { * console.log(line); * } * ``` */ execute_stack_exec_stream, }; } ================================================ FILE: frontend/public/client/responses.d.ts ================================================ import * as Types from "./types.js"; export type AuthResponses = { GetLoginOptions: Types.GetLoginOptionsResponse; SignUpLocalUser: Types.SignUpLocalUserResponse; LoginLocalUser: Types.LoginLocalUserResponse; ExchangeForJwt: Types.ExchangeForJwtResponse; GetUser: Types.GetUserResponse; }; export type UserResponses = { PushRecentlyViewed: Types.PushRecentlyViewedResponse; SetLastSeenUpdate: Types.SetLastSeenUpdateResponse; CreateApiKey: Types.CreateApiKeyResponse; DeleteApiKey: Types.DeleteApiKeyResponse; }; export type ReadResponses = { GetVersion: Types.GetVersionResponse; GetCoreInfo: Types.GetCoreInfoResponse; ListSecrets: Types.ListSecretsResponse; ListGitProvidersFromConfig: Types.ListGitProvidersFromConfigResponse; ListDockerRegistriesFromConfig: Types.ListDockerRegistriesFromConfigResponse; GetUsername: Types.GetUsernameResponse; GetPermission: Types.GetPermissionResponse; FindUser: Types.FindUserResponse; ListUsers: Types.ListUsersResponse; ListApiKeys: Types.ListApiKeysResponse; ListApiKeysForServiceUser: Types.ListApiKeysForServiceUserResponse; ListPermissions: Types.ListPermissionsResponse; ListUserTargetPermissions: Types.ListUserTargetPermissionsResponse; GetUserGroup: Types.GetUserGroupResponse; ListUserGroups: Types.ListUserGroupsResponse; GetProceduresSummary: Types.GetProceduresSummaryResponse; GetProcedure: Types.GetProcedureResponse; GetProcedureActionState: Types.GetProcedureActionStateResponse; ListProcedures: Types.ListProceduresResponse; ListFullProcedures: Types.ListFullProceduresResponse; GetActionsSummary: Types.GetActionsSummaryResponse; GetAction: Types.GetActionResponse; GetActionActionState: Types.GetActionActionStateResponse; ListActions: Types.ListActionsResponse; ListFullActions: Types.ListFullActionsResponse; ListSchedules: Types.ListSchedulesResponse; GetServersSummary: Types.GetServersSummaryResponse; GetServer: Types.GetServerResponse; GetServerState: Types.GetServerStateResponse; GetPeripheryVersion: Types.GetPeripheryVersionResponse; GetDockerContainersSummary: Types.GetDockerContainersSummaryResponse; ListDockerContainers: Types.ListDockerContainersResponse; ListAllDockerContainers: Types.ListAllDockerContainersResponse; InspectDockerContainer: Types.InspectDockerContainerResponse; GetResourceMatchingContainer: Types.GetResourceMatchingContainerResponse; GetContainerLog: Types.GetContainerLogResponse; SearchContainerLog: Types.SearchContainerLogResponse; ListDockerNetworks: Types.ListDockerNetworksResponse; InspectDockerNetwork: Types.InspectDockerNetworkResponse; ListDockerImages: Types.ListDockerImagesResponse; InspectDockerImage: Types.InspectDockerImageResponse; ListDockerImageHistory: Types.ListDockerImageHistoryResponse; ListDockerVolumes: Types.ListDockerVolumesResponse; InspectDockerVolume: Types.InspectDockerVolumeResponse; ListComposeProjects: Types.ListComposeProjectsResponse; GetServerActionState: Types.GetServerActionStateResponse; GetHistoricalServerStats: Types.GetHistoricalServerStatsResponse; ListServers: Types.ListServersResponse; ListFullServers: Types.ListFullServersResponse; ListTerminals: Types.ListTerminalsResponse; GetStacksSummary: Types.GetStacksSummaryResponse; GetStack: Types.GetStackResponse; GetStackActionState: Types.GetStackActionStateResponse; GetStackWebhooksEnabled: Types.GetStackWebhooksEnabledResponse; GetStackLog: Types.GetStackLogResponse; SearchStackLog: Types.SearchStackLogResponse; InspectStackContainer: Types.InspectStackContainerResponse; ListStacks: Types.ListStacksResponse; ListFullStacks: Types.ListFullStacksResponse; ListStackServices: Types.ListStackServicesResponse; ListCommonStackExtraArgs: Types.ListCommonStackExtraArgsResponse; ListCommonStackBuildExtraArgs: Types.ListCommonStackBuildExtraArgsResponse; GetDeploymentsSummary: Types.GetDeploymentsSummaryResponse; GetDeployment: Types.GetDeploymentResponse; GetDeploymentContainer: Types.GetDeploymentContainerResponse; GetDeploymentActionState: Types.GetDeploymentActionStateResponse; GetDeploymentStats: Types.GetDeploymentStatsResponse; GetDeploymentLog: Types.GetDeploymentLogResponse; SearchDeploymentLog: Types.SearchDeploymentLogResponse; InspectDeploymentContainer: Types.InspectDeploymentContainerResponse; ListDeployments: Types.ListDeploymentsResponse; ListFullDeployments: Types.ListFullDeploymentsResponse; ListCommonDeploymentExtraArgs: Types.ListCommonDeploymentExtraArgsResponse; GetBuildsSummary: Types.GetBuildsSummaryResponse; GetBuild: Types.GetBuildResponse; GetBuildActionState: Types.GetBuildActionStateResponse; GetBuildMonthlyStats: Types.GetBuildMonthlyStatsResponse; GetBuildWebhookEnabled: Types.GetBuildWebhookEnabledResponse; ListBuilds: Types.ListBuildsResponse; ListFullBuilds: Types.ListFullBuildsResponse; ListBuildVersions: Types.ListBuildVersionsResponse; ListCommonBuildExtraArgs: Types.ListCommonBuildExtraArgsResponse; GetReposSummary: Types.GetReposSummaryResponse; GetRepo: Types.GetRepoResponse; GetRepoActionState: Types.GetRepoActionStateResponse; GetRepoWebhooksEnabled: Types.GetRepoWebhooksEnabledResponse; ListRepos: Types.ListReposResponse; ListFullRepos: Types.ListFullReposResponse; GetResourceSyncsSummary: Types.GetResourceSyncsSummaryResponse; GetResourceSync: Types.GetResourceSyncResponse; GetResourceSyncActionState: Types.GetResourceSyncActionStateResponse; GetSyncWebhooksEnabled: Types.GetSyncWebhooksEnabledResponse; ListResourceSyncs: Types.ListResourceSyncsResponse; ListFullResourceSyncs: Types.ListFullResourceSyncsResponse; GetBuildersSummary: Types.GetBuildersSummaryResponse; GetBuilder: Types.GetBuilderResponse; ListBuilders: Types.ListBuildersResponse; ListFullBuilders: Types.ListFullBuildersResponse; GetAlertersSummary: Types.GetAlertersSummaryResponse; GetAlerter: Types.GetAlerterResponse; ListAlerters: Types.ListAlertersResponse; ListFullAlerters: Types.ListFullAlertersResponse; ExportAllResourcesToToml: Types.ExportAllResourcesToTomlResponse; ExportResourcesToToml: Types.ExportResourcesToTomlResponse; GetTag: Types.GetTagResponse; ListTags: Types.ListTagsResponse; GetUpdate: Types.GetUpdateResponse; ListUpdates: Types.ListUpdatesResponse; ListAlerts: Types.ListAlertsResponse; GetAlert: Types.GetAlertResponse; GetSystemInformation: Types.GetSystemInformationResponse; GetSystemStats: Types.GetSystemStatsResponse; ListSystemProcesses: Types.ListSystemProcessesResponse; GetVariable: Types.GetVariableResponse; ListVariables: Types.ListVariablesResponse; GetGitProviderAccount: Types.GetGitProviderAccountResponse; ListGitProviderAccounts: Types.ListGitProviderAccountsResponse; GetDockerRegistryAccount: Types.GetDockerRegistryAccountResponse; ListDockerRegistryAccounts: Types.ListDockerRegistryAccountsResponse; }; export type WriteResponses = { CreateLocalUser: Types.CreateLocalUserResponse; UpdateUserUsername: Types.UpdateUserUsernameResponse; UpdateUserPassword: Types.UpdateUserPasswordResponse; DeleteUser: Types.DeleteUserResponse; CreateServiceUser: Types.CreateServiceUserResponse; UpdateServiceUserDescription: Types.UpdateServiceUserDescriptionResponse; CreateApiKeyForServiceUser: Types.CreateApiKeyForServiceUserResponse; DeleteApiKeyForServiceUser: Types.DeleteApiKeyForServiceUserResponse; CreateUserGroup: Types.UserGroup; RenameUserGroup: Types.UserGroup; DeleteUserGroup: Types.UserGroup; AddUserToUserGroup: Types.UserGroup; RemoveUserFromUserGroup: Types.UserGroup; SetUsersInUserGroup: Types.UserGroup; SetEveryoneUserGroup: Types.UserGroup; UpdateUserAdmin: Types.UpdateUserAdminResponse; UpdateUserBasePermissions: Types.UpdateUserBasePermissionsResponse; UpdatePermissionOnResourceType: Types.UpdatePermissionOnResourceTypeResponse; UpdatePermissionOnTarget: Types.UpdatePermissionOnTargetResponse; UpdateResourceMeta: Types.UpdateResourceMetaResponse; CreateServer: Types.Server; CopyServer: Types.Server; DeleteServer: Types.Server; UpdateServer: Types.Server; RenameServer: Types.Update; CreateNetwork: Types.Update; CreateTerminal: Types.NoData; DeleteTerminal: Types.NoData; DeleteAllTerminals: Types.NoData; CreateStack: Types.Stack; CopyStack: Types.Stack; DeleteStack: Types.Stack; UpdateStack: Types.Stack; RenameStack: Types.Update; WriteStackFileContents: Types.Update; RefreshStackCache: Types.NoData; CreateStackWebhook: Types.CreateStackWebhookResponse; DeleteStackWebhook: Types.DeleteStackWebhookResponse; CreateDeployment: Types.Deployment; CopyDeployment: Types.Deployment; CreateDeploymentFromContainer: Types.Deployment; DeleteDeployment: Types.Deployment; UpdateDeployment: Types.Deployment; RenameDeployment: Types.Update; CreateBuild: Types.Build; CopyBuild: Types.Build; DeleteBuild: Types.Build; UpdateBuild: Types.Build; RenameBuild: Types.Update; WriteBuildFileContents: Types.Update; RefreshBuildCache: Types.NoData; CreateBuildWebhook: Types.CreateBuildWebhookResponse; DeleteBuildWebhook: Types.DeleteBuildWebhookResponse; CreateBuilder: Types.Builder; CopyBuilder: Types.Builder; DeleteBuilder: Types.Builder; UpdateBuilder: Types.Builder; RenameBuilder: Types.Update; CreateRepo: Types.Repo; CopyRepo: Types.Repo; DeleteRepo: Types.Repo; UpdateRepo: Types.Repo; RenameRepo: Types.Update; RefreshRepoCache: Types.NoData; CreateRepoWebhook: Types.CreateRepoWebhookResponse; DeleteRepoWebhook: Types.DeleteRepoWebhookResponse; CreateAlerter: Types.Alerter; CopyAlerter: Types.Alerter; DeleteAlerter: Types.Alerter; UpdateAlerter: Types.Alerter; RenameAlerter: Types.Update; CreateProcedure: Types.Procedure; CopyProcedure: Types.Procedure; DeleteProcedure: Types.Procedure; UpdateProcedure: Types.Procedure; RenameProcedure: Types.Update; CreateAction: Types.Action; CopyAction: Types.Action; DeleteAction: Types.Action; UpdateAction: Types.Action; RenameAction: Types.Update; CreateResourceSync: Types.ResourceSync; CopyResourceSync: Types.ResourceSync; DeleteResourceSync: Types.ResourceSync; UpdateResourceSync: Types.ResourceSync; RenameResourceSync: Types.Update; CommitSync: Types.Update; WriteSyncFileContents: Types.Update; RefreshResourceSyncPending: Types.ResourceSync; CreateSyncWebhook: Types.CreateSyncWebhookResponse; DeleteSyncWebhook: Types.DeleteSyncWebhookResponse; CreateTag: Types.Tag; DeleteTag: Types.Tag; RenameTag: Types.Tag; UpdateTagColor: Types.Tag; CreateVariable: Types.CreateVariableResponse; UpdateVariableValue: Types.UpdateVariableValueResponse; UpdateVariableDescription: Types.UpdateVariableDescriptionResponse; UpdateVariableIsSecret: Types.UpdateVariableIsSecretResponse; DeleteVariable: Types.DeleteVariableResponse; CreateGitProviderAccount: Types.CreateGitProviderAccountResponse; UpdateGitProviderAccount: Types.UpdateGitProviderAccountResponse; DeleteGitProviderAccount: Types.DeleteGitProviderAccountResponse; CreateDockerRegistryAccount: Types.CreateDockerRegistryAccountResponse; UpdateDockerRegistryAccount: Types.UpdateDockerRegistryAccountResponse; DeleteDockerRegistryAccount: Types.DeleteDockerRegistryAccountResponse; }; export type ExecuteResponses = { StartContainer: Types.Update; RestartContainer: Types.Update; PauseContainer: Types.Update; UnpauseContainer: Types.Update; StopContainer: Types.Update; DestroyContainer: Types.Update; StartAllContainers: Types.Update; RestartAllContainers: Types.Update; PauseAllContainers: Types.Update; UnpauseAllContainers: Types.Update; StopAllContainers: Types.Update; PruneContainers: Types.Update; DeleteNetwork: Types.Update; PruneNetworks: Types.Update; DeleteImage: Types.Update; PruneImages: Types.Update; DeleteVolume: Types.Update; PruneVolumes: Types.Update; PruneDockerBuilders: Types.Update; PruneBuildx: Types.Update; PruneSystem: Types.Update; DeployStack: Types.Update; BatchDeployStack: Types.BatchExecutionResponse; DeployStackIfChanged: Types.Update; BatchDeployStackIfChanged: Types.BatchExecutionResponse; PullStack: Types.Update; BatchPullStack: Types.BatchExecutionResponse; StartStack: Types.Update; RestartStack: Types.Update; StopStack: Types.Update; PauseStack: Types.Update; UnpauseStack: Types.Update; DestroyStack: Types.Update; BatchDestroyStack: Types.BatchExecutionResponse; Deploy: Types.Update; BatchDeploy: Types.BatchExecutionResponse; PullDeployment: Types.Update; StartDeployment: Types.Update; RestartDeployment: Types.Update; PauseDeployment: Types.Update; UnpauseDeployment: Types.Update; StopDeployment: Types.Update; DestroyDeployment: Types.Update; BatchDestroyDeployment: Types.BatchExecutionResponse; RunBuild: Types.Update; BatchRunBuild: Types.BatchExecutionResponse; CancelBuild: Types.Update; CloneRepo: Types.Update; BatchCloneRepo: Types.BatchExecutionResponse; PullRepo: Types.Update; BatchPullRepo: Types.BatchExecutionResponse; BuildRepo: Types.Update; BatchBuildRepo: Types.BatchExecutionResponse; CancelRepoBuild: Types.Update; RunProcedure: Types.Update; BatchRunProcedure: Types.BatchExecutionResponse; RunAction: Types.Update; BatchRunAction: Types.BatchExecutionResponse; RunSync: Types.Update; DeployStackService: Types.Update; StartStackService: Types.Update; RestartStackService: Types.Update; StopStackService: Types.Update; PauseStackService: Types.Update; UnpauseStackService: Types.Update; DestroyStackService: Types.Update; RunStackService: Types.Update; TestAlerter: Types.Update; SendAlert: Types.Update; ClearRepoCache: Types.Update; BackupCoreDatabase: Types.Update; GlobalAutoUpdate: Types.Update; }; ================================================ FILE: frontend/public/client/responses.js ================================================ export {}; ================================================ FILE: frontend/public/client/terminal.d.ts ================================================ import { ClientState } from "./lib"; import { ConnectContainerExecQuery, ConnectDeploymentExecQuery, ConnectStackExecQuery, ConnectTerminalQuery, ExecuteContainerExecBody, ExecuteDeploymentExecBody, ExecuteStackExecBody, ExecuteTerminalBody } from "./types"; export type TerminalCallbacks = { on_message?: (e: MessageEvent) => void; on_login?: () => void; on_open?: () => void; on_close?: () => void; }; export type ConnectExecQuery = { type: "container"; query: ConnectContainerExecQuery; } | { type: "deployment"; query: ConnectDeploymentExecQuery; } | { type: "stack"; query: ConnectStackExecQuery; }; export type ExecuteExecBody = { type: "container"; body: ExecuteContainerExecBody; } | { type: "deployment"; body: ExecuteDeploymentExecBody; } | { type: "stack"; body: ExecuteStackExecBody; }; export type ExecuteCallbacks = { onLine?: (line: string) => void | Promise; onFinish?: (code: string) => void | Promise; }; export declare const terminal_methods: (url: string, state: ClientState) => { connect_terminal: ({ query, on_message, on_login, on_open, on_close, }: { query: ConnectTerminalQuery; } & TerminalCallbacks) => WebSocket; execute_terminal: (request: ExecuteTerminalBody, callbacks?: ExecuteCallbacks) => Promise; execute_terminal_stream: (request: ExecuteTerminalBody) => Promise>; connect_exec: ({ query: { type, query }, on_message, on_login, on_open, on_close, }: { query: ConnectExecQuery; } & TerminalCallbacks) => WebSocket; connect_container_exec: ({ query, ...callbacks }: { query: ConnectContainerExecQuery; } & TerminalCallbacks) => WebSocket; execute_container_exec: (body: ExecuteContainerExecBody, callbacks?: ExecuteCallbacks) => Promise; execute_container_exec_stream: (body: ExecuteContainerExecBody) => Promise>; connect_deployment_exec: ({ query, ...callbacks }: { query: ConnectDeploymentExecQuery; } & TerminalCallbacks) => WebSocket; execute_deployment_exec: (body: ExecuteDeploymentExecBody, callbacks?: ExecuteCallbacks) => Promise; execute_deployment_exec_stream: (body: ExecuteDeploymentExecBody) => Promise>; connect_stack_exec: ({ query, ...callbacks }: { query: ConnectStackExecQuery; } & TerminalCallbacks) => WebSocket; execute_stack_exec: (body: ExecuteStackExecBody, callbacks?: ExecuteCallbacks) => Promise; execute_stack_exec_stream: (body: ExecuteStackExecBody) => Promise>; }; ================================================ FILE: frontend/public/client/terminal.js ================================================ export const terminal_methods = (url, state) => { const connect_terminal = ({ query, on_message, on_login, on_open, on_close, }) => { const url_query = new URLSearchParams(query).toString(); const ws = new WebSocket(url.replace("http", "ws") + "/ws/terminal?" + url_query); // Handle login on websocket open ws.onopen = () => { const login_msg = state.jwt ? { type: "Jwt", params: { jwt: state.jwt, }, } : { type: "ApiKeys", params: { key: state.key, secret: state.secret, }, }; ws.send(JSON.stringify(login_msg)); on_open?.(); }; ws.onmessage = (e) => { if (e.data == "LOGGED_IN") { ws.binaryType = "arraybuffer"; ws.onmessage = (e) => on_message?.(e); on_login?.(); return; } else { on_message?.(e); } }; ws.onclose = () => on_close?.(); return ws; }; const execute_terminal = async (request, callbacks) => { const stream = await execute_terminal_stream(request); for await (const line of stream) { if (line.startsWith("__KOMODO_EXIT_CODE")) { await callbacks?.onFinish?.(line.split(":")[1]); return; } else { await callbacks?.onLine?.(line); } } // This is hit if no __KOMODO_EXIT_CODE is sent, ie early exit await callbacks?.onFinish?.("Early exit without code"); }; const execute_terminal_stream = (request) => execute_stream("/terminal/execute", request); const connect_container_exec = ({ query, ...callbacks }) => connect_exec({ query: { type: "container", query }, ...callbacks }); const connect_deployment_exec = ({ query, ...callbacks }) => connect_exec({ query: { type: "deployment", query }, ...callbacks }); const connect_stack_exec = ({ query, ...callbacks }) => connect_exec({ query: { type: "stack", query }, ...callbacks }); const connect_exec = ({ query: { type, query }, on_message, on_login, on_open, on_close, }) => { const url_query = new URLSearchParams(query).toString(); const ws = new WebSocket(url.replace("http", "ws") + `/ws/${type}/terminal?` + url_query); // Handle login on websocket open ws.onopen = () => { const login_msg = state.jwt ? { type: "Jwt", params: { jwt: state.jwt, }, } : { type: "ApiKeys", params: { key: state.key, secret: state.secret, }, }; ws.send(JSON.stringify(login_msg)); on_open?.(); }; ws.onmessage = (e) => { if (e.data == "LOGGED_IN") { ws.binaryType = "arraybuffer"; ws.onmessage = (e) => on_message?.(e); on_login?.(); return; } else { on_message?.(e); } }; ws.onclose = () => on_close?.(); return ws; }; const execute_container_exec = (body, callbacks) => execute_exec({ type: "container", body }, callbacks); const execute_deployment_exec = (body, callbacks) => execute_exec({ type: "deployment", body }, callbacks); const execute_stack_exec = (body, callbacks) => execute_exec({ type: "stack", body }, callbacks); const execute_exec = async (request, callbacks) => { const stream = await execute_exec_stream(request); for await (const line of stream) { if (line.startsWith("__KOMODO_EXIT_CODE")) { await callbacks?.onFinish?.(line.split(":")[1]); return; } else { await callbacks?.onLine?.(line); } } // This is hit if no __KOMODO_EXIT_CODE is sent, ie early exit await callbacks?.onFinish?.("Early exit without code"); }; const execute_container_exec_stream = (body) => execute_exec_stream({ type: "container", body }); const execute_deployment_exec_stream = (body) => execute_exec_stream({ type: "deployment", body }); const execute_stack_exec_stream = (body) => execute_exec_stream({ type: "stack", body }); const execute_exec_stream = (request) => execute_stream(`/terminal/execute/${request.type}`, request.body); const execute_stream = (path, request) => new Promise(async (res, rej) => { try { let response = await fetch(url + path, { method: "POST", body: JSON.stringify(request), headers: { ...(state.jwt ? { authorization: state.jwt, } : state.key && state.secret ? { "x-api-key": state.key, "x-api-secret": state.secret, } : {}), "content-type": "application/json", }, }); if (response.status === 200) { if (response.body) { const stream = response.body .pipeThrough(new TextDecoderStream("utf-8")) .pipeThrough(new TransformStream({ start(_controller) { this.tail = ""; }, transform(chunk, controller) { const data = this.tail + chunk; // prepend any carry‑over const parts = data.split(/\r?\n/); // split on CRLF or LF this.tail = parts.pop(); // last item may be incomplete for (const line of parts) controller.enqueue(line); }, flush(controller) { if (this.tail) controller.enqueue(this.tail); // final unterminated line }, })); res(stream); } else { rej({ status: response.status, result: { error: "No response body", trace: [] }, }); } } else { try { const result = await response.json(); rej({ status: response.status, result }); } catch (error) { rej({ status: response.status, result: { error: "Failed to get response body", trace: [JSON.stringify(error)], }, error, }); } } } catch (error) { rej({ status: 1, result: { error: "Request failed with error", trace: [JSON.stringify(error)], }, error, }); } }); return { connect_terminal, execute_terminal, execute_terminal_stream, connect_exec, connect_container_exec, execute_container_exec, execute_container_exec_stream, connect_deployment_exec, execute_deployment_exec, execute_deployment_exec_stream, connect_stack_exec, execute_stack_exec, execute_stack_exec_stream, }; }; ================================================ FILE: frontend/public/client/types.d.ts ================================================ export interface MongoIdObj { $oid: string; } export type MongoId = MongoIdObj; /** The levels of permission that a User or UserGroup can have on a resource. */ export declare enum PermissionLevel { /** No permissions. */ None = "None", /** Can read resource information and config */ Read = "Read", /** Can execute actions on the resource */ Execute = "Execute", /** Can update the resource configuration */ Write = "Write" } export interface PermissionLevelAndSpecifics { level: PermissionLevel; specific: Array; } export type I64 = number; export interface Resource { /** * The Mongo ID of the resource. * This field is de/serialized from/to JSON as * `{ "_id": { "$oid": "..." }, ...(rest of serialized Resource) }` */ _id?: MongoId; /** * The resource name. * This is guaranteed unique among others of the same resource type. */ name: string; /** A description for the resource */ description?: string; /** Mark resource as a template */ template?: boolean; /** Tag Ids */ tags?: string[]; /** Resource-specific information (not user configurable). */ info?: Info; /** Resource-specific configuration. */ config?: Config; /** * Set a base permission level that all users will have on the * resource. */ base_permission?: PermissionLevelAndSpecifics | PermissionLevel; /** When description last updated */ updated_at?: I64; } export declare enum ScheduleFormat { English = "English", Cron = "Cron" } export declare enum FileFormat { KeyValue = "key_value", Toml = "toml", Yaml = "yaml", Json = "json" } export interface ActionConfig { /** Whether this action should run at startup. */ run_at_startup: boolean; /** Choose whether to specify schedule as regular CRON, or using the english to CRON parser. */ schedule_format?: ScheduleFormat; /** * Optionally provide a schedule for the procedure to run on. * * There are 2 ways to specify a schedule: * * 1. Regular CRON expression: * * (second, minute, hour, day, month, day-of-week) * ```text * 0 0 0 1,15 * ? * ``` * * 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron): * * ```text * at midnight on the 1st and 15th of the month * ``` */ schedule?: string; /** * Whether schedule is enabled if one is provided. * Can be used to temporarily disable the schedule. */ schedule_enabled: boolean; /** * Optional. A TZ Identifier. If not provided, will use Core local timezone. * https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. */ schedule_timezone?: string; /** Whether to send alerts when the schedule was run. */ schedule_alert: boolean; /** Whether to send alerts when this action fails. */ failure_alert: boolean; /** Whether incoming webhooks actually trigger action. */ webhook_enabled: boolean; /** * Optionally provide an alternate webhook secret for this procedure. * If its an empty string, use the default secret from the config. */ webhook_secret?: string; /** * Whether deno will be instructed to reload all dependencies, * this can usually be kept false outside of development. */ reload_deno_deps?: boolean; /** * Typescript file contents using pre-initialized `komodo` client. * Supports variable / secret interpolation. */ file_contents?: string; /** * Specify the format in which the arguments are defined. * Default: `key_value` (like environment) */ arguments_format?: FileFormat; /** Default arguments to give to the Action for use in the script at `ARGS`. */ arguments?: string; } /** Represents an empty json object: `{}` */ export interface NoData { } export type Action = Resource; export interface ResourceListItem { /** The resource id */ id: string; /** The resource type, ie `Server` or `Deployment` */ type: ResourceTarget["type"]; /** The resource name */ name: string; /** Whether resource is a template */ template: boolean; /** Tag Ids */ tags: string[]; /** Resource specific info */ info: Info; } export declare enum ActionState { /** Unknown case */ Unknown = "Unknown", /** Last clone / pull successful (or never cloned) */ Ok = "Ok", /** Last clone / pull failed */ Failed = "Failed", /** Currently running */ Running = "Running" } export interface ActionListItemInfo { /** Whether last action run successful */ state: ActionState; /** Action last successful run timestamp in ms. */ last_run_at?: I64; /** * If the action has schedule enabled, this is the * next scheduled run time in unix ms. */ next_scheduled_run?: I64; /** * If there is an error parsing schedule expression, * it will be given here. */ schedule_error?: string; } export type ActionListItem = ResourceListItem; export declare enum TemplatesQueryBehavior { /** Include templates in results. Default. */ Include = "Include", /** Exclude templates from results. */ Exclude = "Exclude", /** Results *only* includes templates. */ Only = "Only" } export declare enum TagQueryBehavior { /** Returns resources which have strictly all the tags */ All = "All", /** Returns resources which have one or more of the tags */ Any = "Any" } /** Passing empty Vec is the same as not filtering by that field */ export interface ResourceQuery { names?: string[]; templates?: TemplatesQueryBehavior; /** Pass Vec of tag ids or tag names */ tags?: string[]; /** 'All' or 'Any' */ tag_behavior?: TagQueryBehavior; specific?: T; } export interface ActionQuerySpecifics { } export type ActionQuery = ResourceQuery; export type AlerterEndpoint = /** Send alert serialized to JSON to an http endpoint. */ { type: "Custom"; params: CustomAlerterEndpoint; } /** Send alert to a Slack app */ | { type: "Slack"; params: SlackAlerterEndpoint; } /** Send alert to a Discord app */ | { type: "Discord"; params: DiscordAlerterEndpoint; } /** Send alert to Ntfy */ | { type: "Ntfy"; params: NtfyAlerterEndpoint; } /** Send alert to Pushover */ | { type: "Pushover"; params: PushoverAlerterEndpoint; }; /** Used to reference a specific resource across all resource types */ export type ResourceTarget = { type: "System"; id: string; } | { type: "Server"; id: string; } | { type: "Stack"; id: string; } | { type: "Deployment"; id: string; } | { type: "Build"; id: string; } | { type: "Repo"; id: string; } | { type: "Procedure"; id: string; } | { type: "Action"; id: string; } | { type: "Builder"; id: string; } | { type: "Alerter"; id: string; } | { type: "ResourceSync"; id: string; }; /** Types of maintenance schedules */ export declare enum MaintenanceScheduleType { /** Daily at the specified time */ Daily = "Daily", /** Weekly on the specified day and time */ Weekly = "Weekly", /** One-time maintenance on a specific date and time */ OneTime = "OneTime" } /** Represents a scheduled maintenance window */ export interface MaintenanceWindow { /** Name for the maintenance window (required) */ name: string; /** Description of what maintenance is performed (optional) */ description?: string; /** * The type of maintenance schedule: * - Daily (default) * - Weekly * - OneTime */ schedule_type?: MaintenanceScheduleType; /** For Weekly schedules: Specify the day of the week (Monday, Tuesday, etc.) */ day_of_week?: string; /** For OneTime window: ISO 8601 date format (YYYY-MM-DD) */ date?: string; /** Start hour in 24-hour format (0-23) (optional, defaults to 0) */ hour?: number; /** Start minute (0-59) (optional, defaults to 0) */ minute?: number; /** Duration of the maintenance window in minutes (required) */ duration_minutes: number; /** * Timezone for maintenance window specificiation. * If empty, will use Core timezone. */ timezone?: string; /** Whether this maintenance window is currently enabled */ enabled: boolean; } export interface AlerterConfig { /** Whether the alerter is enabled */ enabled?: boolean; /** * Where to route the alert messages. * * Default: Custom endpoint `http://localhost:7000` */ endpoint?: AlerterEndpoint; /** * Only send specific alert types. * If empty, will send all alert types. */ alert_types?: AlertData["type"][]; /** * Only send alerts on specific resources. * If empty, will send alerts for all resources. */ resources?: ResourceTarget[]; /** DON'T send alerts on these resources. */ except_resources?: ResourceTarget[]; /** Scheduled maintenance windows during which alerts will be suppressed. */ maintenance_windows?: MaintenanceWindow[]; } export type Alerter = Resource; export interface AlerterListItemInfo { /** Whether alerter is enabled for sending alerts */ enabled: boolean; /** The type of the alerter, eg. `Slack`, `Custom` */ endpoint_type: AlerterEndpoint["type"]; } export type AlerterListItem = ResourceListItem; export interface AlerterQuerySpecifics { /** * Filter alerters by enabled. * - `None`: Don't filter by enabled * - `Some(true)`: Only include alerts with `enabled: true` * - `Some(false)`: Only include alerts with `enabled: false` */ enabled?: boolean; /** * Only include alerters with these endpoint types. * If empty, don't filter by enpoint type. */ types: AlerterEndpoint["type"][]; } export type AlerterQuery = ResourceQuery; export type BatchExecutionResponseItem = { status: "Ok"; data: Update; } | { status: "Err"; data: BatchExecutionResponseItemErr; }; export type BatchExecutionResponse = BatchExecutionResponseItem[]; export declare enum Operation { None = "None", CreateServer = "CreateServer", UpdateServer = "UpdateServer", DeleteServer = "DeleteServer", RenameServer = "RenameServer", StartContainer = "StartContainer", RestartContainer = "RestartContainer", PauseContainer = "PauseContainer", UnpauseContainer = "UnpauseContainer", StopContainer = "StopContainer", DestroyContainer = "DestroyContainer", StartAllContainers = "StartAllContainers", RestartAllContainers = "RestartAllContainers", PauseAllContainers = "PauseAllContainers", UnpauseAllContainers = "UnpauseAllContainers", StopAllContainers = "StopAllContainers", PruneContainers = "PruneContainers", CreateNetwork = "CreateNetwork", DeleteNetwork = "DeleteNetwork", PruneNetworks = "PruneNetworks", DeleteImage = "DeleteImage", PruneImages = "PruneImages", DeleteVolume = "DeleteVolume", PruneVolumes = "PruneVolumes", PruneDockerBuilders = "PruneDockerBuilders", PruneBuildx = "PruneBuildx", PruneSystem = "PruneSystem", CreateStack = "CreateStack", UpdateStack = "UpdateStack", RenameStack = "RenameStack", DeleteStack = "DeleteStack", WriteStackContents = "WriteStackContents", RefreshStackCache = "RefreshStackCache", PullStack = "PullStack", DeployStack = "DeployStack", StartStack = "StartStack", RestartStack = "RestartStack", PauseStack = "PauseStack", UnpauseStack = "UnpauseStack", StopStack = "StopStack", DestroyStack = "DestroyStack", RunStackService = "RunStackService", DeployStackService = "DeployStackService", PullStackService = "PullStackService", StartStackService = "StartStackService", RestartStackService = "RestartStackService", PauseStackService = "PauseStackService", UnpauseStackService = "UnpauseStackService", StopStackService = "StopStackService", DestroyStackService = "DestroyStackService", CreateDeployment = "CreateDeployment", UpdateDeployment = "UpdateDeployment", RenameDeployment = "RenameDeployment", DeleteDeployment = "DeleteDeployment", Deploy = "Deploy", PullDeployment = "PullDeployment", StartDeployment = "StartDeployment", RestartDeployment = "RestartDeployment", PauseDeployment = "PauseDeployment", UnpauseDeployment = "UnpauseDeployment", StopDeployment = "StopDeployment", DestroyDeployment = "DestroyDeployment", CreateBuild = "CreateBuild", UpdateBuild = "UpdateBuild", RenameBuild = "RenameBuild", DeleteBuild = "DeleteBuild", RunBuild = "RunBuild", CancelBuild = "CancelBuild", WriteDockerfile = "WriteDockerfile", CreateRepo = "CreateRepo", UpdateRepo = "UpdateRepo", RenameRepo = "RenameRepo", DeleteRepo = "DeleteRepo", CloneRepo = "CloneRepo", PullRepo = "PullRepo", BuildRepo = "BuildRepo", CancelRepoBuild = "CancelRepoBuild", CreateProcedure = "CreateProcedure", UpdateProcedure = "UpdateProcedure", RenameProcedure = "RenameProcedure", DeleteProcedure = "DeleteProcedure", RunProcedure = "RunProcedure", CreateAction = "CreateAction", UpdateAction = "UpdateAction", RenameAction = "RenameAction", DeleteAction = "DeleteAction", RunAction = "RunAction", CreateBuilder = "CreateBuilder", UpdateBuilder = "UpdateBuilder", RenameBuilder = "RenameBuilder", DeleteBuilder = "DeleteBuilder", CreateAlerter = "CreateAlerter", UpdateAlerter = "UpdateAlerter", RenameAlerter = "RenameAlerter", DeleteAlerter = "DeleteAlerter", TestAlerter = "TestAlerter", SendAlert = "SendAlert", CreateResourceSync = "CreateResourceSync", UpdateResourceSync = "UpdateResourceSync", RenameResourceSync = "RenameResourceSync", DeleteResourceSync = "DeleteResourceSync", WriteSyncContents = "WriteSyncContents", CommitSync = "CommitSync", RunSync = "RunSync", ClearRepoCache = "ClearRepoCache", BackupCoreDatabase = "BackupCoreDatabase", GlobalAutoUpdate = "GlobalAutoUpdate", CreateVariable = "CreateVariable", UpdateVariableValue = "UpdateVariableValue", DeleteVariable = "DeleteVariable", CreateGitProviderAccount = "CreateGitProviderAccount", UpdateGitProviderAccount = "UpdateGitProviderAccount", DeleteGitProviderAccount = "DeleteGitProviderAccount", CreateDockerRegistryAccount = "CreateDockerRegistryAccount", UpdateDockerRegistryAccount = "UpdateDockerRegistryAccount", DeleteDockerRegistryAccount = "DeleteDockerRegistryAccount" } /** Represents the output of some command being run */ export interface Log { /** A label for the log */ stage: string; /** The command which was executed */ command: string; /** The output of the command in the standard channel */ stdout: string; /** The output of the command in the error channel */ stderr: string; /** Whether the command run was successful */ success: boolean; /** The start time of the command execution */ start_ts: I64; /** The end time of the command execution */ end_ts: I64; } /** An update's status */ export declare enum UpdateStatus { /** The run is in the system but hasn't started yet */ Queued = "Queued", /** The run is currently running */ InProgress = "InProgress", /** The run is complete */ Complete = "Complete" } export interface Version { major: number; minor: number; patch: number; } /** Represents an action performed by Komodo. */ export interface Update { /** * The Mongo ID of the update. * This field is de/serialized from/to JSON as * `{ "_id": { "$oid": "..." }, ...(rest of serialized Update) }` */ _id?: MongoId; /** The operation performed */ operation: Operation; /** The time the operation started */ start_ts: I64; /** Whether the operation was successful */ success: boolean; /** * The user id that triggered the update. * * Also can take these values for operations triggered automatically: * - `Procedure`: The operation was triggered as part of a procedure run * - `Github`: The operation was triggered by a github webhook * - `Auto Redeploy`: The operation (always `Deploy`) was triggered by an attached build finishing. */ operator: string; /** The target resource to which this update refers */ target: ResourceTarget; /** Logs produced as the operation is performed */ logs: Log[]; /** The time the operation completed. */ end_ts?: I64; /** * The status of the update * - `Queued` * - `InProgress` * - `Complete` */ status: UpdateStatus; /** An optional version on the update, ie build version or deployed version. */ version?: Version; /** An optional commit hash associated with the update, ie cloned hash or deployed hash. */ commit_hash?: string; /** Some unstructured, operation specific data. Not for general usage. */ other_data?: string; /** If the update is for resource config update, give the previous toml contents */ prev_toml?: string; /** If the update is for resource config update, give the current (at time of Update) toml contents */ current_toml?: string; } export type BoxUpdate = Update; /** Configuration for an image registry */ export interface ImageRegistryConfig { /** * Specify the registry provider domain, eg `docker.io`. * If not provided, will not push to any registry. */ domain?: string; /** Specify an account to use with the registry. */ account?: string; /** * Optional. Specify an organization to push the image under. * Empty string means no organization. */ organization?: string; } export interface SystemCommand { path?: string; command?: string; } /** The build configuration. */ export interface BuildConfig { /** Which builder is used to build the image. */ builder_id?: string; /** The current version of the build. */ version?: Version; /** * Whether to automatically increment the patch on every build. * Default is `true` */ auto_increment_version: boolean; /** * An alternate name for the image pushed to the repository. * If this is empty, it will use the build name. * * Can be used in conjunction with `image_tag` to direct multiple builds * with different configs to push to the same image registry, under different, * independantly versioned tags. */ image_name?: string; /** * An extra tag put after the build version, for the image pushed to the repository. * Eg. in image tag of `aarch64` would push to moghtech/komodo-core:1.13.2-aarch64. * If this is empty, the image tag will just be the build version. * * Can be used in conjunction with `image_name` to direct multiple builds * with different configs to push to the same image registry, under different, * independantly versioned tags. */ image_tag?: string; /** Push `:latest` / `:latest-image_tag` tags. */ include_latest_tag: boolean; /** Push build version semver `:1.19.5` + `1.19` / `:1.19.5-image_tag` tags. */ include_version_tags: boolean; /** Push commit hash `:a6v8h83` / `:a6v8h83-image_tag` tags. */ include_commit_tag: boolean; /** Configure quick links that are displayed in the resource header */ links?: string[]; /** Choose a Komodo Repo (Resource) to source the build files. */ linked_repo?: string; /** The git provider domain. Default: github.com */ git_provider: string; /** * Whether to use https to clone the repo (versus http). Default: true * * Note. Komodo does not currently support cloning repos via ssh. */ git_https: boolean; /** * The git account used to access private repos. * Passing empty string can only clone public repos. * * Note. A token for the account must be available in the core config or the builder server's periphery config * for the configured git provider. */ git_account?: string; /** The repo used as the source of the build. */ repo?: string; /** The branch of the repo. */ branch: string; /** Optionally set a specific commit hash. */ commit?: string; /** Whether incoming webhooks actually trigger action. */ webhook_enabled: boolean; /** * Optionally provide an alternate webhook secret for this build. * If its an empty string, use the default secret from the config. */ webhook_secret?: string; /** * If this is checked, the build will source the files on the host. * Use `build_path` and `dockerfile_path` to specify the path on the host. * This is useful for those who wish to setup their files on the host, * rather than defining the contents in UI or in a git repo. */ files_on_host?: boolean; /** * The path of the docker build context relative to the root of the repo. * Default: "." (the root of the repo). */ build_path: string; /** The path of the dockerfile relative to the build path. */ dockerfile_path: string; /** * Configuration for the registry/s to push the built image to. * The first registry in this list will be used with attached Deployments. */ image_registry?: ImageRegistryConfig[]; /** Whether to skip secret interpolation in the build_args. */ skip_secret_interp?: boolean; /** Whether to use buildx to build (eg `docker buildx build ...`) */ use_buildx?: boolean; /** Any extra docker cli arguments to be included in the build command */ extra_args?: string[]; /** The optional command run after repo clone and before docker build. */ pre_build?: SystemCommand; /** * UI defined dockerfile contents. * Supports variable / secret interpolation. */ dockerfile?: string; /** * Docker build arguments. * * These values are visible in the final image by running `docker inspect`. */ build_args?: string; /** * Secret arguments. * * These values remain hidden in the final image by using * docker secret mounts. See . * * The values can be used in RUN commands: * ```sh * RUN --mount=type=secret,id=SECRET_KEY \ * SECRET_KEY=$(cat /run/secrets/SECRET_KEY) ... * ``` */ secret_args?: string; /** Docker labels */ labels?: string; } export interface BuildInfo { /** The timestamp build was last built. */ last_built_at: I64; /** Latest built short commit hash, or null. */ built_hash?: string; /** Latest built commit message, or null. Only for repo based stacks */ built_message?: string; /** * The last built dockerfile contents. * This is updated whenever Komodo successfully runs the build. */ built_contents?: string; /** The absolute path to the file */ remote_path?: string; /** * The remote dockerfile contents, whether on host or in repo. * This is updated whenever Komodo refreshes the build cache. * It will be empty if the dockerfile is defined directly in the build config. */ remote_contents?: string; /** If there was an error in getting the remote contents, it will be here. */ remote_error?: string; /** Latest remote short commit hash, or null. */ latest_hash?: string; /** Latest remote commit message, or null */ latest_message?: string; } export type Build = Resource; export declare enum BuildState { /** Currently building */ Building = "Building", /** Last build successful (or never built) */ Ok = "Ok", /** Last build failed */ Failed = "Failed", /** Other case */ Unknown = "Unknown" } export interface BuildListItemInfo { /** State of the build. Reflects whether most recent build successful. */ state: BuildState; /** Unix timestamp in milliseconds of last build */ last_built_at: I64; /** The current version of the build */ version: Version; /** The builder attached to build. */ builder_id: string; /** Whether build is in files on host mode. */ files_on_host: boolean; /** Whether build has UI defined dockerfile contents */ dockerfile_contents: boolean; /** Linked repo, if one is attached. */ linked_repo: string; /** The git provider domain */ git_provider: string; /** The repo used as the source of the build */ repo: string; /** The branch of the repo */ branch: string; /** Full link to the repo. */ repo_link: string; /** Latest built short commit hash, or null. */ built_hash?: string; /** Latest short commit hash, or null. Only for repo based stacks */ latest_hash?: string; /** The first listed image registry domain */ image_registry_domain?: string; } export type BuildListItem = ResourceListItem; export interface BuildQuerySpecifics { builder_ids?: string[]; repos?: string[]; /** * query for builds last built more recently than this timestamp * defaults to 0 which is a no op */ built_since?: I64; } export type BuildQuery = ResourceQuery; export type BuilderConfig = /** Use a Periphery address as a Builder. */ { type: "Url"; params: UrlBuilderConfig; } /** Use a connected server as a Builder. */ | { type: "Server"; params: ServerBuilderConfig; } /** Use EC2 instances spawned on demand as a Builder. */ | { type: "Aws"; params: AwsBuilderConfig; }; export type Builder = Resource; export interface BuilderListItemInfo { /** 'Url', 'Server', or 'Aws' */ builder_type: string; /** * If 'Url': null * If 'Server': the server id * If 'Aws': the instance type (eg. c5.xlarge) */ instance_type?: string; } export type BuilderListItem = ResourceListItem; export interface BuilderQuerySpecifics { } export type BuilderQuery = ResourceQuery; /** A wrapper for all Komodo exections. */ export type Execution = /** The "null" execution. Does nothing. */ { type: "None"; params: NoData; } /** Run the target action. (alias: `action`, `ac`) */ | { type: "RunAction"; params: RunAction; } | { type: "BatchRunAction"; params: BatchRunAction; } /** Run the target procedure. (alias: `procedure`, `pr`) */ | { type: "RunProcedure"; params: RunProcedure; } | { type: "BatchRunProcedure"; params: BatchRunProcedure; } /** Run the target build. (alias: `build`, `bd`) */ | { type: "RunBuild"; params: RunBuild; } | { type: "BatchRunBuild"; params: BatchRunBuild; } | { type: "CancelBuild"; params: CancelBuild; } /** Deploy the target deployment. (alias: `dp`) */ | { type: "Deploy"; params: Deploy; } | { type: "BatchDeploy"; params: BatchDeploy; } | { type: "PullDeployment"; params: PullDeployment; } | { type: "StartDeployment"; params: StartDeployment; } | { type: "RestartDeployment"; params: RestartDeployment; } | { type: "PauseDeployment"; params: PauseDeployment; } | { type: "UnpauseDeployment"; params: UnpauseDeployment; } | { type: "StopDeployment"; params: StopDeployment; } | { type: "DestroyDeployment"; params: DestroyDeployment; } | { type: "BatchDestroyDeployment"; params: BatchDestroyDeployment; } /** Clone the target repo */ | { type: "CloneRepo"; params: CloneRepo; } | { type: "BatchCloneRepo"; params: BatchCloneRepo; } | { type: "PullRepo"; params: PullRepo; } | { type: "BatchPullRepo"; params: BatchPullRepo; } | { type: "BuildRepo"; params: BuildRepo; } | { type: "BatchBuildRepo"; params: BatchBuildRepo; } | { type: "CancelRepoBuild"; params: CancelRepoBuild; } | { type: "StartContainer"; params: StartContainer; } | { type: "RestartContainer"; params: RestartContainer; } | { type: "PauseContainer"; params: PauseContainer; } | { type: "UnpauseContainer"; params: UnpauseContainer; } | { type: "StopContainer"; params: StopContainer; } | { type: "DestroyContainer"; params: DestroyContainer; } | { type: "StartAllContainers"; params: StartAllContainers; } | { type: "RestartAllContainers"; params: RestartAllContainers; } | { type: "PauseAllContainers"; params: PauseAllContainers; } | { type: "UnpauseAllContainers"; params: UnpauseAllContainers; } | { type: "StopAllContainers"; params: StopAllContainers; } | { type: "PruneContainers"; params: PruneContainers; } | { type: "DeleteNetwork"; params: DeleteNetwork; } | { type: "PruneNetworks"; params: PruneNetworks; } | { type: "DeleteImage"; params: DeleteImage; } | { type: "PruneImages"; params: PruneImages; } | { type: "DeleteVolume"; params: DeleteVolume; } | { type: "PruneVolumes"; params: PruneVolumes; } | { type: "PruneDockerBuilders"; params: PruneDockerBuilders; } | { type: "PruneBuildx"; params: PruneBuildx; } | { type: "PruneSystem"; params: PruneSystem; } /** Execute a Resource Sync. (alias: `sync`) */ | { type: "RunSync"; params: RunSync; } /** Commit a Resource Sync. (alias: `commit`) */ | { type: "CommitSync"; params: CommitSync; } /** Deploy the target stack. (alias: `stack`, `st`) */ | { type: "DeployStack"; params: DeployStack; } | { type: "BatchDeployStack"; params: BatchDeployStack; } | { type: "DeployStackIfChanged"; params: DeployStackIfChanged; } | { type: "BatchDeployStackIfChanged"; params: BatchDeployStackIfChanged; } | { type: "PullStack"; params: PullStack; } | { type: "BatchPullStack"; params: BatchPullStack; } | { type: "StartStack"; params: StartStack; } | { type: "RestartStack"; params: RestartStack; } | { type: "PauseStack"; params: PauseStack; } | { type: "UnpauseStack"; params: UnpauseStack; } | { type: "StopStack"; params: StopStack; } | { type: "DestroyStack"; params: DestroyStack; } | { type: "BatchDestroyStack"; params: BatchDestroyStack; } | { type: "RunStackService"; params: RunStackService; } | { type: "TestAlerter"; params: TestAlerter; } | { type: "SendAlert"; params: SendAlert; } | { type: "ClearRepoCache"; params: ClearRepoCache; } | { type: "BackupCoreDatabase"; params: BackupCoreDatabase; } | { type: "GlobalAutoUpdate"; params: GlobalAutoUpdate; } | { type: "Sleep"; params: Sleep; }; /** Allows to enable / disabled procedures in the sequence / parallel vec on the fly */ export interface EnabledExecution { /** The execution request to run. */ execution: Execution; /** Whether the execution is enabled to run in the procedure. */ enabled: boolean; } /** A single stage of a procedure. Runs a list of executions in parallel. */ export interface ProcedureStage { /** A name for the procedure */ name: string; /** Whether the stage should be run as part of the procedure. */ enabled: boolean; /** The executions in the stage */ executions?: EnabledExecution[]; } /** Config for the [Procedure] */ export interface ProcedureConfig { /** The stages to be run by the procedure. */ stages?: ProcedureStage[]; /** Choose whether to specify schedule as regular CRON, or using the english to CRON parser. */ schedule_format?: ScheduleFormat; /** * Optionally provide a schedule for the procedure to run on. * * There are 2 ways to specify a schedule: * * 1. Regular CRON expression: * * (second, minute, hour, day, month, day-of-week) * ```text * 0 0 0 1,15 * ? * ``` * * 2. "English" expression via [english-to-cron](https://crates.io/crates/english-to-cron): * * ```text * at midnight on the 1st and 15th of the month * ``` */ schedule?: string; /** * Whether schedule is enabled if one is provided. * Can be used to temporarily disable the schedule. */ schedule_enabled: boolean; /** * Optional. A TZ Identifier. If not provided, will use Core local timezone. * https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. */ schedule_timezone?: string; /** Whether to send alerts when the schedule was run. */ schedule_alert: boolean; /** Whether to send alerts when this procedure fails. */ failure_alert: boolean; /** Whether incoming webhooks actually trigger action. */ webhook_enabled: boolean; /** * Optionally provide an alternate webhook secret for this procedure. * If its an empty string, use the default secret from the config. */ webhook_secret?: string; } /** * Procedures run a series of stages sequentially, where * each stage runs executions in parallel. */ export type Procedure = Resource; export type CopyProcedureResponse = Procedure; export type CreateActionWebhookResponse = NoData; /** Response for [CreateApiKey]. */ export interface CreateApiKeyResponse { /** X-API-KEY */ key: string; /** * X-API-SECRET * * Note. * There is no way to get the secret again after it is distributed in this message */ secret: string; } export type CreateApiKeyForServiceUserResponse = CreateApiKeyResponse; export type CreateBuildWebhookResponse = NoData; /** Configuration to access private image repositories on various registries. */ export interface DockerRegistryAccount { /** * The Mongo ID of the docker registry account. * This field is de/serialized from/to JSON as * `{ "_id": { "$oid": "..." }, ...(rest of DockerRegistryAccount) }` */ _id?: MongoId; /** * The domain of the provider. * * For docker registry, this can include 'http://...', * however this is not recommended and won't work unless "insecure registries" are enabled * on your hosts. See . */ domain: string; /** The account username */ username?: string; /** * The token in plain text on the db. * If the database / host can be accessed this is insecure. */ token?: string; } export type CreateDockerRegistryAccountResponse = DockerRegistryAccount; /** * Configuration to access private git repos from various git providers. * Note. Cannot create two accounts with the same domain and username. */ export interface GitProviderAccount { /** * The Mongo ID of the git provider account. * This field is de/serialized from/to JSON as * `{ "_id": { "$oid": "..." }, ...(rest of serialized User) }` */ _id?: MongoId; /** * The domain of the provider. * * For git, this cannot include the protocol eg 'http://', * which is controlled with 'https' field. */ domain: string; /** Whether git provider is accessed over http or https. */ https: boolean; /** The account username */ username?: string; /** * The token in plain text on the db. * If the database / host can be accessed this is insecure. */ token?: string; } export type CreateGitProviderAccountResponse = GitProviderAccount; export type UserConfig = /** User that logs in with username / password */ { type: "Local"; data: { password: string; }; } /** User that logs in via Google Oauth */ | { type: "Google"; data: { google_id: string; avatar: string; }; } /** User that logs in via Github Oauth */ | { type: "Github"; data: { github_id: string; avatar: string; }; } /** User that logs in via Oidc provider */ | { type: "Oidc"; data: { provider: string; user_id: string; }; } /** Non-human managed user, can have it's own permissions / api keys */ | { type: "Service"; data: { description: string; }; }; export interface User { /** * The Mongo ID of the User. * This field is de/serialized from/to JSON as * `{ "_id": { "$oid": "..." }, ...(rest of User schema) }` */ _id?: MongoId; /** The globally unique username for the user. */ username: string; /** Whether user is enabled / able to access the api. */ enabled?: boolean; /** Can give / take other users admin priviledges. */ super_admin?: boolean; /** Whether the user has global admin permissions. */ admin?: boolean; /** Whether the user has permission to create servers. */ create_server_permissions?: boolean; /** Whether the user has permission to create builds */ create_build_permissions?: boolean; /** The user-type specific config. */ config: UserConfig; /** When the user last opened updates dropdown. */ last_update_view?: I64; /** Recently viewed ids */ recents?: Record; /** Give the user elevated permissions on all resources of a certain type */ all?: Record; updated_at?: I64; } export type CreateLocalUserResponse = User; export type CreateProcedureResponse = Procedure; export type CreateRepoWebhookResponse = NoData; export type CreateServiceUserResponse = User; export type CreateStackWebhookResponse = NoData; export type CreateSyncWebhookResponse = NoData; /** * A non-secret global variable which can be interpolated into deployment * environment variable values and build argument values. */ export interface Variable { /** * Unique name associated with the variable. * Instances of '[[variable.name]]' in value will be replaced with 'variable.value'. */ name: string; /** A description for the variable. */ description?: string; /** The value associated with the variable. */ value?: string; /** * If marked as secret, the variable value will be hidden in updates / logs. * Additionally the value will not be served in read requests by non admin users. * * Note that the value is NOT encrypted in the database, and will likely show up in database logs. * The security of these variables comes down to the security * of the database (system level encryption, network isolation, etc.) */ is_secret?: boolean; } export type CreateVariableResponse = Variable; export type DeleteActionWebhookResponse = NoData; export type DeleteApiKeyForServiceUserResponse = NoData; export type DeleteApiKeyResponse = NoData; export type DeleteBuildWebhookResponse = NoData; export type DeleteDockerRegistryAccountResponse = DockerRegistryAccount; export type DeleteGitProviderAccountResponse = GitProviderAccount; export type DeleteProcedureResponse = Procedure; export type DeleteRepoWebhookResponse = NoData; export type DeleteStackWebhookResponse = NoData; export type DeleteSyncWebhookResponse = NoData; export type DeleteUserResponse = User; export type DeleteVariableResponse = Variable; export type DeploymentImage = /** Deploy any external image. */ { type: "Image"; params: { /** The docker image, can be from any registry that works with docker and that the host server can reach. */ image?: string; }; } /** Deploy a Komodo Build. */ | { type: "Build"; params: { /** The id of the Build */ build_id?: string; /** * Use a custom / older version of the image produced by the build. * if version is 0.0.0, this means `latest` image. */ version?: Version; }; }; export declare enum RestartMode { NoRestart = "no", OnFailure = "on-failure", Always = "always", UnlessStopped = "unless-stopped" } export declare enum TerminationSignal { SigHup = "SIGHUP", SigInt = "SIGINT", SigQuit = "SIGQUIT", SigTerm = "SIGTERM" } export interface DeploymentConfig { /** The id of server the deployment is deployed on. */ server_id?: string; /** * The image which the deployment deploys. * Can either be a user inputted image, or a Komodo Build. */ image?: DeploymentImage; /** * Configure the account used to pull the image from the registry. * Used with `docker login`. * * - If the field is empty string, will use the same account config as the build, or none at all if using image. * - If the field contains an account, a token for the account must be available. * - Will get the registry domain from the build / image */ image_registry_account?: string; /** Whether to skip secret interpolation into the deployment environment variables. */ skip_secret_interp?: boolean; /** Whether to redeploy the deployment whenever the attached build finishes. */ redeploy_on_build?: boolean; /** Whether to poll for any updates to the image. */ poll_for_updates?: boolean; /** * Whether to automatically redeploy when * newer a image is found. Will implicitly * enable `poll_for_updates`, you don't need to * enable both. */ auto_update?: boolean; /** Whether to send ContainerStateChange alerts for this deployment. */ send_alerts: boolean; /** Configure quick links that are displayed in the resource header */ links?: string[]; /** * The network attached to the container. * Default is `host`. */ network: string; /** The restart mode given to the container. */ restart?: RestartMode; /** * This is interpolated at the end of the `docker run` command, * which means they are either passed to the containers inner process, * or replaces the container command, depending on use of ENTRYPOINT or CMD in dockerfile. * Empty is no command. */ command?: string; /** The default termination signal to use to stop the deployment. Defaults to SigTerm (default docker signal). */ termination_signal?: TerminationSignal; /** The termination timeout. */ termination_timeout: number; /** * Extra args which are interpolated into the `docker run` command, * and affect the container configuration. */ extra_args?: string[]; /** * Labels attached to various termination signal options. * Used to specify different shutdown functionality depending on the termination signal. */ term_signal_labels?: string; /** * The container port mapping. * Irrelevant if container network is `host`. * Maps ports on host to ports on container. */ ports?: string; /** * The container volume mapping. * Maps files / folders on host to files / folders in container. */ volumes?: string; /** The environment variables passed to the container. */ environment?: string; /** The docker labels given to the container. */ labels?: string; } export type Deployment = Resource; /** * Variants de/serialized from/to snake_case. * * Eg. * - NotDeployed -> not_deployed * - Restarting -> restarting * - Running -> running. */ export declare enum DeploymentState { /** The deployment is currently re/deploying */ Deploying = "deploying", /** Container is running */ Running = "running", /** Container is created but not running */ Created = "created", /** Container is in restart loop */ Restarting = "restarting", /** Container is being removed */ Removing = "removing", /** Container is paused */ Paused = "paused", /** Container is exited */ Exited = "exited", /** Container is dead */ Dead = "dead", /** The deployment is not deployed (no matching container) */ NotDeployed = "not_deployed", /** Server not reachable for status */ Unknown = "unknown" } export interface DeploymentListItemInfo { /** The state of the deployment / underlying docker container. */ state: DeploymentState; /** The status of the docker container (eg. up 12 hours, exited 5 minutes ago.) */ status?: string; /** The image attached to the deployment. */ image: string; /** Whether there is a newer image available at the same tag. */ update_available: boolean; /** The server that deployment sits on. */ server_id: string; /** An attached Komodo Build, if it exists. */ build_id?: string; } export type DeploymentListItem = ResourceListItem; export interface DeploymentQuerySpecifics { /** * Query only for Deployments on these Servers. * If empty, does not filter by Server. * Only accepts Server id (not name). */ server_ids?: string[]; /** * Query only for Deployments with these Builds attached. * If empty, does not filter by Build. * Only accepts Build id (not name). */ build_ids?: string[]; /** Query only for Deployments with available image updates. */ update_available?: boolean; } export type DeploymentQuery = ResourceQuery; /** JSON containing an authentication token. */ export interface JwtResponse { /** User ID for signed in user. */ user_id: string; /** A token the user can use to authenticate their requests. */ jwt: string; } /** Response for [ExchangeForJwt]. */ export type ExchangeForJwtResponse = JwtResponse; /** Response containing pretty formatted toml contents. */ export interface TomlResponse { toml: string; } export type ExportAllResourcesToTomlResponse = TomlResponse; export type ExportResourcesToTomlResponse = TomlResponse; export type FindUserResponse = User; export interface ActionActionState { /** Number of instances of the Action currently running */ running: number; } export type GetActionActionStateResponse = ActionActionState; export type GetActionResponse = Action; /** Severity level of problem. */ export declare enum SeverityLevel { /** * No problem. * * Aliases: ok, low, l */ Ok = "OK", /** * Problem is imminent. * * Aliases: warning, w, medium, m */ Warning = "WARNING", /** * Problem fully realized. * * Aliases: critical, c, high, h */ Critical = "CRITICAL" } /** The variants of data related to the alert. */ export type AlertData = /** A null alert */ { type: "None"; data: {}; } /** * The user triggered a test of the * Alerter configuration. */ | { type: "Test"; data: { /** The id of the alerter */ id: string; /** The name of the alerter */ name: string; }; } /** A server could not be reached. */ | { type: "ServerUnreachable"; data: { /** The id of the server */ id: string; /** The name of the server */ name: string; /** The region of the server */ region?: string; /** The error data */ err?: _Serror; }; } /** A server has high CPU usage. */ | { type: "ServerCpu"; data: { /** The id of the server */ id: string; /** The name of the server */ name: string; /** The region of the server */ region?: string; /** The cpu usage percentage */ percentage: number; }; } /** A server has high memory usage. */ | { type: "ServerMem"; data: { /** The id of the server */ id: string; /** The name of the server */ name: string; /** The region of the server */ region?: string; /** The used memory */ used_gb: number; /** The total memory */ total_gb: number; }; } /** A server has high disk usage. */ | { type: "ServerDisk"; data: { /** The id of the server */ id: string; /** The name of the server */ name: string; /** The region of the server */ region?: string; /** The mount path of the disk */ path: string; /** The used portion of the disk in GB */ used_gb: number; /** The total size of the disk in GB */ total_gb: number; }; } /** A server has a version mismatch with the core. */ | { type: "ServerVersionMismatch"; data: { /** The id of the server */ id: string; /** The name of the server */ name: string; /** The region of the server */ region?: string; /** The actual server version */ server_version: string; /** The core version */ core_version: string; }; } /** A container's state has changed unexpectedly. */ | { type: "ContainerStateChange"; data: { /** The id of the deployment */ id: string; /** The name of the deployment */ name: string; /** The server id of server that the deployment is on */ server_id: string; /** The server name */ server_name: string; /** The previous container state */ from: DeploymentState; /** The current container state */ to: DeploymentState; }; } /** A Deployment has an image update available */ | { type: "DeploymentImageUpdateAvailable"; data: { /** The id of the deployment */ id: string; /** The name of the deployment */ name: string; /** The server id of server that the deployment is on */ server_id: string; /** The server name */ server_name: string; /** The image with update */ image: string; }; } /** A Deployment has an image update available */ | { type: "DeploymentAutoUpdated"; data: { /** The id of the deployment */ id: string; /** The name of the deployment */ name: string; /** The server id of server that the deployment is on */ server_id: string; /** The server name */ server_name: string; /** The updated image */ image: string; }; } /** A stack's state has changed unexpectedly. */ | { type: "StackStateChange"; data: { /** The id of the stack */ id: string; /** The name of the stack */ name: string; /** The server id of server that the stack is on */ server_id: string; /** The server name */ server_name: string; /** The previous stack state */ from: StackState; /** The current stack state */ to: StackState; }; } /** A Stack has an image update available */ | { type: "StackImageUpdateAvailable"; data: { /** The id of the stack */ id: string; /** The name of the stack */ name: string; /** The server id of server that the stack is on */ server_id: string; /** The server name */ server_name: string; /** The service name to update */ service: string; /** The image with update */ image: string; }; } /** A Stack was auto updated */ | { type: "StackAutoUpdated"; data: { /** The id of the stack */ id: string; /** The name of the stack */ name: string; /** The server id of server that the stack is on */ server_id: string; /** The server name */ server_name: string; /** One or more images that were updated */ images: string[]; }; } /** An AWS builder failed to terminate. */ | { type: "AwsBuilderTerminationFailed"; data: { /** The id of the aws instance which failed to terminate */ instance_id: string; /** A reason for the failure */ message: string; }; } /** A resource sync has pending updates */ | { type: "ResourceSyncPendingUpdates"; data: { /** The id of the resource sync */ id: string; /** The name of the resource sync */ name: string; }; } /** A build has failed */ | { type: "BuildFailed"; data: { /** The id of the build */ id: string; /** The name of the build */ name: string; /** The version that failed to build */ version: Version; }; } /** A repo has failed */ | { type: "RepoBuildFailed"; data: { /** The id of the repo */ id: string; /** The name of the repo */ name: string; }; } /** A procedure has failed */ | { type: "ProcedureFailed"; data: { /** The id of the procedure */ id: string; /** The name of the procedure */ name: string; }; } /** An action has failed */ | { type: "ActionFailed"; data: { /** The id of the action */ id: string; /** The name of the action */ name: string; }; } /** A schedule was run */ | { type: "ScheduleRun"; data: { /** Procedure or Action */ resource_type: ResourceTarget["type"]; /** The resource id */ id: string; /** The resource name */ name: string; }; } /** * Custom header / body. * Produced using `/execute/SendAlert` */ | { type: "Custom"; data: { /** The alert message. */ message: string; /** Message details. May be empty string. */ details?: string; }; }; /** Representation of an alert in the system. */ export interface Alert { /** * The Mongo ID of the alert. * This field is de/serialized from/to JSON as * `{ "_id": { "$oid": "..." }, ...(rest of serialized Alert) }` */ _id?: MongoId; /** Unix timestamp in milliseconds the alert was opened */ ts: I64; /** Whether the alert is already resolved */ resolved: boolean; /** The severity of the alert */ level: SeverityLevel; /** The target of the alert */ target: ResourceTarget; /** The data attached to the alert */ data: AlertData; /** The timestamp of alert resolution */ resolved_ts?: I64; } export type GetAlertResponse = Alert; export type GetAlerterResponse = Alerter; export interface BuildActionState { building: boolean; } export type GetBuildActionStateResponse = BuildActionState; export type GetBuildResponse = Build; export type GetBuilderResponse = Builder; export type GetContainerLogResponse = Log; export interface DeploymentActionState { pulling: boolean; deploying: boolean; starting: boolean; restarting: boolean; pausing: boolean; unpausing: boolean; stopping: boolean; destroying: boolean; renaming: boolean; } export type GetDeploymentActionStateResponse = DeploymentActionState; export type GetDeploymentLogResponse = Log; export type GetDeploymentResponse = Deployment; export interface ContainerStats { name: string; cpu_perc: string; mem_perc: string; mem_usage: string; net_io: string; block_io: string; pids: string; } export type GetDeploymentStatsResponse = ContainerStats; export type GetDockerRegistryAccountResponse = DockerRegistryAccount; export type GetGitProviderAccountResponse = GitProviderAccount; export type GetPermissionResponse = PermissionLevelAndSpecifics; export interface ProcedureActionState { running: boolean; } export type GetProcedureActionStateResponse = ProcedureActionState; export type GetProcedureResponse = Procedure; export interface RepoActionState { /** Whether Repo currently cloning on the attached Server */ cloning: boolean; /** Whether Repo currently pulling on the attached Server */ pulling: boolean; /** Whether Repo currently building using the attached Builder. */ building: boolean; /** Whether Repo currently renaming. */ renaming: boolean; } export type GetRepoActionStateResponse = RepoActionState; export interface RepoConfig { /** The server to clone the repo on. */ server_id?: string; /** Attach a builder to 'build' the repo. */ builder_id?: string; /** The git provider domain. Default: github.com */ git_provider: string; /** * Whether to use https to clone the repo (versus http). Default: true * * Note. Komodo does not currently support cloning repos via ssh. */ git_https: boolean; /** * The git account used to access private repos. * Passing empty string can only clone public repos. * * Note. A token for the account must be available in the core config or the builder server's periphery config * for the configured git provider. */ git_account?: string; /** The github repo to clone. */ repo?: string; /** The repo branch. */ branch: string; /** Optionally set a specific commit hash. */ commit?: string; /** * Explicitly specify the folder to clone the repo in. * - If absolute (has leading '/') * - Used directly as the path * - If relative * - Taken relative to Periphery `repo_dir` (ie `${root_directory}/repos`) */ path?: string; /** Whether incoming webhooks actually trigger action. */ webhook_enabled: boolean; /** * Optionally provide an alternate webhook secret for this repo. * If its an empty string, use the default secret from the config. */ webhook_secret?: string; /** * Command to be run after the repo is cloned. * The path is relative to the root of the repo. */ on_clone?: SystemCommand; /** * Command to be run after the repo is pulled. * The path is relative to the root of the repo. */ on_pull?: SystemCommand; /** Configure quick links that are displayed in the resource header */ links?: string[]; /** * The environment variables passed to the compose file. * They will be written to path defined in env_file_path, * which is given relative to the run directory. * * If it is empty, no file will be written. */ environment?: string; /** * The name of the written environment file before `docker compose up`. * Relative to the repo root. * Default: .env */ env_file_path: string; /** Whether to skip secret interpolation into the repo environment variable file. */ skip_secret_interp?: boolean; } export interface RepoInfo { /** When repo was last pulled */ last_pulled_at?: I64; /** When repo was last built */ last_built_at?: I64; /** Latest built short commit hash, or null. */ built_hash?: string; /** Latest built commit message, or null. Only for repo based stacks */ built_message?: string; /** Latest remote short commit hash, or null. */ latest_hash?: string; /** Latest remote commit message, or null */ latest_message?: string; } export type Repo = Resource; export type GetRepoResponse = Repo; export interface ResourceSyncActionState { /** Whether sync currently syncing */ syncing: boolean; } export type GetResourceSyncActionStateResponse = ResourceSyncActionState; /** The sync configuration. */ export interface ResourceSyncConfig { /** Choose a Komodo Repo (Resource) to source the sync files. */ linked_repo?: string; /** The git provider domain. Default: github.com */ git_provider: string; /** * Whether to use https to clone the repo (versus http). Default: true * * Note. Komodo does not currently support cloning repos via ssh. */ git_https: boolean; /** The Github repo used as the source of the build. */ repo?: string; /** The branch of the repo. */ branch: string; /** Optionally set a specific commit hash. */ commit?: string; /** * The git account used to access private repos. * Passing empty string can only clone public repos. * * Note. A token for the account must be available in the core config or the builder server's periphery config * for the configured git provider. */ git_account?: string; /** Whether incoming webhooks actually trigger action. */ webhook_enabled: boolean; /** * Optionally provide an alternate webhook secret for this sync. * If its an empty string, use the default secret from the config. */ webhook_secret?: string; /** * Files are available on the Komodo Core host. * Specify the file / folder with [ResourceSyncConfig::resource_path]. */ files_on_host?: boolean; /** * The path of the resource file(s) to sync. * - If Files on Host, this is relative to the configured `sync_directory` in core config. * - If Git Repo based, this is relative to the root of the repo. * Can be a specific file, or a directory containing multiple files / folders. * See [https://komo.do/docs/sync-resources](https://komo.do/docs/sync-resources) for more information. */ resource_path?: string[]; /** * Enable "pushes" to the file, * which exports resources matching tags to single file. * - If using `files_on_host`, it is stored in the file_contents, which must point to a .toml file path (it will be created if it doesn't exist). * - If using `file_contents`, it is stored in the database. * When using this, "delete" mode is always enabled. */ managed?: boolean; /** * Whether sync should delete resources * not declared in the resource files */ delete?: boolean; /** * Whether sync should include resources. * Default: true */ include_resources: boolean; /** * When using `managed` resource sync, will only export resources * matching all of the given tags. If none, will match all resources. */ match_tags?: string[]; /** Whether sync should include variables. */ include_variables?: boolean; /** Whether sync should include user groups. */ include_user_groups?: boolean; /** * Whether sync should send alert when it enters Pending state. * Default: true */ pending_alert: boolean; /** Manage the file contents in the UI. */ file_contents?: string; } export type DiffData = /** Resource will be created */ { type: "Create"; data: { /** The name of resource to create */ name?: string; /** The proposed resource to create in TOML */ proposed: string; }; } | { type: "Update"; data: { /** The proposed TOML */ proposed: string; /** The current TOML */ current: string; }; } | { type: "Delete"; data: { /** The current TOML of the resource to delete */ current: string; }; }; export interface ResourceDiff { /** * The resource target. * The target id will be empty if "Create" ResourceDiffType. */ target: ResourceTarget; /** The data associated with the diff. */ data: DiffData; } export interface SyncDeployUpdate { /** Resources to deploy */ to_deploy: number; /** A readable log of all the changes to be applied */ log: string; } export interface SyncFileContents { /** The base resource path. */ resource_path?: string; /** The path of the file / error path relative to the resource path. */ path: string; /** The contents of the file */ contents: string; } export interface ResourceSyncInfo { /** Unix timestamp of last applied sync */ last_sync_ts?: I64; /** Short commit hash of last applied sync */ last_sync_hash?: string; /** Commit message of last applied sync */ last_sync_message?: string; /** The list of pending updates to resources */ resource_updates?: ResourceDiff[]; /** The list of pending updates to variables */ variable_updates?: DiffData[]; /** The list of pending updates to user groups */ user_group_updates?: DiffData[]; /** The list of pending deploys to resources. */ pending_deploy?: SyncDeployUpdate; /** If there is an error, it will be stored here */ pending_error?: string; /** The commit hash which produced these pending updates. */ pending_hash?: string; /** The commit message which produced these pending updates. */ pending_message?: string; /** The current sync files */ remote_contents?: SyncFileContents[]; /** Any read errors in files by path */ remote_errors?: SyncFileContents[]; } export type ResourceSync = Resource; export type GetResourceSyncResponse = ResourceSync; /** Current pending actions on the server. */ export interface ServerActionState { /** Server currently pruning networks */ pruning_networks: boolean; /** Server currently pruning containers */ pruning_containers: boolean; /** Server currently pruning images */ pruning_images: boolean; /** Server currently pruning volumes */ pruning_volumes: boolean; /** Server currently pruning docker builders */ pruning_builders: boolean; /** Server currently pruning builx cache */ pruning_buildx: boolean; /** Server currently pruning system */ pruning_system: boolean; /** Server currently starting containers. */ starting_containers: boolean; /** Server currently restarting containers. */ restarting_containers: boolean; /** Server currently pausing containers. */ pausing_containers: boolean; /** Server currently unpausing containers. */ unpausing_containers: boolean; /** Server currently stopping containers. */ stopping_containers: boolean; } export type GetServerActionStateResponse = ServerActionState; /** Server configuration. */ export interface ServerConfig { /** * The http address of the periphery client. * Default: http://localhost:8120 */ address: string; /** * The address to use with links for containers on the server. * If empty, will use the 'address' for links. */ external_address?: string; /** An optional region label */ region?: string; /** * Whether a server is enabled. * If a server is disabled, * you won't be able to perform any actions on it or see deployment's status. * Default: false */ enabled: boolean; /** * The timeout used to reach the server in seconds. * default: 2 */ timeout_seconds: I64; /** * An optional override passkey to use * to authenticate with periphery agent. * If this is empty, will use passkey in core config. */ passkey?: string; /** * Sometimes the system stats reports a mount path that is not desired. * Use this field to filter it out from the report. */ ignore_mounts?: string[]; /** * Whether to monitor any server stats beyond passing health check. * default: true */ stats_monitoring: boolean; /** * Whether to trigger 'docker image prune -a -f' every 24 hours. * default: true */ auto_prune: boolean; /** Configure quick links that are displayed in the resource header */ links?: string[]; /** Whether to send alerts about the servers reachability */ send_unreachable_alerts: boolean; /** Whether to send alerts about the servers CPU status */ send_cpu_alerts: boolean; /** Whether to send alerts about the servers MEM status */ send_mem_alerts: boolean; /** Whether to send alerts about the servers DISK status */ send_disk_alerts: boolean; /** Whether to send alerts about the servers version mismatch with core */ send_version_mismatch_alerts: boolean; /** The percentage threshhold which triggers WARNING state for CPU. */ cpu_warning: number; /** The percentage threshhold which triggers CRITICAL state for CPU. */ cpu_critical: number; /** The percentage threshhold which triggers WARNING state for MEM. */ mem_warning: number; /** The percentage threshhold which triggers CRITICAL state for MEM. */ mem_critical: number; /** The percentage threshhold which triggers WARNING state for DISK. */ disk_warning: number; /** The percentage threshhold which triggers CRITICAL state for DISK. */ disk_critical: number; /** Scheduled maintenance windows during which alerts will be suppressed. */ maintenance_windows?: MaintenanceWindow[]; } export type Server = Resource; export type GetServerResponse = Server; export interface StackActionState { pulling: boolean; deploying: boolean; starting: boolean; restarting: boolean; pausing: boolean; unpausing: boolean; stopping: boolean; destroying: boolean; } export type GetStackActionStateResponse = StackActionState; export type GetStackLogResponse = Log; export declare enum StackFileRequires { /** Diff requires service redeploy. */ Redeploy = "Redeploy", /** Diff requires service restart */ Restart = "Restart", /** Diff requires no action. Default. */ None = "None" } /** Configure additional file dependencies of the Stack. */ export interface StackFileDependency { /** Specify the file */ path: string; /** Specify specific service/s */ services?: string[]; /** Specify */ requires?: StackFileRequires; } /** The compose file configuration. */ export interface StackConfig { /** The server to deploy the stack on. */ server_id?: string; /** Configure quick links that are displayed in the resource header */ links?: string[]; /** * Optionally specify a custom project name for the stack. * If this is empty string, it will default to the stack name. * Used with `docker compose -p {project_name}`. * * Note. Can be used to import pre-existing stacks. */ project_name?: string; /** * Whether to automatically `compose pull` before redeploying stack. * Ensured latest images are deployed. * Will fail if the compose file specifies a locally build image. */ auto_pull: boolean; /** * Whether to `docker compose build` before `compose down` / `compose up`. * Combine with build_extra_args for custom behaviors. */ run_build?: boolean; /** Whether to poll for any updates to the images. */ poll_for_updates?: boolean; /** * Whether to automatically redeploy when * newer images are found. Will implicitly * enable `poll_for_updates`, you don't need to * enable both. */ auto_update?: boolean; /** * If auto update is enabled, Komodo will * by default only update the specific services * with image updates. If this parameter is set to true, * Komodo will redeploy the whole Stack (all services). */ auto_update_all_services?: boolean; /** Whether to run `docker compose down` before `compose up`. */ destroy_before_deploy?: boolean; /** Whether to skip secret interpolation into the stack environment variables. */ skip_secret_interp?: boolean; /** Choose a Komodo Repo (Resource) to source the compose files. */ linked_repo?: string; /** The git provider domain. Default: github.com */ git_provider: string; /** * Whether to use https to clone the repo (versus http). Default: true * * Note. Komodo does not currently support cloning repos via ssh. */ git_https: boolean; /** * The git account used to access private repos. * Passing empty string can only clone public repos. * * Note. A token for the account must be available in the core config or the builder server's periphery config * for the configured git provider. */ git_account?: string; /** * The repo used as the source of the build. * {namespace}/{repo_name} */ repo?: string; /** The branch of the repo. */ branch: string; /** Optionally set a specific commit hash. */ commit?: string; /** Optionally set a specific clone path */ clone_path?: string; /** * By default, the Stack will `git pull` the repo after it is first cloned. * If this option is enabled, the repo folder will be deleted and recloned instead. */ reclone?: boolean; /** Whether incoming webhooks actually trigger action. */ webhook_enabled: boolean; /** * Optionally provide an alternate webhook secret for this stack. * If its an empty string, use the default secret from the config. */ webhook_secret?: string; /** * By default, the Stack will `DeployStackIfChanged`. * If this option is enabled, will always run `DeployStack` without diffing. */ webhook_force_deploy?: boolean; /** * If this is checked, the stack will source the files on the host. * Use `run_directory` and `file_paths` to specify the path on the host. * This is useful for those who wish to setup their files on the host, * rather than defining the contents in UI or in a git repo. */ files_on_host?: boolean; /** Directory to change to (`cd`) before running `docker compose up -d`. */ run_directory?: string; /** * Add paths to compose files, relative to the run path. * If this is empty, will use file `compose.yaml`. */ file_paths?: string[]; /** * The name of the written environment file before `docker compose up`. * Relative to the run directory root. * Default: .env */ env_file_path: string; /** * Add additional env files to attach with `--env-file`. * Relative to the run directory root. * * Note. It is already included as an `additional_file`. * Don't add it again there. */ additional_env_files?: string[]; /** * Add additional config files either in repo or on host to track. * Can add any files associated with the stack to enable editing them in the UI. * Doing so will also include diffing these when deciding to deploy in `DeployStackIfChanged`. * Relative to the run directory. * * Note. If the config file is .env and should be included in compose command * using `--env-file`, add it to `additional_env_files` instead. */ config_files?: StackFileDependency[]; /** Whether to send StackStateChange alerts for this stack. */ send_alerts: boolean; /** Used with `registry_account` to login to a registry before docker compose up. */ registry_provider?: string; /** Used with `registry_provider` to login to a registry before docker compose up. */ registry_account?: string; /** The optional command to run before the Stack is deployed. */ pre_deploy?: SystemCommand; /** The optional command to run after the Stack is deployed. */ post_deploy?: SystemCommand; /** * The extra arguments to pass after `docker compose up -d`. * If empty, no extra arguments will be passed. */ extra_args?: string[]; /** * The extra arguments to pass after `docker compose build`. * If empty, no extra build arguments will be passed. * Only used if `run_build: true` */ build_extra_args?: string[]; /** * Ignore certain services declared in the compose file when checking * the stack status. For example, an init service might be exited, but the * stack should be healthy. This init service should be in `ignore_services` */ ignore_services?: string[]; /** * The contents of the file directly, for management in the UI. * If this is empty, it will fall back to checking git config for * repo based compose file. * Supports variable / secret interpolation. */ file_contents?: string; /** * The environment variables passed to the compose file. * They will be written to path defined in env_file_path, * which is given relative to the run directory. * * If it is empty, no file will be written. */ environment?: string; } export interface FileContents { /** The path to the file */ path: string; /** The contents of the file */ contents: string; } export interface StackServiceNames { /** The name of the service */ service_name: string; /** * Will either be the declared container_name in the compose file, * or a pattern to match auto named containers. * * Auto named containers are composed of three parts: * * 1. The name of the compose project (top level name field of compose file). * This defaults to the name of the parent folder of the compose file. * Komodo will always set it to be the name of the stack, but imported stacks * will have a different name. * 2. The service name * 3. The replica number * * Example: stacko-mongo-1. * * This stores only 1. and 2., ie stacko-mongo. * Containers will be matched via regex like `^container_name-?[0-9]*$`` */ container_name: string; /** The services image. */ image?: string; } /** * Same as [FileContents] with some extra * info specific to Stacks. */ export interface StackRemoteFileContents { /** The path to the file */ path: string; /** The contents of the file */ contents: string; /** * The services depending on this file, * or empty for global requirement (eg all compose files and env files). */ services?: string[]; /** Whether diff requires Redeploy / Restart / None */ requires?: StackFileRequires; } export interface StackInfo { /** * If any of the expected compose / additional files are missing in the repo, * they will be stored here. */ missing_files?: string[]; /** * The deployed project name. * This is updated whenever Komodo successfully deploys the stack. * If it is present, Komodo will use it for actions over other options, * to ensure control is maintained after changing the project name (there is no rename compose project api). */ deployed_project_name?: string; /** Deployed short commit hash, or null. Only for repo based stacks. */ deployed_hash?: string; /** Deployed commit message, or null. Only for repo based stacks */ deployed_message?: string; /** * The deployed compose / additional file contents. * This is updated whenever Komodo successfully deploys the stack. */ deployed_contents?: FileContents[]; /** * The deployed service names. * This is updated whenever it is empty, or deployed contents is updated. */ deployed_services?: StackServiceNames[]; /** * The output of `docker compose config`. * This is updated whenever Komodo successfully deploys the stack. */ deployed_config?: string; /** * The latest service names. * This is updated whenever the stack cache refreshes, using the latest file contents (either db defined or remote). */ latest_services?: StackServiceNames[]; /** * The remote compose / additional file contents, whether on host or in repo. * This is updated whenever Komodo refreshes the stack cache. * It will be empty if the file is defined directly in the stack config. */ remote_contents?: StackRemoteFileContents[]; /** If there was an error in getting the remote contents, it will be here. */ remote_errors?: FileContents[]; /** Latest commit hash, or null */ latest_hash?: string; /** Latest commit message, or null */ latest_message?: string; } export type Stack = Resource; export type GetStackResponse = Stack; /** System information of a server */ export interface SystemInformation { /** The system name */ name?: string; /** The system long os version */ os?: string; /** System's kernel version */ kernel?: string; /** Physical core count */ core_count?: number; /** System hostname based off DNS */ host_name?: string; /** The CPU's brand */ cpu_brand: string; /** Whether terminals are disabled on this Periphery server */ terminals_disabled: boolean; /** Whether container exec is disabled on this Periphery server */ container_exec_disabled: boolean; } export type GetSystemInformationResponse = SystemInformation; export interface SystemLoadAverage { /** 1m load average */ one: number; /** 5m load average */ five: number; /** 15m load average */ fifteen: number; } /** Info for a single disk mounted on the system. */ export interface SingleDiskUsage { /** The mount point of the disk */ mount: string; /** Detected file system */ file_system: string; /** Used portion of the disk in GB */ used_gb: number; /** Total size of the disk in GB */ total_gb: number; } export declare enum Timelength { /** `1-sec` */ OneSecond = "1-sec", /** `5-sec` */ FiveSeconds = "5-sec", /** `10-sec` */ TenSeconds = "10-sec", /** `15-sec` */ FifteenSeconds = "15-sec", /** `30-sec` */ ThirtySeconds = "30-sec", /** `1-min` */ OneMinute = "1-min", /** `2-min` */ TwoMinutes = "2-min", /** `5-min` */ FiveMinutes = "5-min", /** `10-min` */ TenMinutes = "10-min", /** `15-min` */ FifteenMinutes = "15-min", /** `30-min` */ ThirtyMinutes = "30-min", /** `1-hr` */ OneHour = "1-hr", /** `2-hr` */ TwoHours = "2-hr", /** `6-hr` */ SixHours = "6-hr", /** `8-hr` */ EightHours = "8-hr", /** `12-hr` */ TwelveHours = "12-hr", /** `1-day` */ OneDay = "1-day", /** `3-day` */ ThreeDay = "3-day", /** `1-wk` */ OneWeek = "1-wk", /** `2-wk` */ TwoWeeks = "2-wk", /** `30-day` */ ThirtyDays = "30-day" } /** Realtime system stats data. */ export interface SystemStats { /** Cpu usage percentage */ cpu_perc: number; /** Load average (1m, 5m, 15m) */ load_average?: SystemLoadAverage; /** * [1.15.9+] * Free memory in GB. * This is really the 'Free' memory, not the 'Available' memory. * It may be different than mem_total_gb - mem_used_gb. */ mem_free_gb?: number; /** Used memory in GB. 'Total' - 'Available' (not free) memory. */ mem_used_gb: number; /** Total memory in GB */ mem_total_gb: number; /** Breakdown of individual disks, ie their usages, sizes, and mount points */ disks: SingleDiskUsage[]; /** Network ingress usage in MB */ network_ingress_bytes?: number; /** Network egress usage in MB */ network_egress_bytes?: number; /** The rate the system stats are being polled from the system */ polling_rate: Timelength; /** Unix timestamp in milliseconds when stats were last polled */ refresh_ts: I64; /** Unix timestamp in milliseconds when disk list was last refreshed */ refresh_list_ts: I64; } export type GetSystemStatsResponse = SystemStats; export declare enum TagColor { LightSlate = "LightSlate", Slate = "Slate", DarkSlate = "DarkSlate", LightRed = "LightRed", Red = "Red", DarkRed = "DarkRed", LightOrange = "LightOrange", Orange = "Orange", DarkOrange = "DarkOrange", LightAmber = "LightAmber", Amber = "Amber", DarkAmber = "DarkAmber", LightYellow = "LightYellow", Yellow = "Yellow", DarkYellow = "DarkYellow", LightLime = "LightLime", Lime = "Lime", DarkLime = "DarkLime", LightGreen = "LightGreen", Green = "Green", DarkGreen = "DarkGreen", LightEmerald = "LightEmerald", Emerald = "Emerald", DarkEmerald = "DarkEmerald", LightTeal = "LightTeal", Teal = "Teal", DarkTeal = "DarkTeal", LightCyan = "LightCyan", Cyan = "Cyan", DarkCyan = "DarkCyan", LightSky = "LightSky", Sky = "Sky", DarkSky = "DarkSky", LightBlue = "LightBlue", Blue = "Blue", DarkBlue = "DarkBlue", LightIndigo = "LightIndigo", Indigo = "Indigo", DarkIndigo = "DarkIndigo", LightViolet = "LightViolet", Violet = "Violet", DarkViolet = "DarkViolet", LightPurple = "LightPurple", Purple = "Purple", DarkPurple = "DarkPurple", LightFuchsia = "LightFuchsia", Fuchsia = "Fuchsia", DarkFuchsia = "DarkFuchsia", LightPink = "LightPink", Pink = "Pink", DarkPink = "DarkPink", LightRose = "LightRose", Rose = "Rose", DarkRose = "DarkRose" } export interface Tag { /** * The Mongo ID of the tag. * This field is de/serialized from/to JSON as * `{ "_id": { "$oid": "..." }, ...(rest of serialized Tag) }` */ _id?: MongoId; name: string; owner?: string; /** Hex color code with alpha for UI display */ color?: TagColor; } export type GetTagResponse = Tag; export type GetUpdateResponse = Update; /** * Permission users at the group level. * * All users that are part of a group inherit the group's permissions. * A user can be a part of multiple groups. A user's permission on a particular resource * will be resolved to be the maximum permission level between the user's own permissions and * any groups they are a part of. */ export interface UserGroup { /** * The Mongo ID of the UserGroup. * This field is de/serialized from/to JSON as * `{ "_id": { "$oid": "..." }, ...(rest of serialized User) }` */ _id?: MongoId; /** A name for the user group */ name: string; /** Whether all users will implicitly have the permissions in this group. */ everyone?: boolean; /** User ids of group members */ users?: string[]; /** Give the user group elevated permissions on all resources of a certain type */ all?: Record; /** Unix time (ms) when user group last updated */ updated_at?: I64; } export type GetUserGroupResponse = UserGroup; export type GetUserResponse = User; export type GetVariableResponse = Variable; export declare enum ContainerStateStatusEnum { Running = "running", Created = "created", Paused = "paused", Restarting = "restarting", Exited = "exited", Removing = "removing", Dead = "dead", Empty = "" } export declare enum HealthStatusEnum { Empty = "", None = "none", Starting = "starting", Healthy = "healthy", Unhealthy = "unhealthy" } /** HealthcheckResult stores information about a single run of a healthcheck probe */ export interface HealthcheckResult { /** Date and time at which this check started in [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. */ Start?: string; /** Date and time at which this check ended in [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. */ End?: string; /** ExitCode meanings: - `0` healthy - `1` unhealthy - `2` reserved (considered unhealthy) - other values: error running probe */ ExitCode?: I64; /** Output from last check */ Output?: string; } /** Health stores information about the container's healthcheck results. */ export interface ContainerHealth { /** Status is one of `none`, `starting`, `healthy` or `unhealthy` - \"none\" Indicates there is no healthcheck - \"starting\" Starting indicates that the container is not yet ready - \"healthy\" Healthy indicates that the container is running correctly - \"unhealthy\" Unhealthy indicates that the container has a problem */ Status?: HealthStatusEnum; /** FailingStreak is the number of consecutive failures */ FailingStreak?: I64; /** Log contains the last few results (oldest first) */ Log?: HealthcheckResult[]; } /** ContainerState stores container's running state. It's part of ContainerJSONBase and will be returned by the \"inspect\" command. */ export interface ContainerState { /** String representation of the container state. Can be one of \"created\", \"running\", \"paused\", \"restarting\", \"removing\", \"exited\", or \"dead\". */ Status?: ContainerStateStatusEnum; /** Whether this container is running. Note that a running container can be _paused_. The `Running` and `Paused` booleans are not mutually exclusive: When pausing a container (on Linux), the freezer cgroup is used to suspend all processes in the container. Freezing the process requires the process to be running. As a result, paused containers are both `Running` _and_ `Paused`. Use the `Status` field instead to determine if a container's state is \"running\". */ Running?: boolean; /** Whether this container is paused. */ Paused?: boolean; /** Whether this container is restarting. */ Restarting?: boolean; /** Whether a process within this container has been killed because it ran out of memory since the container was last started. */ OOMKilled?: boolean; Dead?: boolean; /** The process ID of this container */ Pid?: I64; /** The last exit code of this container */ ExitCode?: I64; Error?: string; /** The time when this container was last started. */ StartedAt?: string; /** The time when this container last exited. */ FinishedAt?: string; Health?: ContainerHealth; } export type Usize = number; export interface ResourcesBlkioWeightDevice { Path?: string; Weight?: Usize; } export interface ThrottleDevice { /** Device path */ Path?: string; /** Rate */ Rate?: I64; } /** A device mapping between the host and container */ export interface DeviceMapping { PathOnHost?: string; PathInContainer?: string; CgroupPermissions?: string; } /** A request for devices to be sent to device drivers */ export interface DeviceRequest { Driver?: string; Count?: I64; DeviceIDs?: string[]; /** A list of capabilities; an OR list of AND lists of capabilities. */ Capabilities?: string[][]; /** Driver-specific options, specified as a key/value pairs. These options are passed directly to the driver. */ Options?: Record; } export interface ResourcesUlimits { /** Name of ulimit */ Name?: string; /** Soft limit */ Soft?: I64; /** Hard limit */ Hard?: I64; } /** The logging configuration for this container */ export interface HostConfigLogConfig { Type?: string; Config?: Record; } /** PortBinding represents a binding between a host IP address and a host port. */ export interface PortBinding { /** Host IP address that the container's port is mapped to. */ HostIp?: string; /** Host port number that the container's port is mapped to. */ HostPort?: string; } export declare enum RestartPolicyNameEnum { Empty = "", No = "no", Always = "always", UnlessStopped = "unless-stopped", OnFailure = "on-failure" } /** The behavior to apply when the container exits. The default is not to restart. An ever increasing delay (double the previous delay, starting at 100ms) is added before each restart to prevent flooding the server. */ export interface RestartPolicy { /** - Empty string means not to restart - `no` Do not automatically restart - `always` Always restart - `unless-stopped` Restart always except when the user has manually stopped the container - `on-failure` Restart only when the container exit code is non-zero */ Name?: RestartPolicyNameEnum; /** If `on-failure` is used, the number of times to retry before giving up. */ MaximumRetryCount?: I64; } export declare enum MountTypeEnum { Empty = "", Bind = "bind", Volume = "volume", Image = "image", Tmpfs = "tmpfs", Npipe = "npipe", Cluster = "cluster" } export declare enum MountBindOptionsPropagationEnum { Empty = "", Private = "private", Rprivate = "rprivate", Shared = "shared", Rshared = "rshared", Slave = "slave", Rslave = "rslave" } /** Optional configuration for the `bind` type. */ export interface MountBindOptions { /** A propagation mode with the value `[r]private`, `[r]shared`, or `[r]slave`. */ Propagation?: MountBindOptionsPropagationEnum; /** Disable recursive bind mount. */ NonRecursive?: boolean; /** Create mount point on host if missing */ CreateMountpoint?: boolean; /** Make the mount non-recursively read-only, but still leave the mount recursive (unless NonRecursive is set to `true` in conjunction). Addded in v1.44, before that version all read-only mounts were non-recursive by default. To match the previous behaviour this will default to `true` for clients on versions prior to v1.44. */ ReadOnlyNonRecursive?: boolean; /** Raise an error if the mount cannot be made recursively read-only. */ ReadOnlyForceRecursive?: boolean; } /** Map of driver specific options */ export interface MountVolumeOptionsDriverConfig { /** Name of the driver to use to create the volume. */ Name?: string; /** key/value map of driver specific options. */ Options?: Record; } /** Optional configuration for the `volume` type. */ export interface MountVolumeOptions { /** Populate volume with data from the target. */ NoCopy?: boolean; /** User-defined key/value metadata. */ Labels?: Record; DriverConfig?: MountVolumeOptionsDriverConfig; /** Source path inside the volume. Must be relative without any back traversals. */ Subpath?: string; } /** Optional configuration for the `tmpfs` type. */ export interface MountTmpfsOptions { /** The size for the tmpfs mount in bytes. */ SizeBytes?: I64; /** The permission mode for the tmpfs mount in an integer. */ Mode?: I64; } export interface ContainerMount { /** Container path. */ Target?: string; /** Mount source (e.g. a volume name, a host path). */ Source?: string; /** The mount type. Available types: - `bind` Mounts a file or directory from the host into the container. Must exist prior to creating the container. - `volume` Creates a volume with the given name and options (or uses a pre-existing volume with the same name and options). These are **not** removed when the container is removed. - `tmpfs` Create a tmpfs with the given options. The mount source cannot be specified for tmpfs. - `npipe` Mounts a named pipe from the host into the container. Must exist prior to creating the container. - `cluster` a Swarm cluster volume */ Type?: MountTypeEnum; /** Whether the mount should be read-only. */ ReadOnly?: boolean; /** The consistency requirement for the mount: `default`, `consistent`, `cached`, or `delegated`. */ Consistency?: string; BindOptions?: MountBindOptions; VolumeOptions?: MountVolumeOptions; TmpfsOptions?: MountTmpfsOptions; } export declare enum HostConfigCgroupnsModeEnum { Empty = "", Private = "private", Host = "host" } export declare enum HostConfigIsolationEnum { Empty = "", Default = "default", Process = "process", Hyperv = "hyperv" } /** Container configuration that depends on the host we are running on */ export interface HostConfig { /** An integer value representing this container's relative CPU weight versus other containers. */ CpuShares?: I64; /** Memory limit in bytes. */ Memory?: I64; /** Path to `cgroups` under which the container's `cgroup` is created. If the path is not absolute, the path is considered to be relative to the `cgroups` path of the init process. Cgroups are created if they do not already exist. */ CgroupParent?: string; /** Block IO weight (relative weight). */ BlkioWeight?: number; /** Block IO weight (relative device weight) in the form: ``` [{\"Path\": \"device_path\", \"Weight\": weight}] ``` */ BlkioWeightDevice?: ResourcesBlkioWeightDevice[]; /** Limit read rate (bytes per second) from a device, in the form: ``` [{\"Path\": \"device_path\", \"Rate\": rate}] ``` */ BlkioDeviceReadBps?: ThrottleDevice[]; /** Limit write rate (bytes per second) to a device, in the form: ``` [{\"Path\": \"device_path\", \"Rate\": rate}] ``` */ BlkioDeviceWriteBps?: ThrottleDevice[]; /** Limit read rate (IO per second) from a device, in the form: ``` [{\"Path\": \"device_path\", \"Rate\": rate}] ``` */ BlkioDeviceReadIOps?: ThrottleDevice[]; /** Limit write rate (IO per second) to a device, in the form: ``` [{\"Path\": \"device_path\", \"Rate\": rate}] ``` */ BlkioDeviceWriteIOps?: ThrottleDevice[]; /** The length of a CPU period in microseconds. */ CpuPeriod?: I64; /** Microseconds of CPU time that the container can get in a CPU period. */ CpuQuota?: I64; /** The length of a CPU real-time period in microseconds. Set to 0 to allocate no time allocated to real-time tasks. */ CpuRealtimePeriod?: I64; /** The length of a CPU real-time runtime in microseconds. Set to 0 to allocate no time allocated to real-time tasks. */ CpuRealtimeRuntime?: I64; /** CPUs in which to allow execution (e.g., `0-3`, `0,1`). */ CpusetCpus?: string; /** Memory nodes (MEMs) in which to allow execution (0-3, 0,1). Only effective on NUMA systems. */ CpusetMems?: string; /** A list of devices to add to the container. */ Devices?: DeviceMapping[]; /** a list of cgroup rules to apply to the container */ DeviceCgroupRules?: string[]; /** A list of requests for devices to be sent to device drivers. */ DeviceRequests?: DeviceRequest[]; /** Hard limit for kernel TCP buffer memory (in bytes). Depending on the OCI runtime in use, this option may be ignored. It is no longer supported by the default (runc) runtime. This field is omitted when empty. */ KernelMemoryTCP?: I64; /** Memory soft limit in bytes. */ MemoryReservation?: I64; /** Total memory limit (memory + swap). Set as `-1` to enable unlimited swap. */ MemorySwap?: I64; /** Tune a container's memory swappiness behavior. Accepts an integer between 0 and 100. */ MemorySwappiness?: I64; /** CPU quota in units of 10-9 CPUs. */ NanoCpus?: I64; /** Disable OOM Killer for the container. */ OomKillDisable?: boolean; /** Run an init inside the container that forwards signals and reaps processes. This field is omitted if empty, and the default (as configured on the daemon) is used. */ Init?: boolean; /** Tune a container's PIDs limit. Set `0` or `-1` for unlimited, or `null` to not change. */ PidsLimit?: I64; /** A list of resource limits to set in the container. For example: ``` {\"Name\": \"nofile\", \"Soft\": 1024, \"Hard\": 2048} ``` */ Ulimits?: ResourcesUlimits[]; /** The number of usable CPUs (Windows only). On Windows Server containers, the processor resource controls are mutually exclusive. The order of precedence is `CPUCount` first, then `CPUShares`, and `CPUPercent` last. */ CpuCount?: I64; /** The usable percentage of the available CPUs (Windows only). On Windows Server containers, the processor resource controls are mutually exclusive. The order of precedence is `CPUCount` first, then `CPUShares`, and `CPUPercent` last. */ CpuPercent?: I64; /** Maximum IOps for the container system drive (Windows only) */ IOMaximumIOps?: I64; /** Maximum IO in bytes per second for the container system drive (Windows only). */ IOMaximumBandwidth?: I64; /** A list of volume bindings for this container. Each volume binding is a string in one of these forms: - `host-src:container-dest[:options]` to bind-mount a host path into the container. Both `host-src`, and `container-dest` must be an _absolute_ path. - `volume-name:container-dest[:options]` to bind-mount a volume managed by a volume driver into the container. `container-dest` must be an _absolute_ path. `options` is an optional, comma-delimited list of: - `nocopy` disables automatic copying of data from the container path to the volume. The `nocopy` flag only applies to named volumes. - `[ro|rw]` mounts a volume read-only or read-write, respectively. If omitted or set to `rw`, volumes are mounted read-write. - `[z|Z]` applies SELinux labels to allow or deny multiple containers to read and write to the same volume. - `z`: a _shared_ content label is applied to the content. This label indicates that multiple containers can share the volume content, for both reading and writing. - `Z`: a _private unshared_ label is applied to the content. This label indicates that only the current container can use a private volume. Labeling systems such as SELinux require proper labels to be placed on volume content that is mounted into a container. Without a label, the security system can prevent a container's processes from using the content. By default, the labels set by the host operating system are not modified. - `[[r]shared|[r]slave|[r]private]` specifies mount [propagation behavior](https://www.kernel.org/doc/Documentation/filesystems/sharedsubtree.txt). This only applies to bind-mounted volumes, not internal volumes or named volumes. Mount propagation requires the source mount point (the location where the source directory is mounted in the host operating system) to have the correct propagation properties. For shared volumes, the source mount point must be set to `shared`. For slave volumes, the mount must be set to either `shared` or `slave`. */ Binds?: string[]; /** Path to a file where the container ID is written */ ContainerIDFile?: string; LogConfig?: HostConfigLogConfig; /** Network mode to use for this container. Supported standard values are: `bridge`, `host`, `none`, and `container:`. Any other value is taken as a custom network's name to which this container should connect to. */ NetworkMode?: string; PortBindings?: Record; RestartPolicy?: RestartPolicy; /** Automatically remove the container when the container's process exits. This has no effect if `RestartPolicy` is set. */ AutoRemove?: boolean; /** Driver that this container uses to mount volumes. */ VolumeDriver?: string; /** A list of volumes to inherit from another container, specified in the form `[:]`. */ VolumesFrom?: string[]; /** Specification for mounts to be added to the container. */ Mounts?: ContainerMount[]; /** Initial console size, as an `[height, width]` array. */ ConsoleSize?: number[]; /** Arbitrary non-identifying metadata attached to container and provided to the runtime when the container is started. */ Annotations?: Record; /** A list of kernel capabilities to add to the container. Conflicts with option 'Capabilities'. */ CapAdd?: string[]; /** A list of kernel capabilities to drop from the container. Conflicts with option 'Capabilities'. */ CapDrop?: string[]; /** cgroup namespace mode for the container. Possible values are: - `\"private\"`: the container runs in its own private cgroup namespace - `\"host\"`: use the host system's cgroup namespace If not specified, the daemon default is used, which can either be `\"private\"` or `\"host\"`, depending on daemon version, kernel support and configuration. */ CgroupnsMode?: HostConfigCgroupnsModeEnum; /** A list of DNS servers for the container to use. */ Dns?: string[]; /** A list of DNS options. */ DnsOptions?: string[]; /** A list of DNS search domains. */ DnsSearch?: string[]; /** A list of hostnames/IP mappings to add to the container's `/etc/hosts` file. Specified in the form `[\"hostname:IP\"]`. */ ExtraHosts?: string[]; /** A list of additional groups that the container process will run as. */ GroupAdd?: string[]; /** IPC sharing mode for the container. Possible values are: - `\"none\"`: own private IPC namespace, with /dev/shm not mounted - `\"private\"`: own private IPC namespace - `\"shareable\"`: own private IPC namespace, with a possibility to share it with other containers - `\"container:\"`: join another (shareable) container's IPC namespace - `\"host\"`: use the host system's IPC namespace If not specified, daemon default is used, which can either be `\"private\"` or `\"shareable\"`, depending on daemon version and configuration. */ IpcMode?: string; /** Cgroup to use for the container. */ Cgroup?: string; /** A list of links for the container in the form `container_name:alias`. */ Links?: string[]; /** An integer value containing the score given to the container in order to tune OOM killer preferences. */ OomScoreAdj?: I64; /** Set the PID (Process) Namespace mode for the container. It can be either: - `\"container:\"`: joins another container's PID namespace - `\"host\"`: use the host's PID namespace inside the container */ PidMode?: string; /** Gives the container full access to the host. */ Privileged?: boolean; /** Allocates an ephemeral host port for all of a container's exposed ports. Ports are de-allocated when the container stops and allocated when the container starts. The allocated port might be changed when restarting the container. The port is selected from the ephemeral port range that depends on the kernel. For example, on Linux the range is defined by `/proc/sys/net/ipv4/ip_local_port_range`. */ PublishAllPorts?: boolean; /** Mount the container's root filesystem as read only. */ ReadonlyRootfs?: boolean; /** A list of string values to customize labels for MLS systems, such as SELinux. */ SecurityOpt?: string[]; /** Storage driver options for this container, in the form `{\"size\": \"120G\"}`. */ StorageOpt?: Record; /** A map of container directories which should be replaced by tmpfs mounts, and their corresponding mount options. For example: ``` { \"/run\": \"rw,noexec,nosuid,size=65536k\" } ``` */ Tmpfs?: Record; /** UTS namespace to use for the container. */ UTSMode?: string; /** Sets the usernamespace mode for the container when usernamespace remapping option is enabled. */ UsernsMode?: string; /** Size of `/dev/shm` in bytes. If omitted, the system uses 64MB. */ ShmSize?: I64; /** A list of kernel parameters (sysctls) to set in the container. For example: ``` {\"net.ipv4.ip_forward\": \"1\"} ``` */ Sysctls?: Record; /** Runtime to use with this container. */ Runtime?: string; /** Isolation technology of the container. (Windows only) */ Isolation?: HostConfigIsolationEnum; /** The list of paths to be masked inside the container (this overrides the default set of paths). */ MaskedPaths?: string[]; /** The list of paths to be set as read-only inside the container (this overrides the default set of paths). */ ReadonlyPaths?: string[]; } /** Information about the storage driver used to store the container's and image's filesystem. */ export interface GraphDriverData { /** Name of the storage driver. */ Name?: string; /** Low-level storage metadata, provided as key/value pairs. This information is driver-specific, and depends on the storage-driver in use, and should be used for informational purposes only. */ Data?: Record; } /** MountPoint represents a mount point configuration inside the container. This is used for reporting the mountpoints in use by a container. */ export interface MountPoint { /** The mount type: - `bind` a mount of a file or directory from the host into the container. - `volume` a docker volume with the given `Name`. - `tmpfs` a `tmpfs`. - `npipe` a named pipe from the host into the container. - `cluster` a Swarm cluster volume */ Type?: MountTypeEnum; /** Name is the name reference to the underlying data defined by `Source` e.g., the volume name. */ Name?: string; /** Source location of the mount. For volumes, this contains the storage location of the volume (within `/var/lib/docker/volumes/`). For bind-mounts, and `npipe`, this contains the source (host) part of the bind-mount. For `tmpfs` mount points, this field is empty. */ Source?: string; /** Destination is the path relative to the container root (`/`) where the `Source` is mounted inside the container. */ Destination?: string; /** Driver is the volume driver used to create the volume (if it is a volume). */ Driver?: string; /** Mode is a comma separated list of options supplied by the user when creating the bind/volume mount. The default is platform-specific (`\"z\"` on Linux, empty on Windows). */ Mode?: string; /** Whether the mount is mounted writable (read-write). */ RW?: boolean; /** Propagation describes how mounts are propagated from the host into the mount point, and vice-versa. Refer to the [Linux kernel documentation](https://www.kernel.org/doc/Documentation/filesystems/sharedsubtree.txt) for details. This field is not used on Windows. */ Propagation?: string; } /** A test to perform to check that the container is healthy. */ export interface HealthConfig { /** The test to perform. Possible values are: - `[]` inherit healthcheck from image or parent image - `[\"NONE\"]` disable healthcheck - `[\"CMD\", args...]` exec arguments directly - `[\"CMD-SHELL\", command]` run command with system's default shell */ Test?: string[]; /** The time to wait between checks in nanoseconds. It should be 0 or at least 1000000 (1 ms). 0 means inherit. */ Interval?: I64; /** The time to wait before considering the check to have hung. It should be 0 or at least 1000000 (1 ms). 0 means inherit. */ Timeout?: I64; /** The number of consecutive failures needed to consider a container as unhealthy. 0 means inherit. */ Retries?: I64; /** Start period for the container to initialize before starting health-retries countdown in nanoseconds. It should be 0 or at least 1000000 (1 ms). 0 means inherit. */ StartPeriod?: I64; /** The time to wait between checks in nanoseconds during the start period. It should be 0 or at least 1000000 (1 ms). 0 means inherit. */ StartInterval?: I64; } /** Configuration for a container that is portable between hosts. When used as `ContainerConfig` field in an image, `ContainerConfig` is an optional field containing the configuration of the container that was last committed when creating the image. Previous versions of Docker builder used this field to store build cache, and it is not in active use anymore. */ export interface ContainerConfig { /** The hostname to use for the container, as a valid RFC 1123 hostname. */ Hostname?: string; /** The domain name to use for the container. */ Domainname?: string; /** The user that commands are run as inside the container. */ User?: string; /** Whether to attach to `stdin`. */ AttachStdin?: boolean; /** Whether to attach to `stdout`. */ AttachStdout?: boolean; /** Whether to attach to `stderr`. */ AttachStderr?: boolean; /** An object mapping ports to an empty object in the form: `{\"/\": {}}` */ ExposedPorts?: Record>; /** Attach standard streams to a TTY, including `stdin` if it is not closed. */ Tty?: boolean; /** Open `stdin` */ OpenStdin?: boolean; /** Close `stdin` after one attached client disconnects */ StdinOnce?: boolean; /** A list of environment variables to set inside the container in the form `[\"VAR=value\", ...]`. A variable without `=` is removed from the environment, rather than to have an empty value. */ Env?: string[]; /** Command to run specified as a string or an array of strings. */ Cmd?: string[]; Healthcheck?: HealthConfig; /** Command is already escaped (Windows only) */ ArgsEscaped?: boolean; /** The name (or reference) of the image to use when creating the container, or which was used when the container was created. */ Image?: string; /** An object mapping mount point paths inside the container to empty objects. */ Volumes?: Record>; /** The working directory for commands to run in. */ WorkingDir?: string; /** The entry point for the container as a string or an array of strings. If the array consists of exactly one empty string (`[\"\"]`) then the entry point is reset to system default (i.e., the entry point used by docker when there is no `ENTRYPOINT` instruction in the `Dockerfile`). */ Entrypoint?: string[]; /** Disable networking for the container. */ NetworkDisabled?: boolean; /** MAC address of the container. Deprecated: this field is deprecated in API v1.44 and up. Use EndpointSettings.MacAddress instead. */ MacAddress?: string; /** `ONBUILD` metadata that were defined in the image's `Dockerfile`. */ OnBuild?: string[]; /** User-defined key/value metadata. */ Labels?: Record; /** Signal to stop a container as a string or unsigned integer. */ StopSignal?: string; /** Timeout to stop a container in seconds. */ StopTimeout?: I64; /** Shell for when `RUN`, `CMD`, and `ENTRYPOINT` uses a shell. */ Shell?: string[]; } /** EndpointIPAMConfig represents an endpoint's IPAM configuration. */ export interface EndpointIpamConfig { IPv4Address?: string; IPv6Address?: string; LinkLocalIPs?: string[]; } /** Configuration for a network endpoint. */ export interface EndpointSettings { IPAMConfig?: EndpointIpamConfig; Links?: string[]; /** MAC address for the endpoint on this network. The network driver might ignore this parameter. */ MacAddress?: string; Aliases?: string[]; /** Unique ID of the network. */ NetworkID?: string; /** Unique ID for the service endpoint in a Sandbox. */ EndpointID?: string; /** Gateway address for this network. */ Gateway?: string; /** IPv4 address. */ IPAddress?: string; /** Mask length of the IPv4 address. */ IPPrefixLen?: I64; /** IPv6 gateway address. */ IPv6Gateway?: string; /** Global IPv6 address. */ GlobalIPv6Address?: string; /** Mask length of the global IPv6 address. */ GlobalIPv6PrefixLen?: I64; /** DriverOpts is a mapping of driver options and values. These options are passed directly to the driver and are driver specific. */ DriverOpts?: Record; /** List of all DNS names an endpoint has on a specific network. This list is based on the container name, network aliases, container short ID, and hostname. These DNS names are non-fully qualified but can contain several dots. You can get fully qualified DNS names by appending `.`. For instance, if container name is `my.ctr` and the network is named `testnet`, `DNSNames` will contain `my.ctr` and the FQDN will be `my.ctr.testnet`. */ DNSNames?: string[]; } /** NetworkSettings exposes the network settings in the API */ export interface NetworkSettings { /** Name of the default bridge interface when dockerd's --bridge flag is set. */ Bridge?: string; /** SandboxID uniquely represents a container's network stack. */ SandboxID?: string; Ports?: Record; /** SandboxKey is the full path of the netns handle */ SandboxKey?: string; /** Information about all networks that the container is connected to. */ Networks?: Record; } export interface Container { /** The ID of the container */ Id?: string; /** The time the container was created */ Created?: string; /** The path to the command being run */ Path?: string; /** The arguments to the command being run */ Args?: string[]; State?: ContainerState; /** The container's image ID */ Image?: string; ResolvConfPath?: string; HostnamePath?: string; HostsPath?: string; LogPath?: string; Name?: string; RestartCount?: I64; Driver?: string; Platform?: string; MountLabel?: string; ProcessLabel?: string; AppArmorProfile?: string; /** IDs of exec instances that are running in the container. */ ExecIDs?: string[]; HostConfig?: HostConfig; GraphDriver?: GraphDriverData; /** The size of files that have been created or changed by this container. */ SizeRw?: I64; /** The total size of all the files in this container. */ SizeRootFs?: I64; Mounts?: MountPoint[]; Config?: ContainerConfig; NetworkSettings?: NetworkSettings; } export type InspectDeploymentContainerResponse = Container; export type InspectDockerContainerResponse = Container; /** Information about the image's RootFS, including the layer IDs. */ export interface ImageInspectRootFs { Type?: string; Layers?: string[]; } /** Additional metadata of the image in the local cache. This information is local to the daemon, and not part of the image itself. */ export interface ImageInspectMetadata { /** Date and time at which the image was last tagged in [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. This information is only available if the image was tagged locally, and omitted otherwise. */ LastTagTime?: string; } /** Information about an image in the local image cache. */ export interface Image { /** ID is the content-addressable ID of an image. This identifier is a content-addressable digest calculated from the image's configuration (which includes the digests of layers used by the image). Note that this digest differs from the `RepoDigests` below, which holds digests of image manifests that reference the image. */ Id?: string; /** List of image names/tags in the local image cache that reference this image. Multiple image tags can refer to the same image, and this list may be empty if no tags reference the image, in which case the image is \"untagged\", in which case it can still be referenced by its ID. */ RepoTags?: string[]; /** List of content-addressable digests of locally available image manifests that the image is referenced from. Multiple manifests can refer to the same image. These digests are usually only available if the image was either pulled from a registry, or if the image was pushed to a registry, which is when the manifest is generated and its digest calculated. */ RepoDigests?: string[]; /** ID of the parent image. Depending on how the image was created, this field may be empty and is only set for images that were built/created locally. This field is empty if the image was pulled from an image registry. */ Parent?: string; /** Optional message that was set when committing or importing the image. */ Comment?: string; /** Date and time at which the image was created, formatted in [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format with nano-seconds. This information is only available if present in the image, and omitted otherwise. */ Created?: string; /** The version of Docker that was used to build the image. Depending on how the image was created, this field may be empty. */ DockerVersion?: string; /** Name of the author that was specified when committing the image, or as specified through MAINTAINER (deprecated) in the Dockerfile. */ Author?: string; /** Configuration for a container that is portable between hosts. */ Config?: ContainerConfig; /** Hardware CPU architecture that the image runs on. */ Architecture?: string; /** CPU architecture variant (presently ARM-only). */ Variant?: string; /** Operating System the image is built to run on. */ Os?: string; /** Operating System version the image is built to run on (especially for Windows). */ OsVersion?: string; /** Total size of the image including all layers it is composed of. */ Size?: I64; GraphDriver?: GraphDriverData; RootFS?: ImageInspectRootFs; Metadata?: ImageInspectMetadata; } export type InspectDockerImageResponse = Image; export interface IpamConfig { Subnet?: string; IPRange?: string; Gateway?: string; AuxiliaryAddresses: Record; } export interface Ipam { /** Name of the IPAM driver to use. */ Driver?: string; /** List of IPAM configuration options, specified as a map: ``` {\"Subnet\": , \"IPRange\": , \"Gateway\": , \"AuxAddress\": } ``` */ Config: IpamConfig[]; /** Driver-specific options, specified as a map. */ Options: Record; } export interface NetworkContainer { /** This is the key on the incoming map of NetworkContainer */ ContainerID?: string; Name?: string; EndpointID?: string; MacAddress?: string; IPv4Address?: string; IPv6Address?: string; } export interface Network { Name?: string; Id?: string; Created?: string; Scope?: string; Driver?: string; EnableIPv6?: boolean; IPAM?: Ipam; Internal?: boolean; Attachable?: boolean; Ingress?: boolean; /** This field is turned from map into array for easier usability. */ Containers: NetworkContainer[]; Options?: Record; Labels?: Record; } export type InspectDockerNetworkResponse = Network; export declare enum VolumeScopeEnum { Empty = "", Local = "local", Global = "global" } export type U64 = number; /** The version number of the object such as node, service, etc. This is needed to avoid conflicting writes. The client must send the version number along with the modified specification when updating these objects. This approach ensures safe concurrency and determinism in that the change on the object may not be applied if the version number has changed from the last read. In other words, if two update requests specify the same base version, only one of the requests can succeed. As a result, two separate update requests that happen at the same time will not unintentionally overwrite each other. */ export interface ObjectVersion { Index?: U64; } export declare enum ClusterVolumeSpecAccessModeScopeEnum { Empty = "", Single = "single", Multi = "multi" } export declare enum ClusterVolumeSpecAccessModeSharingEnum { Empty = "", None = "none", Readonly = "readonly", Onewriter = "onewriter", All = "all" } /** One cluster volume secret entry. Defines a key-value pair that is passed to the plugin. */ export interface ClusterVolumeSpecAccessModeSecrets { /** Key is the name of the key of the key-value pair passed to the plugin. */ Key?: string; /** Secret is the swarm Secret object from which to read data. This can be a Secret name or ID. The Secret data is retrieved by swarm and used as the value of the key-value pair passed to the plugin. */ Secret?: string; } export type Topology = Record; /** Requirements for the accessible topology of the volume. These fields are optional. For an in-depth description of what these fields mean, see the CSI specification. */ export interface ClusterVolumeSpecAccessModeAccessibilityRequirements { /** A list of required topologies, at least one of which the volume must be accessible from. */ Requisite?: Topology[]; /** A list of topologies that the volume should attempt to be provisioned in. */ Preferred?: Topology[]; } /** The desired capacity that the volume should be created with. If empty, the plugin will decide the capacity. */ export interface ClusterVolumeSpecAccessModeCapacityRange { /** The volume must be at least this big. The value of 0 indicates an unspecified minimum */ RequiredBytes?: I64; /** The volume must not be bigger than this. The value of 0 indicates an unspecified maximum. */ LimitBytes?: I64; } export declare enum ClusterVolumeSpecAccessModeAvailabilityEnum { Empty = "", Active = "active", Pause = "pause", Drain = "drain" } /** Defines how the volume is used by tasks. */ export interface ClusterVolumeSpecAccessMode { /** The set of nodes this volume can be used on at one time. - `single` The volume may only be scheduled to one node at a time. - `multi` the volume may be scheduled to any supported number of nodes at a time. */ Scope?: ClusterVolumeSpecAccessModeScopeEnum; /** The number and way that different tasks can use this volume at one time. - `none` The volume may only be used by one task at a time. - `readonly` The volume may be used by any number of tasks, but they all must mount the volume as readonly - `onewriter` The volume may be used by any number of tasks, but only one may mount it as read/write. - `all` The volume may have any number of readers and writers. */ Sharing?: ClusterVolumeSpecAccessModeSharingEnum; /** Swarm Secrets that are passed to the CSI storage plugin when operating on this volume. */ Secrets?: ClusterVolumeSpecAccessModeSecrets[]; AccessibilityRequirements?: ClusterVolumeSpecAccessModeAccessibilityRequirements; CapacityRange?: ClusterVolumeSpecAccessModeCapacityRange; /** The availability of the volume for use in tasks. - `active` The volume is fully available for scheduling on the cluster - `pause` No new workloads should use the volume, but existing workloads are not stopped. - `drain` All workloads using this volume should be stopped and rescheduled, and no new ones should be started. */ Availability?: ClusterVolumeSpecAccessModeAvailabilityEnum; } /** Cluster-specific options used to create the volume. */ export interface ClusterVolumeSpec { /** Group defines the volume group of this volume. Volumes belonging to the same group can be referred to by group name when creating Services. Referring to a volume by group instructs Swarm to treat volumes in that group interchangeably for the purpose of scheduling. Volumes with an empty string for a group technically all belong to the same, emptystring group. */ Group?: string; AccessMode?: ClusterVolumeSpecAccessMode; } /** Information about the global status of the volume. */ export interface ClusterVolumeInfo { /** The capacity of the volume in bytes. A value of 0 indicates that the capacity is unknown. */ CapacityBytes?: I64; /** A map of strings to strings returned from the storage plugin when the volume is created. */ VolumeContext?: Record; /** The ID of the volume as returned by the CSI storage plugin. This is distinct from the volume's ID as provided by Docker. This ID is never used by the user when communicating with Docker to refer to this volume. If the ID is blank, then the Volume has not been successfully created in the plugin yet. */ VolumeID?: string; /** The topology this volume is actually accessible from. */ AccessibleTopology?: Topology[]; } export declare enum ClusterVolumePublishStatusStateEnum { Empty = "", PendingPublish = "pending-publish", Published = "published", PendingNodeUnpublish = "pending-node-unpublish", PendingControllerUnpublish = "pending-controller-unpublish" } export interface ClusterVolumePublishStatus { /** The ID of the Swarm node the volume is published on. */ NodeID?: string; /** The published state of the volume. * `pending-publish` The volume should be published to this node, but the call to the controller plugin to do so has not yet been successfully completed. * `published` The volume is published successfully to the node. * `pending-node-unpublish` The volume should be unpublished from the node, and the manager is awaiting confirmation from the worker that it has done so. * `pending-controller-unpublish` The volume is successfully unpublished from the node, but has not yet been successfully unpublished on the controller. */ State?: ClusterVolumePublishStatusStateEnum; /** A map of strings to strings returned by the CSI controller plugin when a volume is published. */ PublishContext?: Record; } /** Options and information specific to, and only present on, Swarm CSI cluster volumes. */ export interface ClusterVolume { /** The Swarm ID of this volume. Because cluster volumes are Swarm objects, they have an ID, unlike non-cluster volumes. This ID can be used to refer to the Volume instead of the name. */ ID?: string; Version?: ObjectVersion; CreatedAt?: string; UpdatedAt?: string; Spec?: ClusterVolumeSpec; Info?: ClusterVolumeInfo; /** The status of the volume as it pertains to its publishing and use on specific nodes */ PublishStatus?: ClusterVolumePublishStatus[]; } /** Usage details about the volume. This information is used by the `GET /system/df` endpoint, and omitted in other endpoints. */ export interface VolumeUsageData { /** Amount of disk space used by the volume (in bytes). This information is only available for volumes created with the `\"local\"` volume driver. For volumes created with other volume drivers, this field is set to `-1` (\"not available\") */ Size: I64; /** The number of containers referencing this volume. This field is set to `-1` if the reference-count is not available. */ RefCount: I64; } export interface Volume { /** Name of the volume. */ Name: string; /** Name of the volume driver used by the volume. */ Driver: string; /** Mount path of the volume on the host. */ Mountpoint: string; /** Date/Time the volume was created. */ CreatedAt?: string; /** Low-level details about the volume, provided by the volume driver. Details are returned as a map with key/value pairs: `{\"key\":\"value\",\"key2\":\"value2\"}`. The `Status` field is optional, and is omitted if the volume driver does not support this feature. */ Status?: Record>; /** User-defined key/value metadata. */ Labels?: Record; /** The level at which the volume exists. Either `global` for cluster-wide, or `local` for machine level. */ Scope?: VolumeScopeEnum; ClusterVolume?: ClusterVolume; /** The driver specific options used when creating the volume. */ Options?: Record; UsageData?: VolumeUsageData; } export type InspectDockerVolumeResponse = Volume; export type InspectStackContainerResponse = Container; export type JsonObject = any; export type JsonValue = any; export type ListActionsResponse = ActionListItem[]; export type ListAlertersResponse = AlerterListItem[]; export declare enum PortTypeEnum { EMPTY = "", TCP = "tcp", UDP = "udp", SCTP = "sctp" } /** An open port on a container */ export interface Port { /** Host IP address that the container's port is mapped to */ IP?: string; /** Port on the container */ PrivatePort?: number; /** Port exposed on the host */ PublicPort?: number; Type?: PortTypeEnum; } /** Container summary returned by container list apis. */ export interface ContainerListItem { /** The Server which holds the container. */ server_id?: string; /** The first name in Names, not including the initial '/' */ name: string; /** The ID of this container */ id?: string; /** The name of the image used when creating this container */ image?: string; /** The ID of the image that this container was created from */ image_id?: string; /** When the container was created */ created?: I64; /** The size of files that have been created or changed by this container */ size_rw?: I64; /** The total size of all the files in this container */ size_root_fs?: I64; /** The state of this container (e.g. `exited`) */ state: ContainerStateStatusEnum; /** Additional human-readable status of this container (e.g. `Exit 0`) */ status?: string; /** The network mode */ network_mode?: string; /** The network names attached to container */ networks?: string[]; /** Port mappings for the container */ ports?: Port[]; /** The volume names attached to container */ volumes?: string[]; /** The container stats, if they can be retreived. */ stats?: ContainerStats; /** * The labels attached to container. * It's too big to send with container list, * can get it using InspectContainer */ labels?: Record; } export type ListAllDockerContainersResponse = ContainerListItem[]; /** An api key used to authenticate requests via request headers. */ export interface ApiKey { /** Unique key associated with secret */ key: string; /** Hash of the secret */ secret: string; /** User associated with the api key */ user_id: string; /** Name associated with the api key for management */ name: string; /** Timestamp of key creation */ created_at: I64; /** Expiry of key, or 0 if never expires */ expires: I64; } export type ListApiKeysForServiceUserResponse = ApiKey[]; export type ListApiKeysResponse = ApiKey[]; export interface BuildVersionResponseItem { version: Version; ts: I64; } export type ListBuildVersionsResponse = BuildVersionResponseItem[]; export type ListBuildersResponse = BuilderListItem[]; export type ListBuildsResponse = BuildListItem[]; export type ListCommonBuildExtraArgsResponse = string[]; export type ListCommonDeploymentExtraArgsResponse = string[]; export type ListCommonStackBuildExtraArgsResponse = string[]; export type ListCommonStackExtraArgsResponse = string[]; export interface ComposeProject { /** The compose project name. */ name: string; /** The status of the project, as returned by docker. */ status?: string; /** The compose files included in the project. */ compose_files: string[]; } export type ListComposeProjectsResponse = ComposeProject[]; export type ListDeploymentsResponse = DeploymentListItem[]; export type ListDockerContainersResponse = ContainerListItem[]; /** individual image layer information in response to ImageHistory operation */ export interface ImageHistoryResponseItem { Id: string; Created: I64; CreatedBy: string; Tags?: string[]; Size: I64; Comment: string; } export type ListDockerImageHistoryResponse = ImageHistoryResponseItem[]; export interface ImageListItem { /** The first tag in `repo_tags`, or Id if no tags. */ name: string; /** ID is the content-addressable ID of an image. This identifier is a content-addressable digest calculated from the image's configuration (which includes the digests of layers used by the image). Note that this digest differs from the `RepoDigests` below, which holds digests of image manifests that reference the image. */ id: string; /** ID of the parent image. Depending on how the image was created, this field may be empty and is only set for images that were built/created locally. This field is empty if the image was pulled from an image registry. */ parent_id: string; /** Date and time at which the image was created as a Unix timestamp (number of seconds sinds EPOCH). */ created: I64; /** Total size of the image including all layers it is composed of. */ size: I64; /** Whether the image is in use by any container */ in_use: boolean; } export type ListDockerImagesResponse = ImageListItem[]; export interface NetworkListItem { name?: string; id?: string; created?: string; scope?: string; driver?: string; enable_ipv6?: boolean; ipam_driver?: string; ipam_subnet?: string; ipam_gateway?: string; internal?: boolean; attachable?: boolean; ingress?: boolean; /** Whether the network is attached to one or more containers */ in_use: boolean; } export type ListDockerNetworksResponse = NetworkListItem[]; export interface ProviderAccount { /** The account username. Required. */ username: string; /** The account access token. Required. */ token?: string; } export interface DockerRegistry { /** The docker provider domain. Default: `docker.io`. */ domain: string; /** The accounts on the registry. Required. */ accounts: ProviderAccount[]; /** * Available organizations on the registry provider. * Used to push an image under an organization's repo rather than an account's repo. */ organizations?: string[]; } export type ListDockerRegistriesFromConfigResponse = DockerRegistry[]; export type ListDockerRegistryAccountsResponse = DockerRegistryAccount[]; export interface VolumeListItem { /** The name of the volume */ name: string; driver: string; mountpoint: string; created?: string; scope: VolumeScopeEnum; /** Amount of disk space used by the volume (in bytes). This information is only available for volumes created with the `\"local\"` volume driver. For volumes created with other volume drivers, this field is set to `-1` (\"not available\") */ size?: I64; /** Whether the volume is currently attached to any container */ in_use: boolean; } export type ListDockerVolumesResponse = VolumeListItem[]; export type ListFullActionsResponse = Action[]; export type ListFullAlertersResponse = Alerter[]; export type ListFullBuildersResponse = Builder[]; export type ListFullBuildsResponse = Build[]; export type ListFullDeploymentsResponse = Deployment[]; export type ListFullProceduresResponse = Procedure[]; export type ListFullReposResponse = Repo[]; export type ListFullResourceSyncsResponse = ResourceSync[]; export type ListFullServersResponse = Server[]; export type ListFullStacksResponse = Stack[]; export type ListGitProviderAccountsResponse = GitProviderAccount[]; export interface GitProvider { /** The git provider domain. Default: `github.com`. */ domain: string; /** Whether to use https. Default: true. */ https: boolean; /** The accounts on the git provider. Required. */ accounts: ProviderAccount[]; } export type ListGitProvidersFromConfigResponse = GitProvider[]; export type UserTarget = /** User Id */ { type: "User"; id: string; } /** UserGroup Id */ | { type: "UserGroup"; id: string; }; /** Representation of a User or UserGroups permission on a resource. */ export interface Permission { /** The id of the permission document */ _id?: MongoId; /** The target User / UserGroup */ user_target: UserTarget; /** The target resource */ resource_target: ResourceTarget; /** The permission level for the [user_target] on the [resource_target]. */ level?: PermissionLevel; /** Any specific permissions for the [user_target] on the [resource_target]. */ specific?: Array; } export type ListPermissionsResponse = Permission[]; export declare enum ProcedureState { /** Currently running */ Running = "Running", /** Last run successful */ Ok = "Ok", /** Last run failed */ Failed = "Failed", /** Other case (never run) */ Unknown = "Unknown" } export interface ProcedureListItemInfo { /** Number of stages procedure has. */ stages: I64; /** Reflect whether last run successful / currently running. */ state: ProcedureState; /** Procedure last successful run timestamp in ms. */ last_run_at?: I64; /** * If the procedure has schedule enabled, this is the * next scheduled run time in unix ms. */ next_scheduled_run?: I64; /** * If there is an error parsing schedule expression, * it will be given here. */ schedule_error?: string; } export type ProcedureListItem = ResourceListItem; export type ListProceduresResponse = ProcedureListItem[]; export declare enum RepoState { /** Unknown case */ Unknown = "Unknown", /** Last clone / pull successful (or never cloned) */ Ok = "Ok", /** Last clone / pull failed */ Failed = "Failed", /** Currently cloning */ Cloning = "Cloning", /** Currently pulling */ Pulling = "Pulling", /** Currently building */ Building = "Building" } export interface RepoListItemInfo { /** The server that repo sits on. */ server_id: string; /** The builder that builds the repo. */ builder_id: string; /** Repo last cloned / pulled timestamp in ms. */ last_pulled_at: I64; /** Repo last built timestamp in ms. */ last_built_at: I64; /** The git provider domain */ git_provider: string; /** The configured repo */ repo: string; /** The configured branch */ branch: string; /** Full link to the repo. */ repo_link: string; /** The repo state */ state: RepoState; /** If the repo is cloned, will be the cloned short commit hash. */ cloned_hash?: string; /** If the repo is cloned, will be the cloned commit message. */ cloned_message?: string; /** If the repo is built, will be the latest built short commit hash. */ built_hash?: string; /** Will be the latest remote short commit hash. */ latest_hash?: string; } export type RepoListItem = ResourceListItem; export type ListReposResponse = RepoListItem[]; export declare enum ResourceSyncState { /** Currently syncing */ Syncing = "Syncing", /** Updates pending */ Pending = "Pending", /** Last sync successful (or never synced). No Changes pending */ Ok = "Ok", /** Last sync failed */ Failed = "Failed", /** Other case */ Unknown = "Unknown" } export interface ResourceSyncListItemInfo { /** Unix timestamp of last sync, or 0 */ last_sync_ts: I64; /** Whether sync is `files_on_host` mode. */ files_on_host: boolean; /** Whether sync has file contents defined. */ file_contents: boolean; /** Whether sync has `managed` mode enabled. */ managed: boolean; /** Resource paths to the files. */ resource_path: string[]; /** Linked repo, if one is attached. */ linked_repo: string; /** The git provider domain. */ git_provider: string; /** The Github repo used as the source of the sync resources */ repo: string; /** The branch of the repo */ branch: string; /** Full link to the repo. */ repo_link: string; /** Short commit hash of last sync, or empty string */ last_sync_hash?: string; /** Commit message of last sync, or empty string */ last_sync_message?: string; /** State of the sync. Reflects whether most recent sync successful. */ state: ResourceSyncState; } export type ResourceSyncListItem = ResourceListItem; export type ListResourceSyncsResponse = ResourceSyncListItem[]; /** A scheduled Action / Procedure run. */ export interface Schedule { /** Procedure or Alerter */ target: ResourceTarget; /** Readable name of the target resource */ name: string; /** The format of the schedule expression */ schedule_format: ScheduleFormat; /** The schedule for the run */ schedule: string; /** Whether the scheduled run is enabled */ enabled: boolean; /** Custom schedule timezone if it exists */ schedule_timezone: string; /** Last run timestamp in ms. */ last_run_at?: I64; /** Next scheduled run time in unix ms. */ next_scheduled_run?: I64; /** * If there is an error parsing schedule expression, * it will be given here. */ schedule_error?: string; /** Resource tags. */ tags: string[]; } export type ListSchedulesResponse = Schedule[]; export type ListSecretsResponse = string[]; export declare enum ServerState { /** Server health check passing. */ Ok = "Ok", /** Server is unreachable. */ NotOk = "NotOk", /** Server is disabled. */ Disabled = "Disabled" } export interface ServerListItemInfo { /** The server's state. */ state: ServerState; /** Region of the server. */ region: string; /** Address of the server. */ address: string; /** * External address of the server (reachable by users). * Used with links. */ external_address?: string; /** The Komodo Periphery version of the server. */ version: string; /** Whether server is configured to send unreachable alerts. */ send_unreachable_alerts: boolean; /** Whether server is configured to send cpu alerts. */ send_cpu_alerts: boolean; /** Whether server is configured to send mem alerts. */ send_mem_alerts: boolean; /** Whether server is configured to send disk alerts. */ send_disk_alerts: boolean; /** Whether server is configured to send version mismatch alerts. */ send_version_mismatch_alerts: boolean; /** Whether terminals are disabled for this Server. */ terminals_disabled: boolean; /** Whether container exec is disabled for this Server. */ container_exec_disabled: boolean; } export type ServerListItem = ResourceListItem; export type ListServersResponse = ServerListItem[]; export interface StackService { /** The service name */ service: string; /** The service image */ image: string; /** The container */ container?: ContainerListItem; /** Whether there is an update available for this services image. */ update_available: boolean; } export type ListStackServicesResponse = StackService[]; export declare enum StackState { /** The stack is currently re/deploying */ Deploying = "deploying", /** All containers are running. */ Running = "running", /** All containers are paused */ Paused = "paused", /** All contianers are stopped */ Stopped = "stopped", /** All containers are created */ Created = "created", /** All containers are restarting */ Restarting = "restarting", /** All containers are dead */ Dead = "dead", /** All containers are removing */ Removing = "removing", /** The containers are in a mix of states */ Unhealthy = "unhealthy", /** The stack is not deployed */ Down = "down", /** Server not reachable for status */ Unknown = "unknown" } export interface StackServiceWithUpdate { service: string; /** The service's image */ image: string; /** Whether there is a newer image available for this service */ update_available: boolean; } export interface StackListItemInfo { /** The server that stack is deployed on. */ server_id: string; /** Whether stack is using files on host mode */ files_on_host: boolean; /** Whether stack has file contents defined. */ file_contents: boolean; /** Linked repo, if one is attached. */ linked_repo: string; /** The git provider domain */ git_provider: string; /** The configured repo */ repo: string; /** The configured branch */ branch: string; /** Full link to the repo. */ repo_link: string; /** The stack state */ state: StackState; /** A string given by docker conveying the status of the stack. */ status?: string; /** * The services that are part of the stack. * If deployed, will be `deployed_services`. * Otherwise, its `latest_services` */ services: StackServiceWithUpdate[]; /** * Whether the compose project is missing on the host. * Ie, it does not show up in `docker compose ls`. * If true, and the stack is not Down, this is an unhealthy state. */ project_missing: boolean; /** * If any compose files are missing in the repo, the path will be here. * If there are paths here, this is an unhealthy state, and deploying will fail. */ missing_files: string[]; /** Deployed short commit hash, or null. Only for repo based stacks. */ deployed_hash?: string; /** Latest short commit hash, or null. Only for repo based stacks */ latest_hash?: string; } export type StackListItem = ResourceListItem; export type ListStacksResponse = StackListItem[]; /** Information about a process on the system. */ export interface SystemProcess { /** The process PID */ pid: number; /** The process name */ name: string; /** The path to the process executable */ exe?: string; /** The command used to start the process */ cmd: string[]; /** The time the process was started */ start_time?: number; /** * The cpu usage percentage of the process. * This is in core-percentage, eg 100% is 1 full core, and * an 8 core machine would max at 800%. */ cpu_perc: number; /** The memory usage of the process in MB */ mem_mb: number; /** Process disk read in KB/s */ disk_read_kb: number; /** Process disk write in KB/s */ disk_write_kb: number; } export type ListSystemProcessesResponse = SystemProcess[]; export type ListTagsResponse = Tag[]; /** * Info about an active terminal on a server. * Retrieve with [ListTerminals][crate::api::read::server::ListTerminals]. */ export interface TerminalInfo { /** The name of the terminal. */ name: string; /** The root program / args of the pty */ command: string; /** The size of the terminal history in memory. */ stored_size_kb: number; } export type ListTerminalsResponse = TerminalInfo[]; export type ListUserGroupsResponse = UserGroup[]; export type ListUserTargetPermissionsResponse = Permission[]; export type ListUsersResponse = User[]; export type ListVariablesResponse = Variable[]; /** The response for [LoginLocalUser] */ export type LoginLocalUserResponse = JwtResponse; export type MongoDocument = any; export interface ProcedureQuerySpecifics { } export type ProcedureQuery = ResourceQuery; export type PushRecentlyViewedResponse = NoData; export interface RepoQuerySpecifics { /** Filter repos by their repo. */ repos: string[]; } export type RepoQuery = ResourceQuery; export interface ResourceSyncQuerySpecifics { /** Filter syncs by their repo. */ repos: string[]; } export type ResourceSyncQuery = ResourceQuery; export type SearchContainerLogResponse = Log; export type SearchDeploymentLogResponse = Log; export type SearchStackLogResponse = Log; export interface ServerQuerySpecifics { } /** Server-specific query */ export type ServerQuery = ResourceQuery; export type SetLastSeenUpdateResponse = NoData; /** Response for [SignUpLocalUser]. */ export type SignUpLocalUserResponse = JwtResponse; export interface StackQuerySpecifics { /** * Query only for Stacks on these Servers. * If empty, does not filter by Server. * Only accepts Server id (not name). */ server_ids?: string[]; /** * Query only for Stacks with these linked repos. * Only accepts Repo id (not name). */ linked_repos?: string[]; /** Filter syncs by their repo. */ repos?: string[]; /** Query only for Stack with available image updates. */ update_available?: boolean; } export type StackQuery = ResourceQuery; export type UpdateDockerRegistryAccountResponse = DockerRegistryAccount; export type UpdateGitProviderAccountResponse = GitProviderAccount; export type UpdatePermissionOnResourceTypeResponse = NoData; export type UpdatePermissionOnTargetResponse = NoData; export type UpdateProcedureResponse = Procedure; export type UpdateResourceMetaResponse = NoData; export type UpdateServiceUserDescriptionResponse = User; export type UpdateUserAdminResponse = NoData; export type UpdateUserBasePermissionsResponse = NoData; export type UpdateUserPasswordResponse = NoData; export type UpdateUserUsernameResponse = NoData; export type UpdateVariableDescriptionResponse = Variable; export type UpdateVariableIsSecretResponse = Variable; export type UpdateVariableValueResponse = Variable; export type _PartialActionConfig = Partial; export type _PartialAlerterConfig = Partial; export type _PartialAwsBuilderConfig = Partial; export type _PartialBuildConfig = Partial; export type _PartialBuilderConfig = Partial; export type _PartialDeploymentConfig = Partial; export type _PartialDockerRegistryAccount = Partial; export type _PartialGitProviderAccount = Partial; export type _PartialProcedureConfig = Partial; export type _PartialRepoConfig = Partial; export type _PartialResourceSyncConfig = Partial; export type _PartialServerBuilderConfig = Partial; export type _PartialServerConfig = Partial; export type _PartialStackConfig = Partial; export type _PartialTag = Partial; export type _PartialUrlBuilderConfig = Partial; export interface __Serror { error: string; trace: string[]; } export type _Serror = __Serror; /** **Admin only.** Add a user to a user group. Response: [UserGroup] */ export interface AddUserToUserGroup { /** The name or id of UserGroup that user should be added to. */ user_group: string; /** The id or username of the user to add */ user: string; } /** Configuration for an AWS builder. */ export interface AwsBuilderConfig { /** The AWS region to create the instance in */ region: string; /** The instance type to create for the build */ instance_type: string; /** The size of the builder volume in gb */ volume_gb: number; /** * The port periphery will be running on. * Default: `8120` */ port: number; use_https: boolean; /** * The EC2 ami id to create. * The ami should have the periphery client configured to start on startup, * and should have the necessary github / dockerhub accounts configured. */ ami_id?: string; /** The subnet id to create the instance in. */ subnet_id?: string; /** The key pair name to attach to the instance */ key_pair_name?: string; /** * Whether to assign the instance a public IP address. * Likely needed for the instance to be able to reach the open internet. */ assign_public_ip?: boolean; /** * Whether core should use the public IP address to communicate with periphery on the builder. * If false, core will communicate with the instance using the private IP. */ use_public_ip?: boolean; /** * The security group ids to attach to the instance. * This should include a security group to allow core inbound access to the periphery port. */ security_group_ids?: string[]; /** The user data to deploy the instance with. */ user_data?: string; /** Which git providers are available on the AMI */ git_providers?: GitProvider[]; /** Which docker registries are available on the AMI. */ docker_registries?: DockerRegistry[]; /** Which secrets are available on the AMI. */ secrets?: string[]; } /** * Backs up the Komodo Core database to compressed jsonl files. * Admin only. Response: [Update] * * Mount a folder to `/backups`, and Core will use it to create * timestamped database dumps, which can be restored using * the Komodo CLI. * * https://komo.do/docs/setup/backup */ export interface BackupCoreDatabase { } /** Builds multiple Repos in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchBuildRepo { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* repos * foo-* * # add some more * extra-repo-1, extra-repo-2 * ``` */ pattern: string; } /** Clones multiple Repos in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchCloneRepo { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* repos * foo-* * # add some more * extra-repo-1, extra-repo-2 * ``` */ pattern: string; } /** Deploys multiple Deployments in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchDeploy { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* deployments * foo-* * # add some more * extra-deployment-1, extra-deployment-2 * ``` */ pattern: string; } /** Deploys multiple Stacks in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchDeployStack { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* stacks * foo-* * # add some more * extra-stack-1, extra-stack-2 * ``` */ pattern: string; } /** Deploys multiple Stacks if changed in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchDeployStackIfChanged { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* stacks * foo-* * # add some more * extra-stack-1, extra-stack-2 * ``` */ pattern: string; } /** Destroys multiple Deployments in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchDestroyDeployment { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* deployments * foo-* * # add some more * extra-deployment-1, extra-deployment-2 * ``` */ pattern: string; } /** Destroys multiple Stacks in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchDestroyStack { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * d * Example: * ```text * # match all foo-* stacks * foo-* * # add some more * extra-stack-1, extra-stack-2 * ``` */ pattern: string; } export interface BatchExecutionResponseItemErr { name: string; error: _Serror; } /** Pulls multiple Repos in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchPullRepo { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* repos * foo-* * # add some more * extra-repo-1, extra-repo-2 * ``` */ pattern: string; } /** Pulls multiple Stacks in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchPullStack { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* stacks * foo-* * # add some more * extra-stack-1, extra-stack-2 * ``` */ pattern: string; } /** Runs multiple Actions in parallel that match pattern. Response: [BatchExecutionResponse] */ export interface BatchRunAction { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* actions * foo-* * # add some more * extra-action-1, extra-action-2 * ``` */ pattern: string; } /** Runs multiple builds in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchRunBuild { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* builds * foo-* * # add some more * extra-build-1, extra-build-2 * ``` */ pattern: string; } /** Runs multiple Procedures in parallel that match pattern. Response: [BatchExecutionResponse]. */ export interface BatchRunProcedure { /** * Id or name or wildcard pattern or regex. * Supports multiline and comma delineated combinations of the above. * * Example: * ```text * # match all foo-* procedures * foo-* * # add some more * extra-procedure-1, extra-procedure-2 * ``` */ pattern: string; } /** * Builds the target repo, using the attached builder. Response: [Update]. * * Note. Repo must have builder attached at `builder_id`. * * 1. Spawns the target builder instance (For AWS type. For Server type, just use CloneRepo). * 2. Clones the repo on the builder using `git clone https://{$token?}@github.com/${repo} -b ${branch}`. * The token will only be used if a github account is specified, * and must be declared in the periphery configuration on the builder instance. * 3. If `on_clone` and `on_pull` are specified, they will be executed. * `on_clone` will be executed before `on_pull`. */ export interface BuildRepo { /** Id or name */ repo: string; } /** Item in [GetBuildMonthlyStatsResponse] */ export interface BuildStatsDay { time: number; count: number; ts: number; } /** * Cancels the target build. * Only does anything if the build is `building` when called. * Response: [Update] */ export interface CancelBuild { /** Can be id or name */ build: string; } /** * Cancels the target repo build. * Only does anything if the repo build is `building` when called. * Response: [Update] */ export interface CancelRepoBuild { /** Can be id or name */ repo: string; } /** * Clears all repos from the Core repo cache. Admin only. * Response: [Update] */ export interface ClearRepoCache { } /** * Clones the target repo. Response: [Update]. * * Note. Repo must have server attached at `server_id`. * * 1. Clones the repo on the target server using `git clone https://{$token?}@github.com/${repo} -b ${branch}`. * The token will only be used if a github account is specified, * and must be declared in the periphery configuration on the target server. * 2. If `on_clone` and `on_pull` are specified, they will be executed. * `on_clone` will be executed before `on_pull`. */ export interface CloneRepo { /** Id or name */ repo: string; } /** * Exports matching resources, and writes to the target sync's resource file. Response: [Update] * * Note. Will fail if the Sync is not `managed`. */ export interface CommitSync { /** Id or name */ sync: string; } /** * Query to connect to a container exec session (interactive shell over websocket) on the given server. * TODO: Document calling. */ export interface ConnectContainerExecQuery { /** Server Id or name */ server: string; /** The container name */ container: string; /** The shell to use (eg. `sh` or `bash`) */ shell: string; } /** * Query to connect to a container exec session (interactive shell over websocket) on the given Deployment. * This call will use access to the Deployment Terminal to permission the call. * TODO: Document calling. */ export interface ConnectDeploymentExecQuery { /** Deployment Id or name */ deployment: string; /** The shell to use (eg. `sh` or `bash`) */ shell: string; } /** * Query to connect to a container exec session (interactive shell over websocket) on the given Stack / service. * This call will use access to the Stack Terminal to permission the call. * TODO: Document calling. */ export interface ConnectStackExecQuery { /** Stack Id or name */ stack: string; /** The service name to connect to */ service: string; /** The shell to use (eg. `sh` or `bash`) */ shell: string; } /** * Query to connect to a terminal (interactive shell over websocket) on the given server. * TODO: Document calling. */ export interface ConnectTerminalQuery { /** Server Id or name */ server: string; /** * Each periphery can keep multiple terminals open. * If a terminals with the specified name does not exist, * the call will fail. * Create a terminal using [CreateTerminal][super::write::server::CreateTerminal] */ terminal: string; } /** Blkio stats entry. This type is Linux-specific and omitted for Windows containers. */ export interface ContainerBlkioStatEntry { major?: U64; minor?: U64; op?: string; value?: U64; } /** * BlkioStats stores all IO service stats for data read and write. * This type is Linux-specific and holds many fields that are specific to cgroups v1. * On a cgroup v2 host, all fields other than `io_service_bytes_recursive` are omitted or `null`. * This type is only populated on Linux and omitted for Windows containers. */ export interface ContainerBlkioStats { io_service_bytes_recursive?: ContainerBlkioStatEntry[]; /** * This field is only available when using Linux containers with cgroups v1. * It is omitted or `null` when using cgroups v2. */ io_serviced_recursive?: ContainerBlkioStatEntry[]; /** * This field is only available when using Linux containers with cgroups v1. * It is omitted or `null` when using cgroups v2. */ io_queue_recursive?: ContainerBlkioStatEntry[]; /** * This field is only available when using Linux containers with cgroups v1. * It is omitted or `null` when using cgroups v2. */ io_service_time_recursive?: ContainerBlkioStatEntry[]; /** * This field is only available when using Linux containers with cgroups v1. * It is omitted or `null` when using cgroups v2. */ io_wait_time_recursive?: ContainerBlkioStatEntry[]; /** * This field is only available when using Linux containers with cgroups v1. * It is omitted or `null` when using cgroups v2. */ io_merged_recursive?: ContainerBlkioStatEntry[]; /** * This field is only available when using Linux containers with cgroups v1. * It is omitted or `null` when using cgroups v2. */ io_time_recursive?: ContainerBlkioStatEntry[]; /** * This field is only available when using Linux containers with cgroups v1. * It is omitted or `null` when using cgroups v2. */ sectors_recursive?: ContainerBlkioStatEntry[]; } /** All CPU stats aggregated since container inception. */ export interface ContainerCpuUsage { /** Total CPU time consumed in nanoseconds (Linux) or 100's of nanoseconds (Windows). */ total_usage?: U64; /** * Total CPU time (in nanoseconds) consumed per core (Linux). * This field is Linux-specific when using cgroups v1. * It is omitted when using cgroups v2 and Windows containers. */ percpu_usage?: U64[]; /** * Time (in nanoseconds) spent by tasks of the cgroup in kernel mode (Linux), * or time spent (in 100's of nanoseconds) by all container processes in kernel mode (Windows). * Not populated for Windows containers using Hyper-V isolation. */ usage_in_kernelmode?: U64; /** * Time (in nanoseconds) spent by tasks of the cgroup in user mode (Linux), * or time spent (in 100's of nanoseconds) by all container processes in kernel mode (Windows). * Not populated for Windows containers using Hyper-V isolation. */ usage_in_usermode?: U64; } /** * CPU throttling stats of the container. * This type is Linux-specific and omitted for Windows containers. */ export interface ContainerThrottlingData { /** Number of periods with throttling active. */ periods?: U64; /** Number of periods when the container hit its throttling limit. */ throttled_periods?: U64; /** Aggregated time (in nanoseconds) the container was throttled for. */ throttled_time?: U64; } /** CPU related info of the container */ export interface ContainerCpuStats { /** All CPU stats aggregated since container inception. */ cpu_usage?: ContainerCpuUsage; /** * System Usage. * This field is Linux-specific and omitted for Windows containers. */ system_cpu_usage?: U64; /** * Number of online CPUs. * This field is Linux-specific and omitted for Windows containers. */ online_cpus?: number; /** * CPU throttling stats of the container. * This type is Linux-specific and omitted for Windows containers. */ throttling_data?: ContainerThrottlingData; } /** * Aggregates all memory stats since container inception on Linux. * Windows returns stats for commit and private working set only. */ export interface ContainerMemoryStats { /** * Current `res_counter` usage for memory. * This field is Linux-specific and omitted for Windows containers. */ usage?: U64; /** * Maximum usage ever recorded. * This field is Linux-specific and only supported on cgroups v1. * It is omitted when using cgroups v2 and for Windows containers. */ max_usage?: U64; /** * All the stats exported via memory.stat. when using cgroups v2. * This field is Linux-specific and omitted for Windows containers. */ stats?: Record; /** Number of times memory usage hits limits. This field is Linux-specific and only supported on cgroups v1. It is omitted when using cgroups v2 and for Windows containers. */ failcnt?: U64; /** This field is Linux-specific and omitted for Windows containers. */ limit?: U64; /** * Committed bytes. * This field is Windows-specific and omitted for Linux containers. */ commitbytes?: U64; /** * Peak committed bytes. * This field is Windows-specific and omitted for Linux containers. */ commitpeakbytes?: U64; /** * Private working set. * This field is Windows-specific and omitted for Linux containers. */ privateworkingset?: U64; } /** Aggregates the network stats of one container */ export interface ContainerNetworkStats { /** Bytes received. Windows and Linux. */ rx_bytes?: U64; /** Packets received. Windows and Linux. */ rx_packets?: U64; /** * Received errors. Not used on Windows. * This field is Linux-specific and always zero for Windows containers. */ rx_errors?: U64; /** Incoming packets dropped. Windows and Linux. */ rx_dropped?: U64; /** Bytes sent. Windows and Linux. */ tx_bytes?: U64; /** Packets sent. Windows and Linux. */ tx_packets?: U64; /** * Sent errors. Not used on Windows. * This field is Linux-specific and always zero for Windows containers. */ tx_errors?: U64; /** Outgoing packets dropped. Windows and Linux. */ tx_dropped?: U64; /** * Endpoint ID. Not used on Linux. * This field is Windows-specific and omitted for Linux containers. */ endpoint_id?: string; /** * Instance ID. Not used on Linux. * This field is Windows-specific and omitted for Linux containers. */ instance_id?: string; } /** PidsStats contains Linux-specific stats of a container's process-IDs (PIDs). This type is Linux-specific and omitted for Windows containers. */ export interface ContainerPidsStats { /** Current is the number of PIDs in the cgroup. */ current?: U64; /** Limit is the hard limit on the number of pids in the cgroup. A \"Limit\" of 0 means that there is no limit. */ limit?: U64; } /** * StorageStats is the disk I/O stats for read/write on Windows. * This type is Windows-specific and omitted for Linux containers. */ export interface ContainerStorageStats { read_count_normalized?: U64; read_size_bytes?: U64; write_count_normalized?: U64; write_size_bytes?: U64; } export interface Conversion { /** reference on the server. */ local: string; /** reference in the container. */ container: string; } /** * Creates a new action with given `name` and the configuration * of the action at the given `id`. Response: [Action]. */ export interface CopyAction { /** The name of the new action. */ name: string; /** The id of the action to copy. */ id: string; } /** * Creates a new alerter with given `name` and the configuration * of the alerter at the given `id`. Response: [Alerter]. */ export interface CopyAlerter { /** The name of the new alerter. */ name: string; /** The id of the alerter to copy. */ id: string; } /** * Creates a new build with given `name` and the configuration * of the build at the given `id`. Response: [Build]. */ export interface CopyBuild { /** The name of the new build. */ name: string; /** The id of the build to copy. */ id: string; } /** * Creates a new builder with given `name` and the configuration * of the builder at the given `id`. Response: [Builder] */ export interface CopyBuilder { /** The name of the new builder. */ name: string; /** The id of the builder to copy. */ id: string; } /** * Creates a new deployment with given `name` and the configuration * of the deployment at the given `id`. Response: [Deployment] */ export interface CopyDeployment { /** The name of the new deployment. */ name: string; /** The id of the deployment to copy. */ id: string; } /** * Creates a new procedure with given `name` and the configuration * of the procedure at the given `id`. Response: [Procedure]. */ export interface CopyProcedure { /** The name of the new procedure. */ name: string; /** The id of the procedure to copy. */ id: string; } /** * Creates a new repo with given `name` and the configuration * of the repo at the given `id`. Response: [Repo]. */ export interface CopyRepo { /** The name of the new repo. */ name: string; /** The id of the repo to copy. */ id: string; } /** * Creates a new sync with given `name` and the configuration * of the sync at the given `id`. Response: [ResourceSync]. */ export interface CopyResourceSync { /** The name of the new sync. */ name: string; /** The id of the sync to copy. */ id: string; } /** * Creates a new server with given `name` and the configuration * of the server at the given `id`. Response: [Server]. */ export interface CopyServer { /** The name of the new server. */ name: string; /** The id of the server to copy. */ id: string; } /** * Creates a new stack with given `name` and the configuration * of the stack at the given `id`. Response: [Stack]. */ export interface CopyStack { /** The name of the new stack. */ name: string; /** The id of the stack to copy. */ id: string; } /** Create a action. Response: [Action]. */ export interface CreateAction { /** The name given to newly created action. */ name: string; /** Optional partial config to initialize the action with. */ config?: _PartialActionConfig; } /** * Create a webhook on the github action attached to the Action resource. * passed in request. Response: [CreateActionWebhookResponse] */ export interface CreateActionWebhook { /** Id or name */ action: string; } /** Create an alerter. Response: [Alerter]. */ export interface CreateAlerter { /** The name given to newly created alerter. */ name: string; /** Optional partial config to initialize the alerter with. */ config?: _PartialAlerterConfig; } /** * Create an api key for the calling user. * Response: [CreateApiKeyResponse]. * * Note. After the response is served, there will be no way * to get the secret later. */ export interface CreateApiKey { /** The name for the api key. */ name: string; /** * A unix timestamp in millseconds specifying api key expire time. * Default is 0, which means no expiry. */ expires?: I64; } /** * Admin only method to create an api key for a service user. * Response: [CreateApiKeyResponse]. */ export interface CreateApiKeyForServiceUser { /** Must be service user */ user_id: string; /** The name for the api key */ name: string; /** * A unix timestamp in millseconds specifying api key expire time. * Default is 0, which means no expiry. */ expires?: I64; } /** Create a build. Response: [Build]. */ export interface CreateBuild { /** The name given to newly created build. */ name: string; /** Optional partial config to initialize the build with. */ config?: _PartialBuildConfig; } /** * Create a webhook on the github repo attached to the build * passed in request. Response: [CreateBuildWebhookResponse] */ export interface CreateBuildWebhook { /** Id or name */ build: string; } /** Partial representation of [BuilderConfig] */ export type PartialBuilderConfig = { type: "Url"; params: _PartialUrlBuilderConfig; } | { type: "Server"; params: _PartialServerBuilderConfig; } | { type: "Aws"; params: _PartialAwsBuilderConfig; }; /** Create a builder. Response: [Builder]. */ export interface CreateBuilder { /** The name given to newly created builder. */ name: string; /** Optional partial config to initialize the builder with. */ config?: PartialBuilderConfig; } /** Create a deployment. Response: [Deployment]. */ export interface CreateDeployment { /** The name given to newly created deployment. */ name: string; /** Optional partial config to initialize the deployment with. */ config?: _PartialDeploymentConfig; } /** Create a Deployment from an existing container. Response: [Deployment]. */ export interface CreateDeploymentFromContainer { /** The name or id of the existing container. */ name: string; /** The server id or name on which container exists. */ server: string; } /** * **Admin only.** Create a docker registry account. * Response: [DockerRegistryAccount]. */ export interface CreateDockerRegistryAccount { account: _PartialDockerRegistryAccount; } /** * **Admin only.** Create a git provider account. * Response: [GitProviderAccount]. */ export interface CreateGitProviderAccount { /** * The initial account config. Anything in the _id field will be ignored, * as this is generated on creation. */ account: _PartialGitProviderAccount; } /** * **Admin only.** Create a local user. * Response: [User]. * * Note. Not to be confused with /auth/SignUpLocalUser. * This method requires admin user credentials, and can * bypass disabled user registration. */ export interface CreateLocalUser { /** The username for the local user. */ username: string; /** A password for the local user. */ password: string; } /** * Create a docker network on the server. * Response: [Update] * * `docker network create {name}` */ export interface CreateNetwork { /** Server Id or name */ server: string; /** The name of the network to create. */ name: string; } /** Create a procedure. Response: [Procedure]. */ export interface CreateProcedure { /** The name given to newly created build. */ name: string; /** Optional partial config to initialize the procedure with. */ config?: _PartialProcedureConfig; } /** Create a repo. Response: [Repo]. */ export interface CreateRepo { /** The name given to newly created repo. */ name: string; /** Optional partial config to initialize the repo with. */ config?: _PartialRepoConfig; } export declare enum RepoWebhookAction { Clone = "Clone", Pull = "Pull", Build = "Build" } /** * Create a webhook on the github repo attached to the (Komodo) Repo resource. * passed in request. Response: [CreateRepoWebhookResponse] */ export interface CreateRepoWebhook { /** Id or name */ repo: string; /** "Clone" or "Pull" or "Build" */ action: RepoWebhookAction; } /** Create a sync. Response: [ResourceSync]. */ export interface CreateResourceSync { /** The name given to newly created sync. */ name: string; /** Optional partial config to initialize the sync with. */ config?: _PartialResourceSyncConfig; } /** Create a server. Response: [Server]. */ export interface CreateServer { /** The name given to newly created server. */ name: string; /** Optional partial config to initialize the server with. */ config?: _PartialServerConfig; } /** * **Admin only.** Create a service user. * Response: [User]. */ export interface CreateServiceUser { /** The username for the service user. */ username: string; /** A description for the service user. */ description: string; } /** Create a stack. Response: [Stack]. */ export interface CreateStack { /** The name given to newly created stack. */ name: string; /** Optional partial config to initialize the stack with. */ config?: _PartialStackConfig; } export declare enum StackWebhookAction { Refresh = "Refresh", Deploy = "Deploy" } /** * Create a webhook on the github repo attached to the stack * passed in request. Response: [CreateStackWebhookResponse] */ export interface CreateStackWebhook { /** Id or name */ stack: string; /** "Refresh" or "Deploy" */ action: StackWebhookAction; } export declare enum SyncWebhookAction { Refresh = "Refresh", Sync = "Sync" } /** * Create a webhook on the github repo attached to the sync * passed in request. Response: [CreateSyncWebhookResponse] */ export interface CreateSyncWebhook { /** Id or name */ sync: string; /** "Refresh" or "Sync" */ action: SyncWebhookAction; } /** Create a tag. Response: [Tag]. */ export interface CreateTag { /** The name of the tag. */ name: string; /** Tag color. Default: Slate. */ color?: TagColor; } /** * Configures the behavior of [CreateTerminal] if the * specified terminal name already exists. */ export declare enum TerminalRecreateMode { /** * Never kill the old terminal if it already exists. * If the command is different, returns error. */ Never = "Never", /** Always kill the old terminal and create new one */ Always = "Always", /** Only kill and recreate if the command is different. */ DifferentCommand = "DifferentCommand" } /** * Create a terminal on the server. * Response: [NoData] */ export interface CreateTerminal { /** Server Id or name */ server: string; /** The name of the terminal on the server to create. */ name: string; /** * The shell command (eg `bash`) to init the shell. * * This can also include args: * `docker exec -it container sh` * * Default: `bash` */ command: string; /** Default: `Never` */ recreate?: TerminalRecreateMode; } /** **Admin only.** Create a user group. Response: [UserGroup] */ export interface CreateUserGroup { /** The name to assign to the new UserGroup */ name: string; } /** **Admin only.** Create variable. Response: [Variable]. */ export interface CreateVariable { /** The name of the variable to create. */ name: string; /** The initial value of the variable. defualt: "". */ value?: string; /** The initial value of the description. default: "". */ description?: string; /** Whether to make this a secret variable. */ is_secret?: boolean; } /** Configuration for a Custom alerter endpoint. */ export interface CustomAlerterEndpoint { /** The http/s endpoint to send the POST to */ url: string; } /** * Deletes the action at the given id, and returns the deleted action. * Response: [Action] */ export interface DeleteAction { /** The id or name of the action to delete. */ id: string; } /** * Delete the webhook on the github action attached to the Action resource. * passed in request. Response: [DeleteActionWebhookResponse] */ export interface DeleteActionWebhook { /** Id or name */ action: string; } /** * Deletes the alerter at the given id, and returns the deleted alerter. * Response: [Alerter] */ export interface DeleteAlerter { /** The id or name of the alerter to delete. */ id: string; } /** * Delete all terminals on the server. * Response: [NoData] */ export interface DeleteAllTerminals { /** Server Id or name */ server: string; } /** * Delete an api key for the calling user. * Response: [NoData] */ export interface DeleteApiKey { /** The key which the user intends to delete. */ key: string; } /** * Admin only method to delete an api key for a service user. * Response: [NoData]. */ export interface DeleteApiKeyForServiceUser { key: string; } /** * Deletes the build at the given id, and returns the deleted build. * Response: [Build] */ export interface DeleteBuild { /** The id or name of the build to delete. */ id: string; } /** * Delete a webhook on the github repo attached to the build * passed in request. Response: [CreateBuildWebhookResponse] */ export interface DeleteBuildWebhook { /** Id or name */ build: string; } /** * Deletes the builder at the given id, and returns the deleted builder. * Response: [Builder] */ export interface DeleteBuilder { /** The id or name of the builder to delete. */ id: string; } /** * Deletes the deployment at the given id, and returns the deleted deployment. * Response: [Deployment]. * * Note. If the associated container is running, it will be deleted as part of * the deployment clean up. */ export interface DeleteDeployment { /** The id or name of the deployment to delete. */ id: string; } /** * **Admin only.** Delete a docker registry account. * Response: [DockerRegistryAccount]. */ export interface DeleteDockerRegistryAccount { /** The id of the docker registry account to delete */ id: string; } /** * **Admin only.** Delete a git provider account. * Response: [DeleteGitProviderAccountResponse]. */ export interface DeleteGitProviderAccount { /** The id of the git provider to delete */ id: string; } /** * Delete a docker image. * Response: [Update] */ export interface DeleteImage { /** Id or name. */ server: string; /** The name of the image to delete. */ name: string; } /** * Delete a docker network. * Response: [Update] */ export interface DeleteNetwork { /** Id or name. */ server: string; /** The name of the network to delete. */ name: string; } /** * Deletes the procedure at the given id, and returns the deleted procedure. * Response: [Procedure] */ export interface DeleteProcedure { /** The id or name of the procedure to delete. */ id: string; } /** * Deletes the repo at the given id, and returns the deleted repo. * Response: [Repo] */ export interface DeleteRepo { /** The id or name of the repo to delete. */ id: string; } /** * Delete the webhook on the github repo attached to the (Komodo) Repo resource. * passed in request. Response: [DeleteRepoWebhookResponse] */ export interface DeleteRepoWebhook { /** Id or name */ repo: string; /** "Clone" or "Pull" or "Build" */ action: RepoWebhookAction; } /** * Deletes the sync at the given id, and returns the deleted sync. * Response: [ResourceSync] */ export interface DeleteResourceSync { /** The id or name of the sync to delete. */ id: string; } /** * Deletes the server at the given id, and returns the deleted server. * Response: [Server] */ export interface DeleteServer { /** The id or name of the server to delete. */ id: string; } /** * Deletes the stack at the given id, and returns the deleted stack. * Response: [Stack] */ export interface DeleteStack { /** The id or name of the stack to delete. */ id: string; } /** * Delete the webhook on the github repo attached to the stack * passed in request. Response: [DeleteStackWebhookResponse] */ export interface DeleteStackWebhook { /** Id or name */ stack: string; /** "Refresh" or "Deploy" */ action: StackWebhookAction; } /** * Delete the webhook on the github repo attached to the sync * passed in request. Response: [DeleteSyncWebhookResponse] */ export interface DeleteSyncWebhook { /** Id or name */ sync: string; /** "Refresh" or "Sync" */ action: SyncWebhookAction; } /** * Delete a tag, and return the deleted tag. Response: [Tag]. * * Note. Will also remove this tag from all attached resources. */ export interface DeleteTag { /** The id of the tag to delete. */ id: string; } /** * Delete a terminal on the server. * Response: [NoData] */ export interface DeleteTerminal { /** Server Id or name */ server: string; /** The name of the terminal on the server to delete. */ terminal: string; } /** * **Admin only**. Delete a user. * Admins can delete any non-admin user. * Only Super Admin can delete an admin. * No users can delete a Super Admin user. * User cannot delete themselves. * Response: [NoData]. */ export interface DeleteUser { /** User id or username */ user: string; } /** **Admin only.** Delete a user group. Response: [UserGroup] */ export interface DeleteUserGroup { /** The id of the UserGroup */ id: string; } /** **Admin only.** Delete a variable. Response: [Variable]. */ export interface DeleteVariable { name: string; } /** * Delete a docker volume. * Response: [Update] */ export interface DeleteVolume { /** Id or name. */ server: string; /** The name of the volume to delete. */ name: string; } /** * Deploys the container for the target deployment. Response: [Update]. * * 1. Pulls the image onto the target server. * 2. If the container is already running, * it will be stopped and removed using `docker container rm ${container_name}`. * 3. The container will be run using `docker run {...params}`, * where params are determined by the deployment's configuration. */ export interface Deploy { /** Name or id */ deployment: string; /** * Override the default termination signal specified in the deployment. * Only used when deployment needs to be taken down before redeploy. */ stop_signal?: TerminationSignal; /** * Override the default termination max time. * Only used when deployment needs to be taken down before redeploy. */ stop_time?: number; } /** Deploys the target stack. `docker compose up`. Response: [Update] */ export interface DeployStack { /** Id or name */ stack: string; /** * Filter to only deploy specific services. * If empty, will deploy all services. */ services?: string[]; /** * Override the default termination max time. * Only used if the stack needs to be taken down first. */ stop_time?: number; } /** * Checks deployed contents vs latest contents, * and only if any changes found * will `docker compose up`. Response: [Update] */ export interface DeployStackIfChanged { /** Id or name */ stack: string; /** * Override the default termination max time. * Only used if the stack needs to be taken down first. */ stop_time?: number; } /** * Stops and destroys the container on the target server. * Reponse: [Update]. * * 1. The container is stopped and removed using `docker container rm ${container_name}`. */ export interface DestroyContainer { /** Name or id */ server: string; /** The container name */ container: string; /** Override the default termination signal. */ signal?: TerminationSignal; /** Override the default termination max time. */ time?: number; } /** * Stops and destroys the container for the target deployment. * Reponse: [Update]. * * 1. The container is stopped and removed using `docker container rm ${container_name}`. */ export interface DestroyDeployment { /** Name or id. */ deployment: string; /** Override the default termination signal specified in the deployment. */ signal?: TerminationSignal; /** Override the default termination max time. */ time?: number; } /** Destoys the target stack. `docker compose down`. Response: [Update] */ export interface DestroyStack { /** Id or name */ stack: string; /** * Filter to only destroy specific services. * If empty, will destroy all services. */ services?: string[]; /** Pass `--remove-orphans` */ remove_orphans?: boolean; /** Override the default termination max time. */ stop_time?: number; } /** Configuration for a Discord alerter. */ export interface DiscordAlerterEndpoint { /** The Discord webhook url */ url: string; } export interface EnvironmentVar { variable: string; value: string; } /** * Exchange a single use exchange token (safe for transport in url query) * for a jwt. * Response: [ExchangeForJwtResponse]. */ export interface ExchangeForJwt { /** The 'exchange token' */ token: string; } /** * Execute a command in the given containers shell. * TODO: Document calling. */ export interface ExecuteContainerExecBody { /** Server Id or name */ server: string; /** The container name */ container: string; /** The shell to use (eg. `sh` or `bash`) */ shell: string; /** The command to execute. */ command: string; } /** * Execute a command in the given containers shell. * TODO: Document calling. */ export interface ExecuteDeploymentExecBody { /** Deployment Id or name */ deployment: string; /** The shell to use (eg. `sh` or `bash`) */ shell: string; /** The command to execute. */ command: string; } /** * Execute a command in the given containers shell. * TODO: Document calling. */ export interface ExecuteStackExecBody { /** Stack Id or name */ stack: string; /** The service name to connect to */ service: string; /** The shell to use (eg. `sh` or `bash`) */ shell: string; /** The command to execute. */ command: string; } /** * Execute a terminal command on the given server. * TODO: Document calling. */ export interface ExecuteTerminalBody { /** Server Id or name */ server: string; /** * The name of the terminal on the server to use to execute. * If the terminal at name exists, it will be used to execute the command. * Otherwise, a new terminal will be created for this command, which will * persist until it exits or is deleted. */ terminal: string; /** The command to execute. */ command: string; } /** * Get pretty formatted monrun sync toml for all resources * which the user has permissions to view. * Response: [TomlResponse]. */ export interface ExportAllResourcesToToml { /** * Whether to include any resources (servers, stacks, etc.) * in the exported contents. * Default: `true` */ include_resources: boolean; /** * Filter resources by tag. * Accepts tag name or id. Empty array will not filter by tag. */ tags?: string[]; /** * Whether to include variables in the exported contents. * Default: false */ include_variables?: boolean; /** * Whether to include user groups in the exported contents. * Default: false */ include_user_groups?: boolean; } /** * Get pretty formatted monrun sync toml for specific resources and user groups. * Response: [TomlResponse]. */ export interface ExportResourcesToToml { /** The targets to include in the export. */ targets?: ResourceTarget[]; /** The user group names or ids to include in the export. */ user_groups?: string[]; /** Whether to include variables */ include_variables?: boolean; } /** * **Admin only.** * Find a user. * Response: [FindUserResponse] */ export interface FindUser { /** Id or username */ user: string; } /** Statistics sample for a container. */ export interface FullContainerStats { /** Name of the container */ name: string; /** ID of the container */ id?: string; /** * Date and time at which this sample was collected. * The value is formatted as [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) with nano-seconds. */ read?: string; /** * Date and time at which this first sample was collected. * This field is not propagated if the \"one-shot\" option is set. * If the \"one-shot\" option is set, this field may be omitted, empty, * or set to a default date (`0001-01-01T00:00:00Z`). * The value is formatted as [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) with nano-seconds. */ preread?: string; /** * PidsStats contains Linux-specific stats of a container's process-IDs (PIDs). * This type is Linux-specific and omitted for Windows containers. */ pids_stats?: ContainerPidsStats; /** * BlkioStats stores all IO service stats for data read and write. * This type is Linux-specific and holds many fields that are specific to cgroups v1. * On a cgroup v2 host, all fields other than `io_service_bytes_recursive` are omitted or `null`. * This type is only populated on Linux and omitted for Windows containers. */ blkio_stats?: ContainerBlkioStats; /** * The number of processors on the system. * This field is Windows-specific and always zero for Linux containers. */ num_procs?: number; storage_stats?: ContainerStorageStats; cpu_stats?: ContainerCpuStats; precpu_stats?: ContainerCpuStats; memory_stats?: ContainerMemoryStats; /** Network statistics for the container per interface. This field is omitted if the container has no networking enabled. */ networks?: Record; } /** Get a specific action. Response: [Action]. */ export interface GetAction { /** Id or name */ action: string; } /** Get current action state for the action. Response: [ActionActionState]. */ export interface GetActionActionState { /** Id or name */ action: string; } /** * Gets a summary of data relating to all actions. * Response: [GetActionsSummaryResponse]. */ export interface GetActionsSummary { } /** Response for [GetActionsSummary]. */ export interface GetActionsSummaryResponse { /** The total number of actions. */ total: number; /** The number of actions with Ok state. */ ok: number; /** The number of actions currently running. */ running: number; /** The number of actions with failed state. */ failed: number; /** The number of actions with unknown state. */ unknown: number; } /** Get an alert: Response: [Alert]. */ export interface GetAlert { id: string; } /** Get a specific alerter. Response: [Alerter]. */ export interface GetAlerter { /** Id or name */ alerter: string; } /** * Gets a summary of data relating to all alerters. * Response: [GetAlertersSummaryResponse]. */ export interface GetAlertersSummary { } /** Response for [GetAlertersSummary]. */ export interface GetAlertersSummaryResponse { total: number; } /** Get a specific build. Response: [Build]. */ export interface GetBuild { /** Id or name */ build: string; } /** Get current action state for the build. Response: [BuildActionState]. */ export interface GetBuildActionState { /** Id or name */ build: string; } /** * Gets summary and timeseries breakdown of the last months build count / time for charting. * Response: [GetBuildMonthlyStatsResponse]. * * Note. This method is paginated. One page is 30 days of data. * Query for older pages by incrementing the page, starting at 0. */ export interface GetBuildMonthlyStats { /** * Query for older data by incrementing the page. * `page: 0` is the default, and will return the most recent data. */ page?: number; } /** Response for [GetBuildMonthlyStats]. */ export interface GetBuildMonthlyStatsResponse { total_time: number; total_count: number; days: BuildStatsDay[]; } /** Get whether a Build's target repo has a webhook for the build configured. Response: [GetBuildWebhookEnabledResponse]. */ export interface GetBuildWebhookEnabled { /** Id or name */ build: string; } /** Response for [GetBuildWebhookEnabled] */ export interface GetBuildWebhookEnabledResponse { /** * Whether the repo webhooks can even be managed. * The repo owner must be in `github_webhook_app.owners` list to be managed. */ managed: boolean; /** Whether pushes to branch trigger build. Will always be false if managed is false. */ enabled: boolean; } /** Get a specific builder by id or name. Response: [Builder]. */ export interface GetBuilder { /** Id or name */ builder: string; } /** * Gets a summary of data relating to all builders. * Response: [GetBuildersSummaryResponse]. */ export interface GetBuildersSummary { } /** Response for [GetBuildersSummary]. */ export interface GetBuildersSummaryResponse { /** The total number of builders. */ total: number; } /** * Gets a summary of data relating to all builds. * Response: [GetBuildsSummaryResponse]. */ export interface GetBuildsSummary { } /** Response for [GetBuildsSummary]. */ export interface GetBuildsSummaryResponse { /** The total number of builds in Komodo. */ total: number; /** The number of builds with Ok state. */ ok: number; /** The number of builds with Failed state. */ failed: number; /** The number of builds currently building. */ building: number; /** The number of builds with unknown state. */ unknown: number; } /** * Get the container log's tail, split by stdout/stderr. * Response: [Log]. * * Note. This call will hit the underlying server directly for most up to date log. */ export interface GetContainerLog { /** Id or name */ server: string; /** The container name */ container: string; /** * The number of lines of the log tail to include. * Default: 100. * Max: 5000. */ tail: U64; /** Enable `--timestamps` */ timestamps?: boolean; } /** * Get info about the core api configuration. * Response: [GetCoreInfoResponse]. */ export interface GetCoreInfo { } /** Response for [GetCoreInfo]. */ export interface GetCoreInfoResponse { /** The title assigned to this core api. */ title: string; /** The monitoring interval of this core api. */ monitoring_interval: Timelength; /** The webhook base url. */ webhook_base_url: string; /** Whether transparent mode is enabled, which gives all users read access to all resources. */ transparent_mode: boolean; /** Whether UI write access should be disabled */ ui_write_disabled: boolean; /** Whether non admins can create resources */ disable_non_admin_create: boolean; /** Whether confirm dialog should be disabled */ disable_confirm_dialog: boolean; /** The repo owners for which github webhook management api is available */ github_webhook_owners: string[]; /** Whether to disable websocket automatic reconnect. */ disable_websocket_reconnect: boolean; /** Whether to enable fancy toml highlighting. */ enable_fancy_toml: boolean; /** TZ identifier Core is using, if manually set. */ timezone: string; } /** Get a specific deployment by name or id. Response: [Deployment]. */ export interface GetDeployment { /** Id or name */ deployment: string; } /** * Get current action state for the deployment. * Response: [DeploymentActionState]. */ export interface GetDeploymentActionState { /** Id or name */ deployment: string; } /** * Get the container, including image / status, of the target deployment. * Response: [GetDeploymentContainerResponse]. * * Note. This does not hit the server directly. The status comes from an * in memory cache on the core, which hits the server periodically * to keep it up to date. */ export interface GetDeploymentContainer { /** Id or name */ deployment: string; } /** Response for [GetDeploymentContainer]. */ export interface GetDeploymentContainerResponse { state: DeploymentState; container?: ContainerListItem; } /** * Get the deployment log's tail, split by stdout/stderr. * Response: [Log]. * * Note. This call will hit the underlying server directly for most up to date log. */ export interface GetDeploymentLog { /** Id or name */ deployment: string; /** * The number of lines of the log tail to include. * Default: 100. * Max: 5000. */ tail: U64; /** Enable `--timestamps` */ timestamps?: boolean; } /** * Get the deployment container's stats using `docker stats`. * Response: [GetDeploymentStatsResponse]. * * Note. This call will hit the underlying server directly for most up to date stats. */ export interface GetDeploymentStats { /** Id or name */ deployment: string; } /** * Gets a summary of data relating to all deployments. * Response: [GetDeploymentsSummaryResponse]. */ export interface GetDeploymentsSummary { } /** Response for [GetDeploymentsSummary]. */ export interface GetDeploymentsSummaryResponse { /** The total number of Deployments */ total: I64; /** The number of Deployments with Running state */ running: I64; /** The number of Deployments with Stopped or Paused state */ stopped: I64; /** The number of Deployments with NotDeployed state */ not_deployed: I64; /** The number of Deployments with Restarting or Dead or Created (other) state */ unhealthy: I64; /** The number of Deployments with Unknown state */ unknown: I64; } /** * Gets a summary of data relating to all containers. * Response: [GetDockerContainersSummaryResponse]. */ export interface GetDockerContainersSummary { } /** Response for [GetDockerContainersSummary] */ export interface GetDockerContainersSummaryResponse { /** The total number of Containers */ total: number; /** The number of Containers with Running state */ running: number; /** The number of Containers with Stopped or Paused or Created state */ stopped: number; /** The number of Containers with Restarting or Dead state */ unhealthy: number; /** The number of Containers with Unknown state */ unknown: number; } /** * Get a specific docker registry account. * Response: [GetDockerRegistryAccountResponse]. */ export interface GetDockerRegistryAccount { id: string; } /** * Get a specific git provider account. * Response: [GetGitProviderAccountResponse]. */ export interface GetGitProviderAccount { id: string; } /** * Paginated endpoint serving historical (timeseries) server stats for graphing. * Response: [GetHistoricalServerStatsResponse]. */ export interface GetHistoricalServerStats { /** Id or name */ server: string; /** The granularity of the data. */ granularity: Timelength; /** * Page of historical data. Default is 0, which is the most recent data. * Use with the `next_page` field of the response. */ page?: number; } /** System stats stored on the database. */ export interface SystemStatsRecord { /** Unix timestamp in milliseconds */ ts: I64; /** Server id */ sid: string; /** Cpu usage percentage */ cpu_perc: number; /** Load average (1m, 5m, 15m) */ load_average?: SystemLoadAverage; /** Memory used in GB */ mem_used_gb: number; /** Total memory in GB */ mem_total_gb: number; /** Disk used in GB */ disk_used_gb: number; /** Total disk size in GB */ disk_total_gb: number; /** Breakdown of individual disks, including their usage, total size, and mount point */ disks: SingleDiskUsage[]; /** Total network ingress in bytes */ network_ingress_bytes?: number; /** Total network egress in bytes */ network_egress_bytes?: number; } /** Response to [GetHistoricalServerStats]. */ export interface GetHistoricalServerStatsResponse { /** The timeseries page of data. */ stats: SystemStatsRecord[]; /** If there is a next page of data, pass this to `page` to get it. */ next_page?: number; } /** * Non authenticated route to see the available options * users have to login to Komodo, eg. local auth, github, google. * Response: [GetLoginOptionsResponse]. */ export interface GetLoginOptions { } /** The response for [GetLoginOptions]. */ export interface GetLoginOptionsResponse { /** Whether local auth is enabled. */ local: boolean; /** Whether github login is enabled. */ github: boolean; /** Whether google login is enabled. */ google: boolean; /** Whether OIDC login is enabled. */ oidc: boolean; /** Whether user registration (Sign Up) has been disabled */ registration_disabled: boolean; } /** * Get the version of the Komodo Periphery agent on the target server. * Response: [GetPeripheryVersionResponse]. */ export interface GetPeripheryVersion { /** Id or name */ server: string; } /** Response for [GetPeripheryVersion]. */ export interface GetPeripheryVersionResponse { /** The version of periphery. */ version: string; } /** * Gets the calling user's permission level on a specific resource. * Factors in any UserGroup's permissions they may be a part of. * Response: [PermissionLevel] */ export interface GetPermission { /** The target to get user permission on. */ target: ResourceTarget; } /** Get a specific procedure. Response: [Procedure]. */ export interface GetProcedure { /** Id or name */ procedure: string; } /** Get current action state for the procedure. Response: [ProcedureActionState]. */ export interface GetProcedureActionState { /** Id or name */ procedure: string; } /** * Gets a summary of data relating to all procedures. * Response: [GetProceduresSummaryResponse]. */ export interface GetProceduresSummary { } /** Response for [GetProceduresSummary]. */ export interface GetProceduresSummaryResponse { /** The total number of procedures. */ total: number; /** The number of procedures with Ok state. */ ok: number; /** The number of procedures currently running. */ running: number; /** The number of procedures with failed state. */ failed: number; /** The number of procedures with unknown state. */ unknown: number; } /** Get a specific repo. Response: [Repo]. */ export interface GetRepo { /** Id or name */ repo: string; } /** Get current action state for the repo. Response: [RepoActionState]. */ export interface GetRepoActionState { /** Id or name */ repo: string; } /** Get a target Repo's configured webhooks. Response: [GetRepoWebhooksEnabledResponse]. */ export interface GetRepoWebhooksEnabled { /** Id or name */ repo: string; } /** Response for [GetRepoWebhooksEnabled] */ export interface GetRepoWebhooksEnabledResponse { /** * Whether the repo webhooks can even be managed. * The repo owner must be in `github_webhook_app.owners` list to be managed. */ managed: boolean; /** Whether pushes to branch trigger clone. Will always be false if managed is false. */ clone_enabled: boolean; /** Whether pushes to branch trigger pull. Will always be false if managed is false. */ pull_enabled: boolean; /** Whether pushes to branch trigger build. Will always be false if managed is false. */ build_enabled: boolean; } /** * Gets a summary of data relating to all repos. * Response: [GetReposSummaryResponse]. */ export interface GetReposSummary { } /** Response for [GetReposSummary] */ export interface GetReposSummaryResponse { /** The total number of repos */ total: number; /** The number of repos with Ok state. */ ok: number; /** The number of repos currently cloning. */ cloning: number; /** The number of repos currently pulling. */ pulling: number; /** The number of repos currently building. */ building: number; /** The number of repos with failed state. */ failed: number; /** The number of repos with unknown state. */ unknown: number; } /** Find the attached resource for a container. Either Deployment or Stack. Response: [GetResourceMatchingContainerResponse]. */ export interface GetResourceMatchingContainer { /** Id or name */ server: string; /** The container name */ container: string; } /** Response for [GetResourceMatchingContainer]. Resource is either Deployment, Stack, or None. */ export interface GetResourceMatchingContainerResponse { resource?: ResourceTarget; } /** Get a specific sync. Response: [ResourceSync]. */ export interface GetResourceSync { /** Id or name */ sync: string; } /** Get current action state for the sync. Response: [ResourceSyncActionState]. */ export interface GetResourceSyncActionState { /** Id or name */ sync: string; } /** * Gets a summary of data relating to all syncs. * Response: [GetResourceSyncsSummaryResponse]. */ export interface GetResourceSyncsSummary { } /** Response for [GetResourceSyncsSummary] */ export interface GetResourceSyncsSummaryResponse { /** The total number of syncs */ total: number; /** The number of syncs with Ok state. */ ok: number; /** The number of syncs currently syncing. */ syncing: number; /** The number of syncs with pending updates */ pending: number; /** The number of syncs with failed state. */ failed: number; /** The number of syncs with unknown state. */ unknown: number; } /** Get a specific server. Response: [Server]. */ export interface GetServer { /** Id or name */ server: string; } /** Get current action state for the servers. Response: [ServerActionState]. */ export interface GetServerActionState { /** Id or name */ server: string; } /** Get the state of the target server. Response: [GetServerStateResponse]. */ export interface GetServerState { /** Id or name */ server: string; } /** The response for [GetServerState]. */ export interface GetServerStateResponse { /** The server status. */ status: ServerState; } /** * Gets a summary of data relating to all servers. * Response: [GetServersSummaryResponse]. */ export interface GetServersSummary { } /** Response for [GetServersSummary]. */ export interface GetServersSummaryResponse { /** The total number of servers. */ total: I64; /** The number of healthy (`status: OK`) servers. */ healthy: I64; /** The number of servers with warnings (e.g., version mismatch). */ warning: I64; /** The number of unhealthy servers. */ unhealthy: I64; /** The number of disabled servers. */ disabled: I64; } /** Get a specific stack. Response: [Stack]. */ export interface GetStack { /** Id or name */ stack: string; } /** Get current action state for the stack. Response: [StackActionState]. */ export interface GetStackActionState { /** Id or name */ stack: string; } /** * Get a stack's logs. Filter down included services. Response: [GetStackLogResponse]. * * Note. This call will hit the underlying server directly for most up to date log. */ export interface GetStackLog { /** Id or name */ stack: string; /** * Filter the logs to only ones from specific services. * If empty, will include logs from all services. */ services: string[]; /** * The number of lines of the log tail to include. * Default: 100. * Max: 5000. */ tail: U64; /** Enable `--timestamps` */ timestamps?: boolean; } /** Get a target stack's configured webhooks. Response: [GetStackWebhooksEnabledResponse]. */ export interface GetStackWebhooksEnabled { /** Id or name */ stack: string; } /** Response for [GetStackWebhooksEnabled] */ export interface GetStackWebhooksEnabledResponse { /** * Whether the repo webhooks can even be managed. * The repo owner must be in `github_webhook_app.owners` list to be managed. */ managed: boolean; /** Whether pushes to branch trigger refresh. Will always be false if managed is false. */ refresh_enabled: boolean; /** Whether pushes to branch trigger stack execution. Will always be false if managed is false. */ deploy_enabled: boolean; } /** * Gets a summary of data relating to all syncs. * Response: [GetStacksSummaryResponse]. */ export interface GetStacksSummary { } /** Response for [GetStacksSummary] */ export interface GetStacksSummaryResponse { /** The total number of stacks */ total: number; /** The number of stacks with Running state. */ running: number; /** The number of stacks with Stopped or Paused state. */ stopped: number; /** The number of stacks with Down state. */ down: number; /** The number of stacks with Unhealthy or Restarting or Dead or Created or Removing state. */ unhealthy: number; /** The number of stacks with Unknown state. */ unknown: number; } /** Get a target Sync's configured webhooks. Response: [GetSyncWebhooksEnabledResponse]. */ export interface GetSyncWebhooksEnabled { /** Id or name */ sync: string; } /** Response for [GetSyncWebhooksEnabled] */ export interface GetSyncWebhooksEnabledResponse { /** * Whether the repo webhooks can even be managed. * The repo owner must be in `github_webhook_app.owners` list to be managed. */ managed: boolean; /** Whether pushes to branch trigger refresh. Will always be false if managed is false. */ refresh_enabled: boolean; /** Whether pushes to branch trigger sync execution. Will always be false if managed is false. */ sync_enabled: boolean; } /** * Get the system information of the target server. * Response: [SystemInformation]. */ export interface GetSystemInformation { /** Id or name */ server: string; } /** * Get the system stats on the target server. Response: [SystemStats]. * * Note. This does not hit the server directly. The stats come from an * in memory cache on the core, which hits the server periodically * to keep it up to date. */ export interface GetSystemStats { /** Id or name */ server: string; } /** Get data for a specific tag. Response [Tag]. */ export interface GetTag { /** Id or name */ tag: string; } /** * Get all data for the target update. * Response: [Update]. */ export interface GetUpdate { /** The update id. */ id: string; } /** * Get the user extracted from the request headers. * Response: [User]. */ export interface GetUser { } /** * Get a specific user group by name or id. * Response: [UserGroup]. */ export interface GetUserGroup { /** Name or Id */ user_group: string; } /** * Gets the username of a specific user. * Response: [GetUsernameResponse] */ export interface GetUsername { /** The id of the user. */ user_id: string; } /** Response for [GetUsername]. */ export interface GetUsernameResponse { /** The username of the user. */ username: string; /** An optional icon for the user. */ avatar?: string; } /** * List all available global variables. * Response: [Variable] * * Note. For non admin users making this call, * secret variables will have their values obscured. */ export interface GetVariable { /** The name of the variable to get. */ name: string; } /** * Get the version of the Komodo Core api. * Response: [GetVersionResponse]. */ export interface GetVersion { } /** Response for [GetVersion]. */ export interface GetVersionResponse { /** The version of the core api. */ version: string; } /** * Trigger a global poll for image updates on Stacks and Deployments * with `poll_for_updates` or `auto_update` enabled. * Admin only. Response: [Update] * * 1. `docker compose pull` any Stacks / Deployments with `poll_for_updates` or `auto_update` enabled. This will pick up any available updates. * 2. Redeploy Stacks / Deployments that have updates found and 'auto_update' enabled. */ export interface GlobalAutoUpdate { } /** * Inspect the docker container associated with the Deployment. * Response: [Container]. */ export interface InspectDeploymentContainer { /** Id or name */ deployment: string; } /** Inspect a docker container on the server. Response: [Container]. */ export interface InspectDockerContainer { /** Id or name */ server: string; /** The container name */ container: string; } /** Inspect a docker image on the server. Response: [Image]. */ export interface InspectDockerImage { /** Id or name */ server: string; /** The image name */ image: string; } /** Inspect a docker network on the server. Response: [InspectDockerNetworkResponse]. */ export interface InspectDockerNetwork { /** Id or name */ server: string; /** The network name */ network: string; } /** Inspect a docker volume on the server. Response: [Volume]. */ export interface InspectDockerVolume { /** Id or name */ server: string; /** The volume name */ volume: string; } /** * Inspect the docker container associated with the Stack. * Response: [Container]. */ export interface InspectStackContainer { /** Id or name */ stack: string; /** The service name to inspect */ service: string; } export interface LatestCommit { hash: string; message: string; } /** List actions matching optional query. Response: [ListActionsResponse]. */ export interface ListActions { /** optional structured query to filter actions. */ query?: ActionQuery; } /** List alerters matching optional query. Response: [ListAlertersResponse]. */ export interface ListAlerters { /** Structured query to filter alerters. */ query?: AlerterQuery; } /** * Get a paginated list of alerts sorted by timestamp descending. * Response: [ListAlertsResponse]. */ export interface ListAlerts { /** * Pass a custom mongo query to filter the alerts. * * ## Example JSON * ```json * { * "resolved": "false", * "level": "CRITICAL", * "$or": [ * { * "target": { * "type": "Server", * "id": "6608bf89cb2a12b257ab6c09" * } * }, * { * "target": { * "type": "Server", * "id": "660a5f60b74f90d5dae45fa3" * } * } * ] * } * ``` * This will filter to only include open alerts that have CRITICAL level on those two servers. */ query?: MongoDocument; /** * Retrieve older results by incrementing the page. * `page: 0` is default, and returns the most recent results. */ page?: U64; } /** Response for [ListAlerts]. */ export interface ListAlertsResponse { alerts: Alert[]; /** * If more alerts exist, the next page will be given here. * Otherwise it will be `null` */ next_page?: I64; } /** * List all docker containers on the target server. * Response: [ListDockerContainersResponse]. */ export interface ListAllDockerContainers { /** Filter by server id or name. */ servers?: string[]; } /** * Gets list of api keys for the calling user. * Response: [ListApiKeysResponse] */ export interface ListApiKeys { } /** * **Admin only.** * Gets list of api keys for the user. * Will still fail if you call for a user_id that isn't a service user. * Response: [ListApiKeysForServiceUserResponse] */ export interface ListApiKeysForServiceUser { /** Id or username */ user: string; } /** * Retrieve versions of the build that were built in the past and available for deployment, * sorted by most recent first. * Response: [ListBuildVersionsResponse]. */ export interface ListBuildVersions { /** Id or name */ build: string; /** Filter to only include versions matching this major version. */ major?: number; /** Filter to only include versions matching this minor version. */ minor?: number; /** Filter to only include versions matching this patch version. */ patch?: number; /** Limit the number of included results. Default is no limit. */ limit?: I64; } /** List builders matching structured query. Response: [ListBuildersResponse]. */ export interface ListBuilders { query?: BuilderQuery; } /** List builds matching optional query. Response: [ListBuildsResponse]. */ export interface ListBuilds { /** optional structured query to filter builds. */ query?: BuildQuery; } /** * Gets a list of existing values used as extra args across other builds. * Useful to offer suggestions. Response: [ListCommonBuildExtraArgsResponse] */ export interface ListCommonBuildExtraArgs { /** optional structured query to filter builds. */ query?: BuildQuery; } /** * Gets a list of existing values used as extra args across other deployments. * Useful to offer suggestions. Response: [ListCommonDeploymentExtraArgsResponse] */ export interface ListCommonDeploymentExtraArgs { /** optional structured query to filter deployments. */ query?: DeploymentQuery; } /** * Gets a list of existing values used as build extra args across other stacks. * Useful to offer suggestions. Response: [ListCommonStackBuildExtraArgsResponse] */ export interface ListCommonStackBuildExtraArgs { /** optional structured query to filter stacks. */ query?: StackQuery; } /** * Gets a list of existing values used as extra args across other stacks. * Useful to offer suggestions. Response: [ListCommonStackExtraArgsResponse] */ export interface ListCommonStackExtraArgs { /** optional structured query to filter stacks. */ query?: StackQuery; } /** * List all docker compose projects on the target server. * Response: [ListComposeProjectsResponse]. */ export interface ListComposeProjects { /** Id or name */ server: string; } /** * List deployments matching optional query. * Response: [ListDeploymentsResponse]. */ export interface ListDeployments { /** optional structured query to filter deployments. */ query?: DeploymentQuery; } /** * List all docker containers on the target server. * Response: [ListDockerContainersResponse]. */ export interface ListDockerContainers { /** Id or name */ server: string; } /** Get image history from the server. Response: [ListDockerImageHistoryResponse]. */ export interface ListDockerImageHistory { /** Id or name */ server: string; /** The image name */ image: string; } /** * List the docker images locally cached on the target server. * Response: [ListDockerImagesResponse]. */ export interface ListDockerImages { /** Id or name */ server: string; } /** List the docker networks on the server. Response: [ListDockerNetworksResponse]. */ export interface ListDockerNetworks { /** Id or name */ server: string; } /** * List the docker registry providers available in Core / Periphery config files. * Response: [ListDockerRegistriesFromConfigResponse]. * * Includes: * - registries in core config * - registries configured on builds, deployments * - registries on the optional Server or Builder */ export interface ListDockerRegistriesFromConfig { /** * Accepts an optional Server or Builder target to expand the core list with * providers available on that specific resource. */ target?: ResourceTarget; } /** * List docker registry accounts matching optional query. * Response: [ListDockerRegistryAccountsResponse]. */ export interface ListDockerRegistryAccounts { /** Optionally filter by accounts with a specific domain. */ domain?: string; /** Optionally filter by accounts with a specific username. */ username?: string; } /** * List all docker volumes on the target server. * Response: [ListDockerVolumesResponse]. */ export interface ListDockerVolumes { /** Id or name */ server: string; } /** List actions matching optional query. Response: [ListFullActionsResponse]. */ export interface ListFullActions { /** optional structured query to filter actions. */ query?: ActionQuery; } /** List full alerters matching optional query. Response: [ListFullAlertersResponse]. */ export interface ListFullAlerters { /** Structured query to filter alerters. */ query?: AlerterQuery; } /** List builders matching structured query. Response: [ListFullBuildersResponse]. */ export interface ListFullBuilders { query?: BuilderQuery; } /** List builds matching optional query. Response: [ListFullBuildsResponse]. */ export interface ListFullBuilds { /** optional structured query to filter builds. */ query?: BuildQuery; } /** * List deployments matching optional query. * Response: [ListFullDeploymentsResponse]. */ export interface ListFullDeployments { /** optional structured query to filter deployments. */ query?: DeploymentQuery; } /** List procedures matching optional query. Response: [ListFullProceduresResponse]. */ export interface ListFullProcedures { /** optional structured query to filter procedures. */ query?: ProcedureQuery; } /** List repos matching optional query. Response: [ListFullReposResponse]. */ export interface ListFullRepos { /** optional structured query to filter repos. */ query?: RepoQuery; } /** List syncs matching optional query. Response: [ListFullResourceSyncsResponse]. */ export interface ListFullResourceSyncs { /** optional structured query to filter syncs. */ query?: ResourceSyncQuery; } /** List servers matching optional query. Response: [ListFullServersResponse]. */ export interface ListFullServers { /** optional structured query to filter servers. */ query?: ServerQuery; } /** List stacks matching optional query. Response: [ListFullStacksResponse]. */ export interface ListFullStacks { /** optional structured query to filter stacks. */ query?: StackQuery; } /** * List git provider accounts matching optional query. * Response: [ListGitProviderAccountsResponse]. */ export interface ListGitProviderAccounts { /** Optionally filter by accounts with a specific domain. */ domain?: string; /** Optionally filter by accounts with a specific username. */ username?: string; } /** * List the git providers available in Core / Periphery config files. * Response: [ListGitProvidersFromConfigResponse]. * * Includes: * - providers in core config * - providers configured on builds, repos, syncs * - providers on the optional Server or Builder */ export interface ListGitProvidersFromConfig { /** * Accepts an optional Server or Builder target to expand the core list with * providers available on that specific resource. */ target?: ResourceTarget; } /** * List permissions for the calling user. * Does not include any permissions on UserGroups they may be a part of. * Response: [ListPermissionsResponse] */ export interface ListPermissions { } /** List procedures matching optional query. Response: [ListProceduresResponse]. */ export interface ListProcedures { /** optional structured query to filter procedures. */ query?: ProcedureQuery; } /** List repos matching optional query. Response: [ListReposResponse]. */ export interface ListRepos { /** optional structured query to filter repos. */ query?: RepoQuery; } /** List syncs matching optional query. Response: [ListResourceSyncsResponse]. */ export interface ListResourceSyncs { /** optional structured query to filter syncs. */ query?: ResourceSyncQuery; } /** * List configured schedules. * Response: [ListSchedulesResponse]. */ export interface ListSchedules { /** Pass Vec of tag ids or tag names */ tags?: string[]; /** 'All' or 'Any' */ tag_behavior?: TagQueryBehavior; } /** * List the available secrets from the core config. * Response: [ListSecretsResponse]. */ export interface ListSecrets { /** * Accepts an optional Server or Builder target to expand the core list with * providers available on that specific resource. */ target?: ResourceTarget; } /** List servers matching optional query. Response: [ListServersResponse]. */ export interface ListServers { /** optional structured query to filter servers. */ query?: ServerQuery; } /** Lists a specific stacks services (the containers). Response: [ListStackServicesResponse]. */ export interface ListStackServices { /** Id or name */ stack: string; } /** List stacks matching optional query. Response: [ListStacksResponse]. */ export interface ListStacks { /** optional structured query to filter stacks. */ query?: StackQuery; } /** * List the processes running on the target server. * Response: [ListSystemProcessesResponse]. * * Note. This does not hit the server directly. The procedures come from an * in memory cache on the core, which hits the server periodically * to keep it up to date. */ export interface ListSystemProcesses { /** Id or name */ server: string; } /** * List data for tags matching optional mongo query. * Response: [ListTagsResponse]. */ export interface ListTags { query?: MongoDocument; } /** * List the current terminals on specified server. * Response: [ListTerminalsResponse]. */ export interface ListTerminals { /** Id or name */ server: string; /** * Force a fresh call to Periphery for the list. * Otherwise the response will be cached for 30s */ fresh?: boolean; } /** * Paginated endpoint for updates matching optional query. * More recent updates will be returned first. */ export interface ListUpdates { /** An optional mongo query to filter the updates. */ query?: MongoDocument; /** * Page of updates. Default is 0, which is the most recent data. * Use with the `next_page` field of the response. */ page?: number; } /** Minimal representation of an action performed by Komodo. */ export interface UpdateListItem { /** The id of the update */ id: string; /** Which operation was run */ operation: Operation; /** The starting time of the operation */ start_ts: I64; /** Whether the operation was successful */ success: boolean; /** The username of the user performing update */ username: string; /** * The user id that triggered the update. * * Also can take these values for operations triggered automatically: * - `Procedure`: The operation was triggered as part of a procedure run * - `Github`: The operation was triggered by a github webhook * - `Auto Redeploy`: The operation (always `Deploy`) was triggered by an attached build finishing. */ operator: string; /** The target resource to which this update refers */ target: ResourceTarget; /** * The status of the update * - `Queued` * - `InProgress` * - `Complete` */ status: UpdateStatus; /** An optional version on the update, ie build version or deployed version. */ version?: Version; /** Some unstructured, operation specific data. Not for general usage. */ other_data?: string; } /** Response for [ListUpdates]. */ export interface ListUpdatesResponse { /** The page of updates, sorted by timestamp descending. */ updates: UpdateListItem[]; /** If there is a next page of data, pass this to `page` to get it. */ next_page?: number; } /** * List all user groups which user can see. Response: [ListUserGroupsResponse]. * * Admins can see all user groups, * and users can see user groups to which they belong. */ export interface ListUserGroups { } /** * List permissions for a specific user. **Admin only**. * Response: [ListUserTargetPermissionsResponse] */ export interface ListUserTargetPermissions { /** Specify either a user or a user group. */ user_target: UserTarget; } /** * **Admin only.** * Gets list of Komodo users. * Response: [ListUsersResponse] */ export interface ListUsers { } /** * List all available global variables. * Response: [ListVariablesResponse] * * Note. For non admin users making this call, * secret variables will have their values obscured. */ export interface ListVariables { } /** * Login as a local user. Will fail if the users credentials don't match * any local user. * * Note. This method is only available if the core api has `local_auth` enabled. */ export interface LoginLocalUser { /** The user's username */ username: string; /** The user's password */ password: string; } export interface NameAndId { name: string; id: string; } /** Configuration for a Ntfy alerter. */ export interface NtfyAlerterEndpoint { /** The ntfy topic URL */ url: string; /** * Optional E-Mail Address to enable ntfy email notifications. * SMTP must be configured on the ntfy server. */ email?: string; } /** Pauses all containers on the target server. Response: [Update] */ export interface PauseAllContainers { /** Name or id */ server: string; } /** * Pauses the container on the target server. Response: [Update] * * 1. Runs `docker pause ${container_name}`. */ export interface PauseContainer { /** Name or id */ server: string; /** The container name */ container: string; } /** * Pauses the container for the target deployment. Response: [Update] * * 1. Runs `docker pause ${container_name}`. */ export interface PauseDeployment { /** Name or id */ deployment: string; } /** Pauses the target stack. `docker compose pause`. Response: [Update] */ export interface PauseStack { /** Id or name */ stack: string; /** * Filter to only pause specific services. * If empty, will pause all services. */ services?: string[]; } export interface PermissionToml { /** * Id can be: * - resource name. `id = "abcd-build"` * - regex matching resource names. `id = "\^(.+)-build-([0-9]+)$\"` */ target: ResourceTarget; /** * The permission level: * - None * - Read * - Execute * - Write */ level?: PermissionLevel; /** Any [SpecificPermissions](SpecificPermission) on the resource */ specific?: Array; } /** * Prunes the docker buildx cache on the target server. Response: [Update]. * * 1. Runs `docker buildx prune -a -f`. */ export interface PruneBuildx { /** Id or name */ server: string; } /** * Prunes the docker containers on the target server. Response: [Update]. * * 1. Runs `docker container prune -f`. */ export interface PruneContainers { /** Id or name */ server: string; } /** * Prunes the docker builders (build cache) on the target server. Response: [Update]. * * 1. Runs `docker builder prune -a -f`. */ export interface PruneDockerBuilders { /** Id or name */ server: string; } /** * Prunes the docker images on the target server. Response: [Update]. * * 1. Runs `docker image prune -a -f`. */ export interface PruneImages { /** Id or name */ server: string; } /** * Prunes the docker networks on the target server. Response: [Update]. * * 1. Runs `docker network prune -f`. */ export interface PruneNetworks { /** Id or name */ server: string; } /** * Prunes the docker system on the target server, including volumes. Response: [Update]. * * 1. Runs `docker system prune -a -f --volumes`. */ export interface PruneSystem { /** Id or name */ server: string; } /** * Prunes the docker volumes on the target server. Response: [Update]. * * 1. Runs `docker volume prune -a -f`. */ export interface PruneVolumes { /** Id or name */ server: string; } /** Pulls the image for the target deployment. Response: [Update] */ export interface PullDeployment { /** Name or id */ deployment: string; } /** * Pulls the target repo. Response: [Update]. * * Note. Repo must have server attached at `server_id`. * * 1. Pulls the repo on the target server using `git pull`. * 2. If `on_pull` is specified, it will be executed after the pull is complete. */ export interface PullRepo { /** Id or name */ repo: string; } /** Pulls images for the target stack. `docker compose pull`. Response: [Update] */ export interface PullStack { /** Id or name */ stack: string; /** * Filter to only pull specific services. * If empty, will pull all services. */ services?: string[]; } /** * Push a resource to the front of the users 10 most recently viewed resources. * Response: [NoData]. */ export interface PushRecentlyViewed { /** The target to push. */ resource: ResourceTarget; } /** Configuration for a Pushover alerter. */ export interface PushoverAlerterEndpoint { /** The pushover URL including application and user tokens in parameters. */ url: string; } /** Trigger a refresh of the cached latest hash and message. */ export interface RefreshBuildCache { /** Id or name */ build: string; } /** Trigger a refresh of the cached latest hash and message. */ export interface RefreshRepoCache { /** Id or name */ repo: string; } /** Trigger a refresh of the computed diff logs for view. Response: [ResourceSync] */ export interface RefreshResourceSyncPending { /** Id or name */ sync: string; } /** * Trigger a refresh of the cached compose file contents. * Refreshes: * - Whether the remote file is missing * - The latest json, and for repos, the remote contents, hash, and message. */ export interface RefreshStackCache { /** Id or name */ stack: string; } /** **Admin only.** Remove a user from a user group. Response: [UserGroup] */ export interface RemoveUserFromUserGroup { /** The name or id of UserGroup that user should be removed from. */ user_group: string; /** The id or username of the user to remove */ user: string; } /** * Rename the Action at id to the given name. * Response: [Update]. */ export interface RenameAction { /** The id or name of the Action to rename. */ id: string; /** The new name. */ name: string; } /** * Rename the Alerter at id to the given name. * Response: [Update]. */ export interface RenameAlerter { /** The id or name of the Alerter to rename. */ id: string; /** The new name. */ name: string; } /** * Rename the Build at id to the given name. * Response: [Update]. */ export interface RenameBuild { /** The id or name of the Build to rename. */ id: string; /** The new name. */ name: string; } /** * Rename the Builder at id to the given name. * Response: [Update]. */ export interface RenameBuilder { /** The id or name of the Builder to rename. */ id: string; /** The new name. */ name: string; } /** * Rename the deployment at id to the given name. Response: [Update]. * * Note. If a container is created for the deployment, it will be renamed using * `docker rename ...`. */ export interface RenameDeployment { /** The id of the deployment to rename. */ id: string; /** The new name. */ name: string; } /** * Rename the Procedure at id to the given name. * Response: [Update]. */ export interface RenameProcedure { /** The id or name of the Procedure to rename. */ id: string; /** The new name. */ name: string; } /** * Rename the Repo at id to the given name. * Response: [Update]. */ export interface RenameRepo { /** The id or name of the Repo to rename. */ id: string; /** The new name. */ name: string; } /** * Rename the ResourceSync at id to the given name. * Response: [Update]. */ export interface RenameResourceSync { /** The id or name of the ResourceSync to rename. */ id: string; /** The new name. */ name: string; } /** * Rename an Server to the given name. * Response: [Update]. */ export interface RenameServer { /** The id or name of the Server to rename. */ id: string; /** The new name. */ name: string; } /** Rename the stack at id to the given name. Response: [Update]. */ export interface RenameStack { /** The id of the stack to rename. */ id: string; /** The new name. */ name: string; } /** Rename a tag at id. Response: [Tag]. */ export interface RenameTag { /** The id of the tag to rename. */ id: string; /** The new name of the tag. */ name: string; } /** **Admin only.** Rename a user group. Response: [UserGroup] */ export interface RenameUserGroup { /** The id of the UserGroup */ id: string; /** The new name for the UserGroup */ name: string; } export declare enum DefaultRepoFolder { /** /${root_directory}/stacks */ Stacks = "Stacks", /** /${root_directory}/builds */ Builds = "Builds", /** /${root_directory}/repos */ Repos = "Repos", /** * If the repo is only cloned * in the core repo cache (resource sync), * this isn't relevant. */ NotApplicable = "NotApplicable" } export interface RepoExecutionArgs { /** Resource name (eg Build name, Repo name) */ name: string; /** Git provider domain. Default: `github.com` */ provider: string; /** Use https (vs http). */ https: boolean; /** Configure the account used to access repo (if private) */ account?: string; /** * Full repo identifier. {namespace}/{repo_name} * Its optional to force checking and produce error if not defined. */ repo?: string; /** Git Branch. Default: `main` */ branch: string; /** Specific commit hash. Optional */ commit?: string; /** The clone destination path */ destination?: string; /** * The default folder to use. * Depends on the resource type. */ default_folder: DefaultRepoFolder; } export interface RepoExecutionResponse { /** Response logs */ logs: Log[]; /** Absolute path to the repo root on the host. */ path: string; /** Latest short commit hash, if it could be retrieved */ commit_hash?: string; /** Latest commit message, if it could be retrieved */ commit_message?: string; } export interface ResourceToml { /** The resource name. Required */ name: string; /** The resource description. Optional. */ description?: string; /** Mark resource as a template */ template?: boolean; /** Tag ids or names. Optional */ tags?: string[]; /** * Optional. Only relevant for deployments / stacks. * * Will ensure deployment / stack is running with the latest configuration. * Deploy actions to achieve this will be included in the sync. * Default is false. */ deploy?: boolean; /** * Optional. Only relevant for deployments / stacks using the 'deploy' sync feature. * * Specify other deployments / stacks by name as dependencies. * The sync will ensure the deployment / stack will only be deployed 'after' its dependencies. */ after?: string[]; /** Resource specific configuration. */ config?: PartialConfig; } export interface UserGroupToml { /** User group name */ name: string; /** Whether all users will implicitly have the permissions in this group. */ everyone?: boolean; /** Users in the group */ users?: string[]; /** Give the user group elevated permissions on all resources of a certain type */ all?: Record; /** Permissions given to the group */ permissions?: PermissionToml[]; } /** Specifies resources to sync on Komodo */ export interface ResourcesToml { servers?: ResourceToml<_PartialServerConfig>[]; deployments?: ResourceToml<_PartialDeploymentConfig>[]; stacks?: ResourceToml<_PartialStackConfig>[]; builds?: ResourceToml<_PartialBuildConfig>[]; repos?: ResourceToml<_PartialRepoConfig>[]; procedures?: ResourceToml<_PartialProcedureConfig>[]; actions?: ResourceToml<_PartialActionConfig>[]; alerters?: ResourceToml<_PartialAlerterConfig>[]; builders?: ResourceToml<_PartialBuilderConfig>[]; resource_syncs?: ResourceToml<_PartialResourceSyncConfig>[]; user_groups?: UserGroupToml[]; variables?: Variable[]; } /** Restarts all containers on the target server. Response: [Update] */ export interface RestartAllContainers { /** Name or id */ server: string; } /** * Restarts the container on the target server. Response: [Update] * * 1. Runs `docker restart ${container_name}`. */ export interface RestartContainer { /** Name or id */ server: string; /** The container name */ container: string; } /** * Restarts the container for the target deployment. Response: [Update] * * 1. Runs `docker restart ${container_name}`. */ export interface RestartDeployment { /** Name or id */ deployment: string; } /** Restarts the target stack. `docker compose restart`. Response: [Update] */ export interface RestartStack { /** Id or name */ stack: string; /** * Filter to only restart specific services. * If empty, will restart all services. */ services?: string[]; } /** Runs the target Action. Response: [Update] */ export interface RunAction { /** Id or name */ action: string; /** * Custom arguments which are merged on top of the default arguments. * CLI Format: `"VAR1=val1&VAR2=val2"` * * Webhook-triggered actions use this to pass WEBHOOK_BRANCH and WEBHOOK_BODY. */ args?: JsonObject; } /** * Runs the target build. Response: [Update]. * * 1. Get a handle to the builder. If using AWS builder, this means starting a builder ec2 instance. * * 2. Clone the repo on the builder. If an `on_clone` commmand is given, it will be executed. * * 3. Execute `docker build {...params}`, where params are determined using the builds configuration. * * 4. If a docker registry is configured, the build will be pushed to the registry. * * 5. If using AWS builder, destroy the builder ec2 instance. * * 6. Deploy any Deployments with *Redeploy on Build* enabled. */ export interface RunBuild { /** Can be build id or name */ build: string; } /** Runs the target Procedure. Response: [Update] */ export interface RunProcedure { /** Id or name */ procedure: string; } /** Runs a one-time command against a service using `docker compose run`. Response: [Update] */ export interface RunStackService { /** Id or name */ stack: string; /** Service to run */ service: string; /** Command and args to pass to the service container */ command?: string[]; /** Do not allocate TTY */ no_tty?: boolean; /** Do not start linked services */ no_deps?: boolean; /** Detach container on run */ detach?: boolean; /** Map service ports to the host */ service_ports?: boolean; /** Extra environment variables for the run */ env?: Record; /** Working directory inside the container */ workdir?: string; /** User to run as inside the container */ user?: string; /** Override the default entrypoint */ entrypoint?: string; /** Pull the image before running */ pull?: boolean; } /** Runs the target resource sync. Response: [Update] */ export interface RunSync { /** Id or name */ sync: string; /** * Only execute sync on a specific resource type. * Combine with `resource_id` to specify resource. */ resource_type?: ResourceTarget["type"]; /** * Only execute sync on a specific resources. * Combine with `resource_type` to specify resources. * Supports name or id. */ resources?: string[]; } export declare enum SearchCombinator { Or = "Or", And = "And" } /** * Search the container log's tail using `grep`. All lines go to stdout. * Response: [Log]. * * Note. This call will hit the underlying server directly for most up to date log. */ export interface SearchContainerLog { /** Id or name */ server: string; /** The container name */ container: string; /** The terms to search for. */ terms: string[]; /** * When searching for multiple terms, can use `AND` or `OR` combinator. * * - `AND`: Only include lines with **all** terms present in that line. * - `OR`: Include lines that have one or more matches in the terms. */ combinator?: SearchCombinator; /** Invert the results, ie return all lines that DON'T match the terms / combinator. */ invert?: boolean; /** Enable `--timestamps` */ timestamps?: boolean; } /** * Search the deployment log's tail using `grep`. All lines go to stdout. * Response: [Log]. * * Note. This call will hit the underlying server directly for most up to date log. */ export interface SearchDeploymentLog { /** Id or name */ deployment: string; /** The terms to search for. */ terms: string[]; /** * When searching for multiple terms, can use `AND` or `OR` combinator. * * - `AND`: Only include lines with **all** terms present in that line. * - `OR`: Include lines that have one or more matches in the terms. */ combinator?: SearchCombinator; /** Invert the results, ie return all lines that DON'T match the terms / combinator. */ invert?: boolean; /** Enable `--timestamps` */ timestamps?: boolean; } /** * Search the stack log's tail using `grep`. All lines go to stdout. * Response: [SearchStackLogResponse]. * * Note. This call will hit the underlying server directly for most up to date log. */ export interface SearchStackLog { /** Id or name */ stack: string; /** * Filter the logs to only ones from specific services. * If empty, will include logs from all services. */ services: string[]; /** The terms to search for. */ terms: string[]; /** * When searching for multiple terms, can use `AND` or `OR` combinator. * * - `AND`: Only include lines with **all** terms present in that line. * - `OR`: Include lines that have one or more matches in the terms. */ combinator?: SearchCombinator; /** Invert the results, ie return all lines that DON'T match the terms / combinator. */ invert?: boolean; /** Enable `--timestamps` */ timestamps?: boolean; } /** Send a custom alert message to configured Alerters. Response: [Update] */ export interface SendAlert { /** The alert level. */ level?: SeverityLevel; /** The alert message. Required. */ message: string; /** The alert details. Optional. */ details?: string; /** * Specific alerter names or ids. * If empty / not passed, sends to all configured alerters * with the `Custom` alert type whitelisted / not blacklisted. */ alerters?: string[]; } /** Configuration for a Komodo Server Builder. */ export interface ServerBuilderConfig { /** The server id of the builder */ server_id?: string; } /** The health of a part of the server. */ export interface ServerHealthState { level: SeverityLevel; /** Whether the health is good enough to close an open alert. */ should_close_alert: boolean; } /** Summary of the health of the server. */ export interface ServerHealth { cpu: ServerHealthState; mem: ServerHealthState; disks: Record; } /** * **Admin only.** Set `everyone` property of User Group. * Response: [UserGroup] */ export interface SetEveryoneUserGroup { /** Id or name. */ user_group: string; /** Whether this user group applies to everyone. */ everyone: boolean; } /** * Set the time the user last opened the UI updates. * Used for unseen notification dot. * Response: [NoData] */ export interface SetLastSeenUpdate { } /** * **Admin only.** Completely override the users in the group. * Response: [UserGroup] */ export interface SetUsersInUserGroup { /** Id or name. */ user_group: string; /** The user ids or usernames to hard set as the group's users. */ users: string[]; } /** * Sign up a new local user account. Will fail if a user with the * given username already exists. * Response: [SignUpLocalUserResponse]. * * Note. This method is only available if the core api has `local_auth` enabled, * and if user registration is not disabled (after the first user). */ export interface SignUpLocalUser { /** The username for the new user. */ username: string; /** * The password for the new user. * This cannot be retreived later. */ password: string; } /** Info for network interface usage. */ export interface SingleNetworkInterfaceUsage { /** The network interface name */ name: string; /** The ingress in bytes */ ingress_bytes: number; /** The egress in bytes */ egress_bytes: number; } /** Configuration for a Slack alerter. */ export interface SlackAlerterEndpoint { /** The Slack app webhook url */ url: string; } /** Sleeps for the specified time. */ export interface Sleep { duration_ms?: I64; } /** Starts all containers on the target server. Response: [Update] */ export interface StartAllContainers { /** Name or id */ server: string; } /** * Starts the container on the target server. Response: [Update] * * 1. Runs `docker start ${container_name}`. */ export interface StartContainer { /** Name or id */ server: string; /** The container name */ container: string; } /** * Starts the container for the target deployment. Response: [Update] * * 1. Runs `docker start ${container_name}`. */ export interface StartDeployment { /** Name or id */ deployment: string; } /** Starts the target stack. `docker compose start`. Response: [Update] */ export interface StartStack { /** Id or name */ stack: string; /** * Filter to only start specific services. * If empty, will start all services. */ services?: string[]; } /** Stops all containers on the target server. Response: [Update] */ export interface StopAllContainers { /** Name or id */ server: string; } /** * Stops the container on the target server. Response: [Update] * * 1. Runs `docker stop ${container_name}`. */ export interface StopContainer { /** Name or id */ server: string; /** The container name */ container: string; /** Override the default termination signal. */ signal?: TerminationSignal; /** Override the default termination max time. */ time?: number; } /** * Stops the container for the target deployment. Response: [Update] * * 1. Runs `docker stop ${container_name}`. */ export interface StopDeployment { /** Name or id */ deployment: string; /** Override the default termination signal specified in the deployment. */ signal?: TerminationSignal; /** Override the default termination max time. */ time?: number; } /** Stops the target stack. `docker compose stop`. Response: [Update] */ export interface StopStack { /** Id or name */ stack: string; /** Override the default termination max time. */ stop_time?: number; /** * Filter to only stop specific services. * If empty, will stop all services. */ services?: string[]; } export interface TerminationSignalLabel { signal: TerminationSignal; label: string; } /** Tests an Alerters ability to reach the configured endpoint. Response: [Update] */ export interface TestAlerter { /** Name or id */ alerter: string; } /** Info for the all system disks combined. */ export interface TotalDiskUsage { /** Used portion in GB */ used_gb: number; /** Total size in GB */ total_gb: number; } /** Unpauses all containers on the target server. Response: [Update] */ export interface UnpauseAllContainers { /** Name or id */ server: string; } /** * Unpauses the container on the target server. Response: [Update] * * 1. Runs `docker unpause ${container_name}`. * * Note. This is the only way to restart a paused container. */ export interface UnpauseContainer { /** Name or id */ server: string; /** The container name */ container: string; } /** * Unpauses the container for the target deployment. Response: [Update] * * 1. Runs `docker unpause ${container_name}`. * * Note. This is the only way to restart a paused container. */ export interface UnpauseDeployment { /** Name or id */ deployment: string; } /** * Unpauses the target stack. `docker compose unpause`. Response: [Update]. * * Note. This is the only way to restart a paused container. */ export interface UnpauseStack { /** Id or name */ stack: string; /** * Filter to only unpause specific services. * If empty, will unpause all services. */ services?: string[]; } /** * Update the action at the given id, and return the updated action. * Response: [Action]. * * Note. This method updates only the fields which are set in the [_PartialActionConfig], * effectively merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateAction { /** The id of the action to update. */ id: string; /** The partial config update to apply. */ config: _PartialActionConfig; } /** * Update the alerter at the given id, and return the updated alerter. Response: [Alerter]. * * Note. This method updates only the fields which are set in the [PartialAlerterConfig][crate::entities::alerter::PartialAlerterConfig], * effectively merging diffs into the final document. This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateAlerter { /** The id of the alerter to update. */ id: string; /** The partial config update to apply. */ config: _PartialAlerterConfig; } /** * Update the build at the given id, and return the updated build. * Response: [Build]. * * Note. This method updates only the fields which are set in the [_PartialBuildConfig], * effectively merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateBuild { /** The id or name of the build to update. */ id: string; /** The partial config update to apply. */ config: _PartialBuildConfig; } /** * Update the builder at the given id, and return the updated builder. * Response: [Builder]. * * Note. This method updates only the fields which are set in the [PartialBuilderConfig], * effectively merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateBuilder { /** The id of the builder to update. */ id: string; /** The partial config update to apply. */ config: PartialBuilderConfig; } /** * Update the deployment at the given id, and return the updated deployment. * Response: [Deployment]. * * Note. If the attached server for the deployment changes, * the deployment will be deleted / cleaned up on the old server. * * Note. This method updates only the fields which are set in the [_PartialDeploymentConfig], * effectively merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateDeployment { /** The deployment id to update. */ id: string; /** The partial config update. */ config: _PartialDeploymentConfig; } /** * **Admin only.** Update a docker registry account. * Response: [DockerRegistryAccount]. */ export interface UpdateDockerRegistryAccount { /** The id of the docker registry to update */ id: string; /** The partial docker registry account. */ account: _PartialDockerRegistryAccount; } /** * **Admin only.** Update a git provider account. * Response: [GitProviderAccount]. */ export interface UpdateGitProviderAccount { /** The id of the git provider account to update. */ id: string; /** The partial git provider account. */ account: _PartialGitProviderAccount; } /** * **Admin only.** Update a user or user groups base permission level on a resource type. * Response: [NoData]. */ export interface UpdatePermissionOnResourceType { /** Specify the user or user group. */ user_target: UserTarget; /** The resource type: eg. Server, Build, Deployment, etc. */ resource_type: ResourceTarget["type"]; /** The base permission level. */ permission: PermissionLevelAndSpecifics | PermissionLevel; } /** * **Admin only.** Update a user or user groups permission on a resource. * Response: [NoData]. */ export interface UpdatePermissionOnTarget { /** Specify the user or user group. */ user_target: UserTarget; /** Specify the target resource. */ resource_target: ResourceTarget; /** Specify the permission level. */ permission: PermissionLevelAndSpecifics | PermissionLevel; } /** * Update the procedure at the given id, and return the updated procedure. * Response: [Procedure]. * * Note. This method updates only the fields which are set in the [_PartialProcedureConfig], * effectively merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateProcedure { /** The id of the procedure to update. */ id: string; /** The partial config update. */ config: _PartialProcedureConfig; } /** * Update the repo at the given id, and return the updated repo. * Response: [Repo]. * * Note. If the attached server for the repo changes, * the repo will be deleted / cleaned up on the old server. * * Note. This method updates only the fields which are set in the [_PartialRepoConfig], * effectively merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateRepo { /** The id of the repo to update. */ id: string; /** The partial config update to apply. */ config: _PartialRepoConfig; } /** * Update a resources common meta fields. * - description * - template * - tags * Response: [NoData]. */ export interface UpdateResourceMeta { /** The target resource to set update meta. */ target: ResourceTarget; /** * New description to set, * or null for no update */ description?: string; /** * New template value (true or false), * or null for no update */ template?: boolean; /** * The exact tags to set, * or null for no update */ tags?: string[]; } /** * Update the sync at the given id, and return the updated sync. * Response: [ResourceSync]. * * Note. This method updates only the fields which are set in the [_PartialResourceSyncConfig], * effectively merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateResourceSync { /** The id of the sync to update. */ id: string; /** The partial config update to apply. */ config: _PartialResourceSyncConfig; } /** * Update the server at the given id, and return the updated server. * Response: [Server]. * * Note. This method updates only the fields which are set in the [_PartialServerConfig], * effectively merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateServer { /** The id or name of the server to update. */ id: string; /** The partial config update to apply. */ config: _PartialServerConfig; } /** * **Admin only.** Update a service user's description. * Response: [User]. */ export interface UpdateServiceUserDescription { /** The service user's username */ username: string; /** A new description for the service user. */ description: string; } /** * Update the stack at the given id, and return the updated stack. * Response: [Stack]. * * Note. If the attached server for the stack changes, * the stack will be deleted / cleaned up on the old server. * * Note. This method updates only the fields which are set in the [_PartialStackConfig], * merging diffs into the final document. * This is helpful when multiple users are using * the same resources concurrently by ensuring no unintentional * field changes occur from out of date local state. */ export interface UpdateStack { /** The id of the Stack to update. */ id: string; /** The partial config update to apply. */ config: _PartialStackConfig; } /** Update color for tag. Response: [Tag]. */ export interface UpdateTagColor { /** The name or id of the tag to update. */ tag: string; /** The new color for the tag. */ color: TagColor; } /** * **Super Admin only.** Update's whether a user is admin. * Response: [NoData]. */ export interface UpdateUserAdmin { /** The target user. */ user_id: string; /** Whether user should be admin. */ admin: boolean; } /** * **Admin only.** Update a user's "base" permissions, eg. "enabled". * Response: [NoData]. */ export interface UpdateUserBasePermissions { /** The target user. */ user_id: string; /** If specified, will update users enabled state. */ enabled?: boolean; /** If specified, will update user's ability to create servers. */ create_servers?: boolean; /** If specified, will update user's ability to create builds. */ create_builds?: boolean; } /** * **Only for local users**. Update the calling users password. * Response: [NoData]. */ export interface UpdateUserPassword { password: string; } /** * **Only for local users**. Update the calling users username. * Response: [NoData]. */ export interface UpdateUserUsername { username: string; } /** **Admin only.** Update variable description. Response: [Variable]. */ export interface UpdateVariableDescription { /** The name of the variable to update. */ name: string; /** The description to set. */ description: string; } /** **Admin only.** Update whether variable is secret. Response: [Variable]. */ export interface UpdateVariableIsSecret { /** The name of the variable to update. */ name: string; /** Whether variable is secret. */ is_secret: boolean; } /** **Admin only.** Update variable value. Response: [Variable]. */ export interface UpdateVariableValue { /** The name of the variable to update. */ name: string; /** The value to set. */ value: string; } /** Configuration for a Komodo Url Builder. */ export interface UrlBuilderConfig { /** The address of the Periphery agent */ address: string; /** A custom passkey to use. Otherwise, use the default passkey. */ passkey?: string; } /** Update dockerfile contents in Files on Server or Git Repo mode. Response: [Update]. */ export interface WriteBuildFileContents { /** The name or id of the target Build. */ build: string; /** The dockerfile contents to write. */ contents: string; } /** Update file contents in Files on Server or Git Repo mode. Response: [Update]. */ export interface WriteStackFileContents { /** The name or id of the target Stack. */ stack: string; /** * The file path relative to the stack run directory, * or absolute path. */ file_path: string; /** The contents to write. */ contents: string; } /** Rename the stack at id to the given name. Response: [Update]. */ export interface WriteSyncFileContents { /** The name or id of the target Sync. */ sync: string; /** * If this file was under a resource folder, this will be the folder. * Otherwise, it should be empty string. */ resource_path: string; /** The file path relative to the resource path. */ file_path: string; /** The contents to write. */ contents: string; } export type AuthRequest = { type: "GetLoginOptions"; params: GetLoginOptions; } | { type: "SignUpLocalUser"; params: SignUpLocalUser; } | { type: "LoginLocalUser"; params: LoginLocalUser; } | { type: "ExchangeForJwt"; params: ExchangeForJwt; } | { type: "GetUser"; params: GetUser; }; /** Days of the week */ export declare enum DayOfWeek { Monday = "Monday", Tuesday = "Tuesday", Wednesday = "Wednesday", Thursday = "Thursday", Friday = "Friday", Saturday = "Saturday", Sunday = "Sunday" } export type ExecuteRequest = { type: "StartContainer"; params: StartContainer; } | { type: "RestartContainer"; params: RestartContainer; } | { type: "PauseContainer"; params: PauseContainer; } | { type: "UnpauseContainer"; params: UnpauseContainer; } | { type: "StopContainer"; params: StopContainer; } | { type: "DestroyContainer"; params: DestroyContainer; } | { type: "StartAllContainers"; params: StartAllContainers; } | { type: "RestartAllContainers"; params: RestartAllContainers; } | { type: "PauseAllContainers"; params: PauseAllContainers; } | { type: "UnpauseAllContainers"; params: UnpauseAllContainers; } | { type: "StopAllContainers"; params: StopAllContainers; } | { type: "PruneContainers"; params: PruneContainers; } | { type: "DeleteNetwork"; params: DeleteNetwork; } | { type: "PruneNetworks"; params: PruneNetworks; } | { type: "DeleteImage"; params: DeleteImage; } | { type: "PruneImages"; params: PruneImages; } | { type: "DeleteVolume"; params: DeleteVolume; } | { type: "PruneVolumes"; params: PruneVolumes; } | { type: "PruneDockerBuilders"; params: PruneDockerBuilders; } | { type: "PruneBuildx"; params: PruneBuildx; } | { type: "PruneSystem"; params: PruneSystem; } | { type: "DeployStack"; params: DeployStack; } | { type: "BatchDeployStack"; params: BatchDeployStack; } | { type: "DeployStackIfChanged"; params: DeployStackIfChanged; } | { type: "BatchDeployStackIfChanged"; params: BatchDeployStackIfChanged; } | { type: "PullStack"; params: PullStack; } | { type: "BatchPullStack"; params: BatchPullStack; } | { type: "StartStack"; params: StartStack; } | { type: "RestartStack"; params: RestartStack; } | { type: "StopStack"; params: StopStack; } | { type: "PauseStack"; params: PauseStack; } | { type: "UnpauseStack"; params: UnpauseStack; } | { type: "DestroyStack"; params: DestroyStack; } | { type: "BatchDestroyStack"; params: BatchDestroyStack; } | { type: "RunStackService"; params: RunStackService; } | { type: "Deploy"; params: Deploy; } | { type: "BatchDeploy"; params: BatchDeploy; } | { type: "PullDeployment"; params: PullDeployment; } | { type: "StartDeployment"; params: StartDeployment; } | { type: "RestartDeployment"; params: RestartDeployment; } | { type: "PauseDeployment"; params: PauseDeployment; } | { type: "UnpauseDeployment"; params: UnpauseDeployment; } | { type: "StopDeployment"; params: StopDeployment; } | { type: "DestroyDeployment"; params: DestroyDeployment; } | { type: "BatchDestroyDeployment"; params: BatchDestroyDeployment; } | { type: "RunBuild"; params: RunBuild; } | { type: "BatchRunBuild"; params: BatchRunBuild; } | { type: "CancelBuild"; params: CancelBuild; } | { type: "CloneRepo"; params: CloneRepo; } | { type: "BatchCloneRepo"; params: BatchCloneRepo; } | { type: "PullRepo"; params: PullRepo; } | { type: "BatchPullRepo"; params: BatchPullRepo; } | { type: "BuildRepo"; params: BuildRepo; } | { type: "BatchBuildRepo"; params: BatchBuildRepo; } | { type: "CancelRepoBuild"; params: CancelRepoBuild; } | { type: "RunProcedure"; params: RunProcedure; } | { type: "BatchRunProcedure"; params: BatchRunProcedure; } | { type: "RunAction"; params: RunAction; } | { type: "BatchRunAction"; params: BatchRunAction; } | { type: "TestAlerter"; params: TestAlerter; } | { type: "SendAlert"; params: SendAlert; } | { type: "RunSync"; params: RunSync; } | { type: "ClearRepoCache"; params: ClearRepoCache; } | { type: "BackupCoreDatabase"; params: BackupCoreDatabase; } | { type: "GlobalAutoUpdate"; params: GlobalAutoUpdate; }; /** * One representative IANA zone for each distinct base UTC offset in the tz database. * https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. * * The `serde`/`strum` renames ensure the canonical identifier is used * when serializing or parsing from a string such as `"Etc/UTC"`. */ export declare enum IanaTimezone { /** UTC−12:00 */ EtcGmtMinus12 = "Etc/GMT+12", /** UTC−11:00 */ PacificPagoPago = "Pacific/Pago_Pago", /** UTC−10:00 */ PacificHonolulu = "Pacific/Honolulu", /** UTC−09:30 */ PacificMarquesas = "Pacific/Marquesas", /** UTC−09:00 */ AmericaAnchorage = "America/Anchorage", /** UTC−08:00 */ AmericaLosAngeles = "America/Los_Angeles", /** UTC−07:00 */ AmericaDenver = "America/Denver", /** UTC−06:00 */ AmericaChicago = "America/Chicago", /** UTC−05:00 */ AmericaNewYork = "America/New_York", /** UTC−04:00 */ AmericaHalifax = "America/Halifax", /** UTC−03:30 */ AmericaStJohns = "America/St_Johns", /** UTC−03:00 */ AmericaSaoPaulo = "America/Sao_Paulo", /** UTC−02:00 */ AmericaNoronha = "America/Noronha", /** UTC−01:00 */ AtlanticAzores = "Atlantic/Azores", /** UTC±00:00 */ EtcUtc = "Etc/UTC", /** UTC+01:00 */ EuropeBerlin = "Europe/Berlin", /** UTC+02:00 */ EuropeBucharest = "Europe/Bucharest", /** UTC+03:00 */ EuropeMoscow = "Europe/Moscow", /** UTC+03:30 */ AsiaTehran = "Asia/Tehran", /** UTC+04:00 */ AsiaDubai = "Asia/Dubai", /** UTC+04:30 */ AsiaKabul = "Asia/Kabul", /** UTC+05:00 */ AsiaKarachi = "Asia/Karachi", /** UTC+05:30 */ AsiaKolkata = "Asia/Kolkata", /** UTC+05:45 */ AsiaKathmandu = "Asia/Kathmandu", /** UTC+06:00 */ AsiaDhaka = "Asia/Dhaka", /** UTC+06:30 */ AsiaYangon = "Asia/Yangon", /** UTC+07:00 */ AsiaBangkok = "Asia/Bangkok", /** UTC+08:00 */ AsiaShanghai = "Asia/Shanghai", /** UTC+08:45 */ AustraliaEucla = "Australia/Eucla", /** UTC+09:00 */ AsiaTokyo = "Asia/Tokyo", /** UTC+09:30 */ AustraliaAdelaide = "Australia/Adelaide", /** UTC+10:00 */ AustraliaSydney = "Australia/Sydney", /** UTC+10:30 */ AustraliaLordHowe = "Australia/Lord_Howe", /** UTC+11:00 */ PacificPortMoresby = "Pacific/Port_Moresby", /** UTC+12:00 */ PacificAuckland = "Pacific/Auckland", /** UTC+12:45 */ PacificChatham = "Pacific/Chatham", /** UTC+13:00 */ PacificTongatapu = "Pacific/Tongatapu", /** UTC+14:00 */ PacificKiritimati = "Pacific/Kiritimati" } export type ReadRequest = { type: "GetVersion"; params: GetVersion; } | { type: "GetCoreInfo"; params: GetCoreInfo; } | { type: "ListSecrets"; params: ListSecrets; } | { type: "ListGitProvidersFromConfig"; params: ListGitProvidersFromConfig; } | { type: "ListDockerRegistriesFromConfig"; params: ListDockerRegistriesFromConfig; } | { type: "GetUsername"; params: GetUsername; } | { type: "GetPermission"; params: GetPermission; } | { type: "FindUser"; params: FindUser; } | { type: "ListUsers"; params: ListUsers; } | { type: "ListApiKeys"; params: ListApiKeys; } | { type: "ListApiKeysForServiceUser"; params: ListApiKeysForServiceUser; } | { type: "ListPermissions"; params: ListPermissions; } | { type: "ListUserTargetPermissions"; params: ListUserTargetPermissions; } | { type: "GetUserGroup"; params: GetUserGroup; } | { type: "ListUserGroups"; params: ListUserGroups; } | { type: "GetProceduresSummary"; params: GetProceduresSummary; } | { type: "GetProcedure"; params: GetProcedure; } | { type: "GetProcedureActionState"; params: GetProcedureActionState; } | { type: "ListProcedures"; params: ListProcedures; } | { type: "ListFullProcedures"; params: ListFullProcedures; } | { type: "GetActionsSummary"; params: GetActionsSummary; } | { type: "GetAction"; params: GetAction; } | { type: "GetActionActionState"; params: GetActionActionState; } | { type: "ListActions"; params: ListActions; } | { type: "ListFullActions"; params: ListFullActions; } | { type: "ListSchedules"; params: ListSchedules; } | { type: "GetServersSummary"; params: GetServersSummary; } | { type: "GetServer"; params: GetServer; } | { type: "GetServerState"; params: GetServerState; } | { type: "GetPeripheryVersion"; params: GetPeripheryVersion; } | { type: "GetServerActionState"; params: GetServerActionState; } | { type: "GetHistoricalServerStats"; params: GetHistoricalServerStats; } | { type: "ListServers"; params: ListServers; } | { type: "ListFullServers"; params: ListFullServers; } | { type: "InspectDockerContainer"; params: InspectDockerContainer; } | { type: "GetResourceMatchingContainer"; params: GetResourceMatchingContainer; } | { type: "GetContainerLog"; params: GetContainerLog; } | { type: "SearchContainerLog"; params: SearchContainerLog; } | { type: "InspectDockerNetwork"; params: InspectDockerNetwork; } | { type: "InspectDockerImage"; params: InspectDockerImage; } | { type: "ListDockerImageHistory"; params: ListDockerImageHistory; } | { type: "InspectDockerVolume"; params: InspectDockerVolume; } | { type: "GetDockerContainersSummary"; params: GetDockerContainersSummary; } | { type: "ListAllDockerContainers"; params: ListAllDockerContainers; } | { type: "ListDockerContainers"; params: ListDockerContainers; } | { type: "ListDockerNetworks"; params: ListDockerNetworks; } | { type: "ListDockerImages"; params: ListDockerImages; } | { type: "ListDockerVolumes"; params: ListDockerVolumes; } | { type: "ListComposeProjects"; params: ListComposeProjects; } | { type: "ListTerminals"; params: ListTerminals; } | { type: "GetSystemInformation"; params: GetSystemInformation; } | { type: "GetSystemStats"; params: GetSystemStats; } | { type: "ListSystemProcesses"; params: ListSystemProcesses; } | { type: "GetStacksSummary"; params: GetStacksSummary; } | { type: "GetStack"; params: GetStack; } | { type: "GetStackActionState"; params: GetStackActionState; } | { type: "GetStackWebhooksEnabled"; params: GetStackWebhooksEnabled; } | { type: "GetStackLog"; params: GetStackLog; } | { type: "SearchStackLog"; params: SearchStackLog; } | { type: "InspectStackContainer"; params: InspectStackContainer; } | { type: "ListStacks"; params: ListStacks; } | { type: "ListFullStacks"; params: ListFullStacks; } | { type: "ListStackServices"; params: ListStackServices; } | { type: "ListCommonStackExtraArgs"; params: ListCommonStackExtraArgs; } | { type: "ListCommonStackBuildExtraArgs"; params: ListCommonStackBuildExtraArgs; } | { type: "GetDeploymentsSummary"; params: GetDeploymentsSummary; } | { type: "GetDeployment"; params: GetDeployment; } | { type: "GetDeploymentContainer"; params: GetDeploymentContainer; } | { type: "GetDeploymentActionState"; params: GetDeploymentActionState; } | { type: "GetDeploymentStats"; params: GetDeploymentStats; } | { type: "GetDeploymentLog"; params: GetDeploymentLog; } | { type: "SearchDeploymentLog"; params: SearchDeploymentLog; } | { type: "InspectDeploymentContainer"; params: InspectDeploymentContainer; } | { type: "ListDeployments"; params: ListDeployments; } | { type: "ListFullDeployments"; params: ListFullDeployments; } | { type: "ListCommonDeploymentExtraArgs"; params: ListCommonDeploymentExtraArgs; } | { type: "GetBuildsSummary"; params: GetBuildsSummary; } | { type: "GetBuild"; params: GetBuild; } | { type: "GetBuildActionState"; params: GetBuildActionState; } | { type: "GetBuildMonthlyStats"; params: GetBuildMonthlyStats; } | { type: "ListBuildVersions"; params: ListBuildVersions; } | { type: "GetBuildWebhookEnabled"; params: GetBuildWebhookEnabled; } | { type: "ListBuilds"; params: ListBuilds; } | { type: "ListFullBuilds"; params: ListFullBuilds; } | { type: "ListCommonBuildExtraArgs"; params: ListCommonBuildExtraArgs; } | { type: "GetReposSummary"; params: GetReposSummary; } | { type: "GetRepo"; params: GetRepo; } | { type: "GetRepoActionState"; params: GetRepoActionState; } | { type: "GetRepoWebhooksEnabled"; params: GetRepoWebhooksEnabled; } | { type: "ListRepos"; params: ListRepos; } | { type: "ListFullRepos"; params: ListFullRepos; } | { type: "GetResourceSyncsSummary"; params: GetResourceSyncsSummary; } | { type: "GetResourceSync"; params: GetResourceSync; } | { type: "GetResourceSyncActionState"; params: GetResourceSyncActionState; } | { type: "GetSyncWebhooksEnabled"; params: GetSyncWebhooksEnabled; } | { type: "ListResourceSyncs"; params: ListResourceSyncs; } | { type: "ListFullResourceSyncs"; params: ListFullResourceSyncs; } | { type: "GetBuildersSummary"; params: GetBuildersSummary; } | { type: "GetBuilder"; params: GetBuilder; } | { type: "ListBuilders"; params: ListBuilders; } | { type: "ListFullBuilders"; params: ListFullBuilders; } | { type: "GetAlertersSummary"; params: GetAlertersSummary; } | { type: "GetAlerter"; params: GetAlerter; } | { type: "ListAlerters"; params: ListAlerters; } | { type: "ListFullAlerters"; params: ListFullAlerters; } | { type: "ExportAllResourcesToToml"; params: ExportAllResourcesToToml; } | { type: "ExportResourcesToToml"; params: ExportResourcesToToml; } | { type: "GetTag"; params: GetTag; } | { type: "ListTags"; params: ListTags; } | { type: "GetUpdate"; params: GetUpdate; } | { type: "ListUpdates"; params: ListUpdates; } | { type: "ListAlerts"; params: ListAlerts; } | { type: "GetAlert"; params: GetAlert; } | { type: "GetVariable"; params: GetVariable; } | { type: "ListVariables"; params: ListVariables; } | { type: "GetGitProviderAccount"; params: GetGitProviderAccount; } | { type: "ListGitProviderAccounts"; params: ListGitProviderAccounts; } | { type: "GetDockerRegistryAccount"; params: GetDockerRegistryAccount; } | { type: "ListDockerRegistryAccounts"; params: ListDockerRegistryAccounts; }; /** The specific types of permission that a User or UserGroup can have on a resource. */ export declare enum SpecificPermission { /** * On **Server** * - Access the terminal apis * On **Stack / Deployment** * - Access the container exec Apis */ Terminal = "Terminal", /** * On **Server** * - Allowed to attach Stacks, Deployments, Repos, Builders to the Server * On **Builder** * - Allowed to attach Builds to the Builder * On **Build** * - Allowed to attach Deployments to the Build */ Attach = "Attach", /** * On **Server** * - Access the `container inspect` apis * On **Stack / Deployment** * - Access `container inspect` apis for associated containers */ Inspect = "Inspect", /** * On **Server** * - Read all container logs on the server * On **Stack / Deployment** * - Read the container logs */ Logs = "Logs", /** * On **Server** * - Read all the processes on the host */ Processes = "Processes" } export type UserRequest = { type: "PushRecentlyViewed"; params: PushRecentlyViewed; } | { type: "SetLastSeenUpdate"; params: SetLastSeenUpdate; } | { type: "CreateApiKey"; params: CreateApiKey; } | { type: "DeleteApiKey"; params: DeleteApiKey; }; export type WriteRequest = { type: "CreateLocalUser"; params: CreateLocalUser; } | { type: "UpdateUserUsername"; params: UpdateUserUsername; } | { type: "UpdateUserPassword"; params: UpdateUserPassword; } | { type: "DeleteUser"; params: DeleteUser; } | { type: "CreateServiceUser"; params: CreateServiceUser; } | { type: "UpdateServiceUserDescription"; params: UpdateServiceUserDescription; } | { type: "CreateApiKeyForServiceUser"; params: CreateApiKeyForServiceUser; } | { type: "DeleteApiKeyForServiceUser"; params: DeleteApiKeyForServiceUser; } | { type: "CreateUserGroup"; params: CreateUserGroup; } | { type: "RenameUserGroup"; params: RenameUserGroup; } | { type: "DeleteUserGroup"; params: DeleteUserGroup; } | { type: "AddUserToUserGroup"; params: AddUserToUserGroup; } | { type: "RemoveUserFromUserGroup"; params: RemoveUserFromUserGroup; } | { type: "SetUsersInUserGroup"; params: SetUsersInUserGroup; } | { type: "SetEveryoneUserGroup"; params: SetEveryoneUserGroup; } | { type: "UpdateUserAdmin"; params: UpdateUserAdmin; } | { type: "UpdateUserBasePermissions"; params: UpdateUserBasePermissions; } | { type: "UpdatePermissionOnResourceType"; params: UpdatePermissionOnResourceType; } | { type: "UpdatePermissionOnTarget"; params: UpdatePermissionOnTarget; } | { type: "UpdateResourceMeta"; params: UpdateResourceMeta; } | { type: "CreateServer"; params: CreateServer; } | { type: "CopyServer"; params: CopyServer; } | { type: "DeleteServer"; params: DeleteServer; } | { type: "UpdateServer"; params: UpdateServer; } | { type: "RenameServer"; params: RenameServer; } | { type: "CreateNetwork"; params: CreateNetwork; } | { type: "CreateTerminal"; params: CreateTerminal; } | { type: "DeleteTerminal"; params: DeleteTerminal; } | { type: "DeleteAllTerminals"; params: DeleteAllTerminals; } | { type: "CreateStack"; params: CreateStack; } | { type: "CopyStack"; params: CopyStack; } | { type: "DeleteStack"; params: DeleteStack; } | { type: "UpdateStack"; params: UpdateStack; } | { type: "RenameStack"; params: RenameStack; } | { type: "WriteStackFileContents"; params: WriteStackFileContents; } | { type: "RefreshStackCache"; params: RefreshStackCache; } | { type: "CreateStackWebhook"; params: CreateStackWebhook; } | { type: "DeleteStackWebhook"; params: DeleteStackWebhook; } | { type: "CreateDeployment"; params: CreateDeployment; } | { type: "CopyDeployment"; params: CopyDeployment; } | { type: "CreateDeploymentFromContainer"; params: CreateDeploymentFromContainer; } | { type: "DeleteDeployment"; params: DeleteDeployment; } | { type: "UpdateDeployment"; params: UpdateDeployment; } | { type: "RenameDeployment"; params: RenameDeployment; } | { type: "CreateBuild"; params: CreateBuild; } | { type: "CopyBuild"; params: CopyBuild; } | { type: "DeleteBuild"; params: DeleteBuild; } | { type: "UpdateBuild"; params: UpdateBuild; } | { type: "RenameBuild"; params: RenameBuild; } | { type: "WriteBuildFileContents"; params: WriteBuildFileContents; } | { type: "RefreshBuildCache"; params: RefreshBuildCache; } | { type: "CreateBuildWebhook"; params: CreateBuildWebhook; } | { type: "DeleteBuildWebhook"; params: DeleteBuildWebhook; } | { type: "CreateBuilder"; params: CreateBuilder; } | { type: "CopyBuilder"; params: CopyBuilder; } | { type: "DeleteBuilder"; params: DeleteBuilder; } | { type: "UpdateBuilder"; params: UpdateBuilder; } | { type: "RenameBuilder"; params: RenameBuilder; } | { type: "CreateRepo"; params: CreateRepo; } | { type: "CopyRepo"; params: CopyRepo; } | { type: "DeleteRepo"; params: DeleteRepo; } | { type: "UpdateRepo"; params: UpdateRepo; } | { type: "RenameRepo"; params: RenameRepo; } | { type: "RefreshRepoCache"; params: RefreshRepoCache; } | { type: "CreateRepoWebhook"; params: CreateRepoWebhook; } | { type: "DeleteRepoWebhook"; params: DeleteRepoWebhook; } | { type: "CreateAlerter"; params: CreateAlerter; } | { type: "CopyAlerter"; params: CopyAlerter; } | { type: "DeleteAlerter"; params: DeleteAlerter; } | { type: "UpdateAlerter"; params: UpdateAlerter; } | { type: "RenameAlerter"; params: RenameAlerter; } | { type: "CreateProcedure"; params: CreateProcedure; } | { type: "CopyProcedure"; params: CopyProcedure; } | { type: "DeleteProcedure"; params: DeleteProcedure; } | { type: "UpdateProcedure"; params: UpdateProcedure; } | { type: "RenameProcedure"; params: RenameProcedure; } | { type: "CreateAction"; params: CreateAction; } | { type: "CopyAction"; params: CopyAction; } | { type: "DeleteAction"; params: DeleteAction; } | { type: "UpdateAction"; params: UpdateAction; } | { type: "RenameAction"; params: RenameAction; } | { type: "CreateResourceSync"; params: CreateResourceSync; } | { type: "CopyResourceSync"; params: CopyResourceSync; } | { type: "DeleteResourceSync"; params: DeleteResourceSync; } | { type: "UpdateResourceSync"; params: UpdateResourceSync; } | { type: "RenameResourceSync"; params: RenameResourceSync; } | { type: "WriteSyncFileContents"; params: WriteSyncFileContents; } | { type: "CommitSync"; params: CommitSync; } | { type: "RefreshResourceSyncPending"; params: RefreshResourceSyncPending; } | { type: "CreateSyncWebhook"; params: CreateSyncWebhook; } | { type: "DeleteSyncWebhook"; params: DeleteSyncWebhook; } | { type: "CreateTag"; params: CreateTag; } | { type: "DeleteTag"; params: DeleteTag; } | { type: "RenameTag"; params: RenameTag; } | { type: "UpdateTagColor"; params: UpdateTagColor; } | { type: "CreateVariable"; params: CreateVariable; } | { type: "UpdateVariableValue"; params: UpdateVariableValue; } | { type: "UpdateVariableDescription"; params: UpdateVariableDescription; } | { type: "UpdateVariableIsSecret"; params: UpdateVariableIsSecret; } | { type: "DeleteVariable"; params: DeleteVariable; } | { type: "CreateGitProviderAccount"; params: CreateGitProviderAccount; } | { type: "UpdateGitProviderAccount"; params: UpdateGitProviderAccount; } | { type: "DeleteGitProviderAccount"; params: DeleteGitProviderAccount; } | { type: "CreateDockerRegistryAccount"; params: CreateDockerRegistryAccount; } | { type: "UpdateDockerRegistryAccount"; params: UpdateDockerRegistryAccount; } | { type: "DeleteDockerRegistryAccount"; params: DeleteDockerRegistryAccount; }; export type WsLoginMessage = { type: "Jwt"; params: { jwt: string; }; } | { type: "ApiKeys"; params: { key: string; secret: string; }; }; ================================================ FILE: frontend/public/client/types.js ================================================ /* Generated by typeshare 1.13.3 */ /** The levels of permission that a User or UserGroup can have on a resource. */ export var PermissionLevel; (function (PermissionLevel) { /** No permissions. */ PermissionLevel["None"] = "None"; /** Can read resource information and config */ PermissionLevel["Read"] = "Read"; /** Can execute actions on the resource */ PermissionLevel["Execute"] = "Execute"; /** Can update the resource configuration */ PermissionLevel["Write"] = "Write"; })(PermissionLevel || (PermissionLevel = {})); export var ScheduleFormat; (function (ScheduleFormat) { ScheduleFormat["English"] = "English"; ScheduleFormat["Cron"] = "Cron"; })(ScheduleFormat || (ScheduleFormat = {})); export var FileFormat; (function (FileFormat) { FileFormat["KeyValue"] = "key_value"; FileFormat["Toml"] = "toml"; FileFormat["Yaml"] = "yaml"; FileFormat["Json"] = "json"; })(FileFormat || (FileFormat = {})); export var ActionState; (function (ActionState) { /** Unknown case */ ActionState["Unknown"] = "Unknown"; /** Last clone / pull successful (or never cloned) */ ActionState["Ok"] = "Ok"; /** Last clone / pull failed */ ActionState["Failed"] = "Failed"; /** Currently running */ ActionState["Running"] = "Running"; })(ActionState || (ActionState = {})); export var TemplatesQueryBehavior; (function (TemplatesQueryBehavior) { /** Include templates in results. Default. */ TemplatesQueryBehavior["Include"] = "Include"; /** Exclude templates from results. */ TemplatesQueryBehavior["Exclude"] = "Exclude"; /** Results *only* includes templates. */ TemplatesQueryBehavior["Only"] = "Only"; })(TemplatesQueryBehavior || (TemplatesQueryBehavior = {})); export var TagQueryBehavior; (function (TagQueryBehavior) { /** Returns resources which have strictly all the tags */ TagQueryBehavior["All"] = "All"; /** Returns resources which have one or more of the tags */ TagQueryBehavior["Any"] = "Any"; })(TagQueryBehavior || (TagQueryBehavior = {})); /** Types of maintenance schedules */ export var MaintenanceScheduleType; (function (MaintenanceScheduleType) { /** Daily at the specified time */ MaintenanceScheduleType["Daily"] = "Daily"; /** Weekly on the specified day and time */ MaintenanceScheduleType["Weekly"] = "Weekly"; /** One-time maintenance on a specific date and time */ MaintenanceScheduleType["OneTime"] = "OneTime"; })(MaintenanceScheduleType || (MaintenanceScheduleType = {})); export var Operation; (function (Operation) { Operation["None"] = "None"; Operation["CreateServer"] = "CreateServer"; Operation["UpdateServer"] = "UpdateServer"; Operation["DeleteServer"] = "DeleteServer"; Operation["RenameServer"] = "RenameServer"; Operation["StartContainer"] = "StartContainer"; Operation["RestartContainer"] = "RestartContainer"; Operation["PauseContainer"] = "PauseContainer"; Operation["UnpauseContainer"] = "UnpauseContainer"; Operation["StopContainer"] = "StopContainer"; Operation["DestroyContainer"] = "DestroyContainer"; Operation["StartAllContainers"] = "StartAllContainers"; Operation["RestartAllContainers"] = "RestartAllContainers"; Operation["PauseAllContainers"] = "PauseAllContainers"; Operation["UnpauseAllContainers"] = "UnpauseAllContainers"; Operation["StopAllContainers"] = "StopAllContainers"; Operation["PruneContainers"] = "PruneContainers"; Operation["CreateNetwork"] = "CreateNetwork"; Operation["DeleteNetwork"] = "DeleteNetwork"; Operation["PruneNetworks"] = "PruneNetworks"; Operation["DeleteImage"] = "DeleteImage"; Operation["PruneImages"] = "PruneImages"; Operation["DeleteVolume"] = "DeleteVolume"; Operation["PruneVolumes"] = "PruneVolumes"; Operation["PruneDockerBuilders"] = "PruneDockerBuilders"; Operation["PruneBuildx"] = "PruneBuildx"; Operation["PruneSystem"] = "PruneSystem"; Operation["CreateStack"] = "CreateStack"; Operation["UpdateStack"] = "UpdateStack"; Operation["RenameStack"] = "RenameStack"; Operation["DeleteStack"] = "DeleteStack"; Operation["WriteStackContents"] = "WriteStackContents"; Operation["RefreshStackCache"] = "RefreshStackCache"; Operation["PullStack"] = "PullStack"; Operation["DeployStack"] = "DeployStack"; Operation["StartStack"] = "StartStack"; Operation["RestartStack"] = "RestartStack"; Operation["PauseStack"] = "PauseStack"; Operation["UnpauseStack"] = "UnpauseStack"; Operation["StopStack"] = "StopStack"; Operation["DestroyStack"] = "DestroyStack"; Operation["RunStackService"] = "RunStackService"; Operation["DeployStackService"] = "DeployStackService"; Operation["PullStackService"] = "PullStackService"; Operation["StartStackService"] = "StartStackService"; Operation["RestartStackService"] = "RestartStackService"; Operation["PauseStackService"] = "PauseStackService"; Operation["UnpauseStackService"] = "UnpauseStackService"; Operation["StopStackService"] = "StopStackService"; Operation["DestroyStackService"] = "DestroyStackService"; Operation["CreateDeployment"] = "CreateDeployment"; Operation["UpdateDeployment"] = "UpdateDeployment"; Operation["RenameDeployment"] = "RenameDeployment"; Operation["DeleteDeployment"] = "DeleteDeployment"; Operation["Deploy"] = "Deploy"; Operation["PullDeployment"] = "PullDeployment"; Operation["StartDeployment"] = "StartDeployment"; Operation["RestartDeployment"] = "RestartDeployment"; Operation["PauseDeployment"] = "PauseDeployment"; Operation["UnpauseDeployment"] = "UnpauseDeployment"; Operation["StopDeployment"] = "StopDeployment"; Operation["DestroyDeployment"] = "DestroyDeployment"; Operation["CreateBuild"] = "CreateBuild"; Operation["UpdateBuild"] = "UpdateBuild"; Operation["RenameBuild"] = "RenameBuild"; Operation["DeleteBuild"] = "DeleteBuild"; Operation["RunBuild"] = "RunBuild"; Operation["CancelBuild"] = "CancelBuild"; Operation["WriteDockerfile"] = "WriteDockerfile"; Operation["CreateRepo"] = "CreateRepo"; Operation["UpdateRepo"] = "UpdateRepo"; Operation["RenameRepo"] = "RenameRepo"; Operation["DeleteRepo"] = "DeleteRepo"; Operation["CloneRepo"] = "CloneRepo"; Operation["PullRepo"] = "PullRepo"; Operation["BuildRepo"] = "BuildRepo"; Operation["CancelRepoBuild"] = "CancelRepoBuild"; Operation["CreateProcedure"] = "CreateProcedure"; Operation["UpdateProcedure"] = "UpdateProcedure"; Operation["RenameProcedure"] = "RenameProcedure"; Operation["DeleteProcedure"] = "DeleteProcedure"; Operation["RunProcedure"] = "RunProcedure"; Operation["CreateAction"] = "CreateAction"; Operation["UpdateAction"] = "UpdateAction"; Operation["RenameAction"] = "RenameAction"; Operation["DeleteAction"] = "DeleteAction"; Operation["RunAction"] = "RunAction"; Operation["CreateBuilder"] = "CreateBuilder"; Operation["UpdateBuilder"] = "UpdateBuilder"; Operation["RenameBuilder"] = "RenameBuilder"; Operation["DeleteBuilder"] = "DeleteBuilder"; Operation["CreateAlerter"] = "CreateAlerter"; Operation["UpdateAlerter"] = "UpdateAlerter"; Operation["RenameAlerter"] = "RenameAlerter"; Operation["DeleteAlerter"] = "DeleteAlerter"; Operation["TestAlerter"] = "TestAlerter"; Operation["SendAlert"] = "SendAlert"; Operation["CreateResourceSync"] = "CreateResourceSync"; Operation["UpdateResourceSync"] = "UpdateResourceSync"; Operation["RenameResourceSync"] = "RenameResourceSync"; Operation["DeleteResourceSync"] = "DeleteResourceSync"; Operation["WriteSyncContents"] = "WriteSyncContents"; Operation["CommitSync"] = "CommitSync"; Operation["RunSync"] = "RunSync"; Operation["ClearRepoCache"] = "ClearRepoCache"; Operation["BackupCoreDatabase"] = "BackupCoreDatabase"; Operation["GlobalAutoUpdate"] = "GlobalAutoUpdate"; Operation["CreateVariable"] = "CreateVariable"; Operation["UpdateVariableValue"] = "UpdateVariableValue"; Operation["DeleteVariable"] = "DeleteVariable"; Operation["CreateGitProviderAccount"] = "CreateGitProviderAccount"; Operation["UpdateGitProviderAccount"] = "UpdateGitProviderAccount"; Operation["DeleteGitProviderAccount"] = "DeleteGitProviderAccount"; Operation["CreateDockerRegistryAccount"] = "CreateDockerRegistryAccount"; Operation["UpdateDockerRegistryAccount"] = "UpdateDockerRegistryAccount"; Operation["DeleteDockerRegistryAccount"] = "DeleteDockerRegistryAccount"; })(Operation || (Operation = {})); /** An update's status */ export var UpdateStatus; (function (UpdateStatus) { /** The run is in the system but hasn't started yet */ UpdateStatus["Queued"] = "Queued"; /** The run is currently running */ UpdateStatus["InProgress"] = "InProgress"; /** The run is complete */ UpdateStatus["Complete"] = "Complete"; })(UpdateStatus || (UpdateStatus = {})); export var BuildState; (function (BuildState) { /** Currently building */ BuildState["Building"] = "Building"; /** Last build successful (or never built) */ BuildState["Ok"] = "Ok"; /** Last build failed */ BuildState["Failed"] = "Failed"; /** Other case */ BuildState["Unknown"] = "Unknown"; })(BuildState || (BuildState = {})); export var RestartMode; (function (RestartMode) { RestartMode["NoRestart"] = "no"; RestartMode["OnFailure"] = "on-failure"; RestartMode["Always"] = "always"; RestartMode["UnlessStopped"] = "unless-stopped"; })(RestartMode || (RestartMode = {})); export var TerminationSignal; (function (TerminationSignal) { TerminationSignal["SigHup"] = "SIGHUP"; TerminationSignal["SigInt"] = "SIGINT"; TerminationSignal["SigQuit"] = "SIGQUIT"; TerminationSignal["SigTerm"] = "SIGTERM"; })(TerminationSignal || (TerminationSignal = {})); /** * Variants de/serialized from/to snake_case. * * Eg. * - NotDeployed -> not_deployed * - Restarting -> restarting * - Running -> running. */ export var DeploymentState; (function (DeploymentState) { /** The deployment is currently re/deploying */ DeploymentState["Deploying"] = "deploying"; /** Container is running */ DeploymentState["Running"] = "running"; /** Container is created but not running */ DeploymentState["Created"] = "created"; /** Container is in restart loop */ DeploymentState["Restarting"] = "restarting"; /** Container is being removed */ DeploymentState["Removing"] = "removing"; /** Container is paused */ DeploymentState["Paused"] = "paused"; /** Container is exited */ DeploymentState["Exited"] = "exited"; /** Container is dead */ DeploymentState["Dead"] = "dead"; /** The deployment is not deployed (no matching container) */ DeploymentState["NotDeployed"] = "not_deployed"; /** Server not reachable for status */ DeploymentState["Unknown"] = "unknown"; })(DeploymentState || (DeploymentState = {})); /** Severity level of problem. */ export var SeverityLevel; (function (SeverityLevel) { /** * No problem. * * Aliases: ok, low, l */ SeverityLevel["Ok"] = "OK"; /** * Problem is imminent. * * Aliases: warning, w, medium, m */ SeverityLevel["Warning"] = "WARNING"; /** * Problem fully realized. * * Aliases: critical, c, high, h */ SeverityLevel["Critical"] = "CRITICAL"; })(SeverityLevel || (SeverityLevel = {})); export var StackFileRequires; (function (StackFileRequires) { /** Diff requires service redeploy. */ StackFileRequires["Redeploy"] = "Redeploy"; /** Diff requires service restart */ StackFileRequires["Restart"] = "Restart"; /** Diff requires no action. Default. */ StackFileRequires["None"] = "None"; })(StackFileRequires || (StackFileRequires = {})); export var Timelength; (function (Timelength) { /** `1-sec` */ Timelength["OneSecond"] = "1-sec"; /** `5-sec` */ Timelength["FiveSeconds"] = "5-sec"; /** `10-sec` */ Timelength["TenSeconds"] = "10-sec"; /** `15-sec` */ Timelength["FifteenSeconds"] = "15-sec"; /** `30-sec` */ Timelength["ThirtySeconds"] = "30-sec"; /** `1-min` */ Timelength["OneMinute"] = "1-min"; /** `2-min` */ Timelength["TwoMinutes"] = "2-min"; /** `5-min` */ Timelength["FiveMinutes"] = "5-min"; /** `10-min` */ Timelength["TenMinutes"] = "10-min"; /** `15-min` */ Timelength["FifteenMinutes"] = "15-min"; /** `30-min` */ Timelength["ThirtyMinutes"] = "30-min"; /** `1-hr` */ Timelength["OneHour"] = "1-hr"; /** `2-hr` */ Timelength["TwoHours"] = "2-hr"; /** `6-hr` */ Timelength["SixHours"] = "6-hr"; /** `8-hr` */ Timelength["EightHours"] = "8-hr"; /** `12-hr` */ Timelength["TwelveHours"] = "12-hr"; /** `1-day` */ Timelength["OneDay"] = "1-day"; /** `3-day` */ Timelength["ThreeDay"] = "3-day"; /** `1-wk` */ Timelength["OneWeek"] = "1-wk"; /** `2-wk` */ Timelength["TwoWeeks"] = "2-wk"; /** `30-day` */ Timelength["ThirtyDays"] = "30-day"; })(Timelength || (Timelength = {})); export var TagColor; (function (TagColor) { TagColor["LightSlate"] = "LightSlate"; TagColor["Slate"] = "Slate"; TagColor["DarkSlate"] = "DarkSlate"; TagColor["LightRed"] = "LightRed"; TagColor["Red"] = "Red"; TagColor["DarkRed"] = "DarkRed"; TagColor["LightOrange"] = "LightOrange"; TagColor["Orange"] = "Orange"; TagColor["DarkOrange"] = "DarkOrange"; TagColor["LightAmber"] = "LightAmber"; TagColor["Amber"] = "Amber"; TagColor["DarkAmber"] = "DarkAmber"; TagColor["LightYellow"] = "LightYellow"; TagColor["Yellow"] = "Yellow"; TagColor["DarkYellow"] = "DarkYellow"; TagColor["LightLime"] = "LightLime"; TagColor["Lime"] = "Lime"; TagColor["DarkLime"] = "DarkLime"; TagColor["LightGreen"] = "LightGreen"; TagColor["Green"] = "Green"; TagColor["DarkGreen"] = "DarkGreen"; TagColor["LightEmerald"] = "LightEmerald"; TagColor["Emerald"] = "Emerald"; TagColor["DarkEmerald"] = "DarkEmerald"; TagColor["LightTeal"] = "LightTeal"; TagColor["Teal"] = "Teal"; TagColor["DarkTeal"] = "DarkTeal"; TagColor["LightCyan"] = "LightCyan"; TagColor["Cyan"] = "Cyan"; TagColor["DarkCyan"] = "DarkCyan"; TagColor["LightSky"] = "LightSky"; TagColor["Sky"] = "Sky"; TagColor["DarkSky"] = "DarkSky"; TagColor["LightBlue"] = "LightBlue"; TagColor["Blue"] = "Blue"; TagColor["DarkBlue"] = "DarkBlue"; TagColor["LightIndigo"] = "LightIndigo"; TagColor["Indigo"] = "Indigo"; TagColor["DarkIndigo"] = "DarkIndigo"; TagColor["LightViolet"] = "LightViolet"; TagColor["Violet"] = "Violet"; TagColor["DarkViolet"] = "DarkViolet"; TagColor["LightPurple"] = "LightPurple"; TagColor["Purple"] = "Purple"; TagColor["DarkPurple"] = "DarkPurple"; TagColor["LightFuchsia"] = "LightFuchsia"; TagColor["Fuchsia"] = "Fuchsia"; TagColor["DarkFuchsia"] = "DarkFuchsia"; TagColor["LightPink"] = "LightPink"; TagColor["Pink"] = "Pink"; TagColor["DarkPink"] = "DarkPink"; TagColor["LightRose"] = "LightRose"; TagColor["Rose"] = "Rose"; TagColor["DarkRose"] = "DarkRose"; })(TagColor || (TagColor = {})); export var ContainerStateStatusEnum; (function (ContainerStateStatusEnum) { ContainerStateStatusEnum["Running"] = "running"; ContainerStateStatusEnum["Created"] = "created"; ContainerStateStatusEnum["Paused"] = "paused"; ContainerStateStatusEnum["Restarting"] = "restarting"; ContainerStateStatusEnum["Exited"] = "exited"; ContainerStateStatusEnum["Removing"] = "removing"; ContainerStateStatusEnum["Dead"] = "dead"; ContainerStateStatusEnum["Empty"] = ""; })(ContainerStateStatusEnum || (ContainerStateStatusEnum = {})); export var HealthStatusEnum; (function (HealthStatusEnum) { HealthStatusEnum["Empty"] = ""; HealthStatusEnum["None"] = "none"; HealthStatusEnum["Starting"] = "starting"; HealthStatusEnum["Healthy"] = "healthy"; HealthStatusEnum["Unhealthy"] = "unhealthy"; })(HealthStatusEnum || (HealthStatusEnum = {})); export var RestartPolicyNameEnum; (function (RestartPolicyNameEnum) { RestartPolicyNameEnum["Empty"] = ""; RestartPolicyNameEnum["No"] = "no"; RestartPolicyNameEnum["Always"] = "always"; RestartPolicyNameEnum["UnlessStopped"] = "unless-stopped"; RestartPolicyNameEnum["OnFailure"] = "on-failure"; })(RestartPolicyNameEnum || (RestartPolicyNameEnum = {})); export var MountTypeEnum; (function (MountTypeEnum) { MountTypeEnum["Empty"] = ""; MountTypeEnum["Bind"] = "bind"; MountTypeEnum["Volume"] = "volume"; MountTypeEnum["Image"] = "image"; MountTypeEnum["Tmpfs"] = "tmpfs"; MountTypeEnum["Npipe"] = "npipe"; MountTypeEnum["Cluster"] = "cluster"; })(MountTypeEnum || (MountTypeEnum = {})); export var MountBindOptionsPropagationEnum; (function (MountBindOptionsPropagationEnum) { MountBindOptionsPropagationEnum["Empty"] = ""; MountBindOptionsPropagationEnum["Private"] = "private"; MountBindOptionsPropagationEnum["Rprivate"] = "rprivate"; MountBindOptionsPropagationEnum["Shared"] = "shared"; MountBindOptionsPropagationEnum["Rshared"] = "rshared"; MountBindOptionsPropagationEnum["Slave"] = "slave"; MountBindOptionsPropagationEnum["Rslave"] = "rslave"; })(MountBindOptionsPropagationEnum || (MountBindOptionsPropagationEnum = {})); export var HostConfigCgroupnsModeEnum; (function (HostConfigCgroupnsModeEnum) { HostConfigCgroupnsModeEnum["Empty"] = ""; HostConfigCgroupnsModeEnum["Private"] = "private"; HostConfigCgroupnsModeEnum["Host"] = "host"; })(HostConfigCgroupnsModeEnum || (HostConfigCgroupnsModeEnum = {})); export var HostConfigIsolationEnum; (function (HostConfigIsolationEnum) { HostConfigIsolationEnum["Empty"] = ""; HostConfigIsolationEnum["Default"] = "default"; HostConfigIsolationEnum["Process"] = "process"; HostConfigIsolationEnum["Hyperv"] = "hyperv"; })(HostConfigIsolationEnum || (HostConfigIsolationEnum = {})); export var VolumeScopeEnum; (function (VolumeScopeEnum) { VolumeScopeEnum["Empty"] = ""; VolumeScopeEnum["Local"] = "local"; VolumeScopeEnum["Global"] = "global"; })(VolumeScopeEnum || (VolumeScopeEnum = {})); export var ClusterVolumeSpecAccessModeScopeEnum; (function (ClusterVolumeSpecAccessModeScopeEnum) { ClusterVolumeSpecAccessModeScopeEnum["Empty"] = ""; ClusterVolumeSpecAccessModeScopeEnum["Single"] = "single"; ClusterVolumeSpecAccessModeScopeEnum["Multi"] = "multi"; })(ClusterVolumeSpecAccessModeScopeEnum || (ClusterVolumeSpecAccessModeScopeEnum = {})); export var ClusterVolumeSpecAccessModeSharingEnum; (function (ClusterVolumeSpecAccessModeSharingEnum) { ClusterVolumeSpecAccessModeSharingEnum["Empty"] = ""; ClusterVolumeSpecAccessModeSharingEnum["None"] = "none"; ClusterVolumeSpecAccessModeSharingEnum["Readonly"] = "readonly"; ClusterVolumeSpecAccessModeSharingEnum["Onewriter"] = "onewriter"; ClusterVolumeSpecAccessModeSharingEnum["All"] = "all"; })(ClusterVolumeSpecAccessModeSharingEnum || (ClusterVolumeSpecAccessModeSharingEnum = {})); export var ClusterVolumeSpecAccessModeAvailabilityEnum; (function (ClusterVolumeSpecAccessModeAvailabilityEnum) { ClusterVolumeSpecAccessModeAvailabilityEnum["Empty"] = ""; ClusterVolumeSpecAccessModeAvailabilityEnum["Active"] = "active"; ClusterVolumeSpecAccessModeAvailabilityEnum["Pause"] = "pause"; ClusterVolumeSpecAccessModeAvailabilityEnum["Drain"] = "drain"; })(ClusterVolumeSpecAccessModeAvailabilityEnum || (ClusterVolumeSpecAccessModeAvailabilityEnum = {})); export var ClusterVolumePublishStatusStateEnum; (function (ClusterVolumePublishStatusStateEnum) { ClusterVolumePublishStatusStateEnum["Empty"] = ""; ClusterVolumePublishStatusStateEnum["PendingPublish"] = "pending-publish"; ClusterVolumePublishStatusStateEnum["Published"] = "published"; ClusterVolumePublishStatusStateEnum["PendingNodeUnpublish"] = "pending-node-unpublish"; ClusterVolumePublishStatusStateEnum["PendingControllerUnpublish"] = "pending-controller-unpublish"; })(ClusterVolumePublishStatusStateEnum || (ClusterVolumePublishStatusStateEnum = {})); export var PortTypeEnum; (function (PortTypeEnum) { PortTypeEnum["EMPTY"] = ""; PortTypeEnum["TCP"] = "tcp"; PortTypeEnum["UDP"] = "udp"; PortTypeEnum["SCTP"] = "sctp"; })(PortTypeEnum || (PortTypeEnum = {})); export var ProcedureState; (function (ProcedureState) { /** Currently running */ ProcedureState["Running"] = "Running"; /** Last run successful */ ProcedureState["Ok"] = "Ok"; /** Last run failed */ ProcedureState["Failed"] = "Failed"; /** Other case (never run) */ ProcedureState["Unknown"] = "Unknown"; })(ProcedureState || (ProcedureState = {})); export var RepoState; (function (RepoState) { /** Unknown case */ RepoState["Unknown"] = "Unknown"; /** Last clone / pull successful (or never cloned) */ RepoState["Ok"] = "Ok"; /** Last clone / pull failed */ RepoState["Failed"] = "Failed"; /** Currently cloning */ RepoState["Cloning"] = "Cloning"; /** Currently pulling */ RepoState["Pulling"] = "Pulling"; /** Currently building */ RepoState["Building"] = "Building"; })(RepoState || (RepoState = {})); export var ResourceSyncState; (function (ResourceSyncState) { /** Currently syncing */ ResourceSyncState["Syncing"] = "Syncing"; /** Updates pending */ ResourceSyncState["Pending"] = "Pending"; /** Last sync successful (or never synced). No Changes pending */ ResourceSyncState["Ok"] = "Ok"; /** Last sync failed */ ResourceSyncState["Failed"] = "Failed"; /** Other case */ ResourceSyncState["Unknown"] = "Unknown"; })(ResourceSyncState || (ResourceSyncState = {})); export var ServerState; (function (ServerState) { /** Server health check passing. */ ServerState["Ok"] = "Ok"; /** Server is unreachable. */ ServerState["NotOk"] = "NotOk"; /** Server is disabled. */ ServerState["Disabled"] = "Disabled"; })(ServerState || (ServerState = {})); export var StackState; (function (StackState) { /** The stack is currently re/deploying */ StackState["Deploying"] = "deploying"; /** All containers are running. */ StackState["Running"] = "running"; /** All containers are paused */ StackState["Paused"] = "paused"; /** All contianers are stopped */ StackState["Stopped"] = "stopped"; /** All containers are created */ StackState["Created"] = "created"; /** All containers are restarting */ StackState["Restarting"] = "restarting"; /** All containers are dead */ StackState["Dead"] = "dead"; /** All containers are removing */ StackState["Removing"] = "removing"; /** The containers are in a mix of states */ StackState["Unhealthy"] = "unhealthy"; /** The stack is not deployed */ StackState["Down"] = "down"; /** Server not reachable for status */ StackState["Unknown"] = "unknown"; })(StackState || (StackState = {})); export var RepoWebhookAction; (function (RepoWebhookAction) { RepoWebhookAction["Clone"] = "Clone"; RepoWebhookAction["Pull"] = "Pull"; RepoWebhookAction["Build"] = "Build"; })(RepoWebhookAction || (RepoWebhookAction = {})); export var StackWebhookAction; (function (StackWebhookAction) { StackWebhookAction["Refresh"] = "Refresh"; StackWebhookAction["Deploy"] = "Deploy"; })(StackWebhookAction || (StackWebhookAction = {})); export var SyncWebhookAction; (function (SyncWebhookAction) { SyncWebhookAction["Refresh"] = "Refresh"; SyncWebhookAction["Sync"] = "Sync"; })(SyncWebhookAction || (SyncWebhookAction = {})); /** * Configures the behavior of [CreateTerminal] if the * specified terminal name already exists. */ export var TerminalRecreateMode; (function (TerminalRecreateMode) { /** * Never kill the old terminal if it already exists. * If the command is different, returns error. */ TerminalRecreateMode["Never"] = "Never"; /** Always kill the old terminal and create new one */ TerminalRecreateMode["Always"] = "Always"; /** Only kill and recreate if the command is different. */ TerminalRecreateMode["DifferentCommand"] = "DifferentCommand"; })(TerminalRecreateMode || (TerminalRecreateMode = {})); export var DefaultRepoFolder; (function (DefaultRepoFolder) { /** /${root_directory}/stacks */ DefaultRepoFolder["Stacks"] = "Stacks"; /** /${root_directory}/builds */ DefaultRepoFolder["Builds"] = "Builds"; /** /${root_directory}/repos */ DefaultRepoFolder["Repos"] = "Repos"; /** * If the repo is only cloned * in the core repo cache (resource sync), * this isn't relevant. */ DefaultRepoFolder["NotApplicable"] = "NotApplicable"; })(DefaultRepoFolder || (DefaultRepoFolder = {})); export var SearchCombinator; (function (SearchCombinator) { SearchCombinator["Or"] = "Or"; SearchCombinator["And"] = "And"; })(SearchCombinator || (SearchCombinator = {})); /** Days of the week */ export var DayOfWeek; (function (DayOfWeek) { DayOfWeek["Monday"] = "Monday"; DayOfWeek["Tuesday"] = "Tuesday"; DayOfWeek["Wednesday"] = "Wednesday"; DayOfWeek["Thursday"] = "Thursday"; DayOfWeek["Friday"] = "Friday"; DayOfWeek["Saturday"] = "Saturday"; DayOfWeek["Sunday"] = "Sunday"; })(DayOfWeek || (DayOfWeek = {})); /** * One representative IANA zone for each distinct base UTC offset in the tz database. * https://en.wikipedia.org/wiki/List_of_tz_database_time_zones. * * The `serde`/`strum` renames ensure the canonical identifier is used * when serializing or parsing from a string such as `"Etc/UTC"`. */ export var IanaTimezone; (function (IanaTimezone) { /** UTC−12:00 */ IanaTimezone["EtcGmtMinus12"] = "Etc/GMT+12"; /** UTC−11:00 */ IanaTimezone["PacificPagoPago"] = "Pacific/Pago_Pago"; /** UTC−10:00 */ IanaTimezone["PacificHonolulu"] = "Pacific/Honolulu"; /** UTC−09:30 */ IanaTimezone["PacificMarquesas"] = "Pacific/Marquesas"; /** UTC−09:00 */ IanaTimezone["AmericaAnchorage"] = "America/Anchorage"; /** UTC−08:00 */ IanaTimezone["AmericaLosAngeles"] = "America/Los_Angeles"; /** UTC−07:00 */ IanaTimezone["AmericaDenver"] = "America/Denver"; /** UTC−06:00 */ IanaTimezone["AmericaChicago"] = "America/Chicago"; /** UTC−05:00 */ IanaTimezone["AmericaNewYork"] = "America/New_York"; /** UTC−04:00 */ IanaTimezone["AmericaHalifax"] = "America/Halifax"; /** UTC−03:30 */ IanaTimezone["AmericaStJohns"] = "America/St_Johns"; /** UTC−03:00 */ IanaTimezone["AmericaSaoPaulo"] = "America/Sao_Paulo"; /** UTC−02:00 */ IanaTimezone["AmericaNoronha"] = "America/Noronha"; /** UTC−01:00 */ IanaTimezone["AtlanticAzores"] = "Atlantic/Azores"; /** UTC±00:00 */ IanaTimezone["EtcUtc"] = "Etc/UTC"; /** UTC+01:00 */ IanaTimezone["EuropeBerlin"] = "Europe/Berlin"; /** UTC+02:00 */ IanaTimezone["EuropeBucharest"] = "Europe/Bucharest"; /** UTC+03:00 */ IanaTimezone["EuropeMoscow"] = "Europe/Moscow"; /** UTC+03:30 */ IanaTimezone["AsiaTehran"] = "Asia/Tehran"; /** UTC+04:00 */ IanaTimezone["AsiaDubai"] = "Asia/Dubai"; /** UTC+04:30 */ IanaTimezone["AsiaKabul"] = "Asia/Kabul"; /** UTC+05:00 */ IanaTimezone["AsiaKarachi"] = "Asia/Karachi"; /** UTC+05:30 */ IanaTimezone["AsiaKolkata"] = "Asia/Kolkata"; /** UTC+05:45 */ IanaTimezone["AsiaKathmandu"] = "Asia/Kathmandu"; /** UTC+06:00 */ IanaTimezone["AsiaDhaka"] = "Asia/Dhaka"; /** UTC+06:30 */ IanaTimezone["AsiaYangon"] = "Asia/Yangon"; /** UTC+07:00 */ IanaTimezone["AsiaBangkok"] = "Asia/Bangkok"; /** UTC+08:00 */ IanaTimezone["AsiaShanghai"] = "Asia/Shanghai"; /** UTC+08:45 */ IanaTimezone["AustraliaEucla"] = "Australia/Eucla"; /** UTC+09:00 */ IanaTimezone["AsiaTokyo"] = "Asia/Tokyo"; /** UTC+09:30 */ IanaTimezone["AustraliaAdelaide"] = "Australia/Adelaide"; /** UTC+10:00 */ IanaTimezone["AustraliaSydney"] = "Australia/Sydney"; /** UTC+10:30 */ IanaTimezone["AustraliaLordHowe"] = "Australia/Lord_Howe"; /** UTC+11:00 */ IanaTimezone["PacificPortMoresby"] = "Pacific/Port_Moresby"; /** UTC+12:00 */ IanaTimezone["PacificAuckland"] = "Pacific/Auckland"; /** UTC+12:45 */ IanaTimezone["PacificChatham"] = "Pacific/Chatham"; /** UTC+13:00 */ IanaTimezone["PacificTongatapu"] = "Pacific/Tongatapu"; /** UTC+14:00 */ IanaTimezone["PacificKiritimati"] = "Pacific/Kiritimati"; })(IanaTimezone || (IanaTimezone = {})); /** The specific types of permission that a User or UserGroup can have on a resource. */ export var SpecificPermission; (function (SpecificPermission) { /** * On **Server** * - Access the terminal apis * On **Stack / Deployment** * - Access the container exec Apis */ SpecificPermission["Terminal"] = "Terminal"; /** * On **Server** * - Allowed to attach Stacks, Deployments, Repos, Builders to the Server * On **Builder** * - Allowed to attach Builds to the Builder * On **Build** * - Allowed to attach Deployments to the Build */ SpecificPermission["Attach"] = "Attach"; /** * On **Server** * - Access the `container inspect` apis * On **Stack / Deployment** * - Access `container inspect` apis for associated containers */ SpecificPermission["Inspect"] = "Inspect"; /** * On **Server** * - Read all container logs on the server * On **Stack / Deployment** * - Read the container logs */ SpecificPermission["Logs"] = "Logs"; /** * On **Server** * - Read all the processes on the host */ SpecificPermission["Processes"] = "Processes"; })(SpecificPermission || (SpecificPermission = {})); ================================================ FILE: frontend/public/deno.d.ts ================================================ // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. /** Deno provides extra properties on `import.meta`. These are included here * to ensure that these are still available when using the Deno namespace in * conjunction with other type libs, like `dom`. * * @category Platform */ interface ImportMeta { /** A string representation of the fully qualified module URL. When the * module is loaded locally, the value will be a file URL (e.g. * `file:///path/module.ts`). * * You can also parse the string as a URL to determine more information about * how the current module was loaded. For example to determine if a module was * local or not: * * ```ts * const url = new URL(import.meta.url); * if (url.protocol === "file:") { * console.log("this module was loaded locally"); * } * ``` */ url: string; /** The absolute path of the current module. * * This property is only provided for local modules (ie. using `file://` URLs). * * Example: * ``` * // Unix * console.log(import.meta.filename); // /home/alice/my_module.ts * * // Windows * console.log(import.meta.filename); // C:\alice\my_module.ts * ``` */ filename?: string; /** The absolute path of the directory containing the current module. * * This property is only provided for local modules (ie. using `file://` URLs). * * * Example: * ``` * // Unix * console.log(import.meta.dirname); // /home/alice * * // Windows * console.log(import.meta.dirname); // C:\alice * ``` */ dirname?: string; /** A flag that indicates if the current module is the main module that was * called when starting the program under Deno. * * ```ts * if (import.meta.main) { * // this was loaded as the main module, maybe do some bootstrapping * } * ``` */ main: boolean; /** A function that returns resolved specifier as if it would be imported * using `import(specifier)`. * * ```ts * console.log(import.meta.resolve("./foo.js")); * // file:///dev/foo.js * ``` */ resolve(specifier: string): string; } /** Deno supports [User Timing Level 3](https://w3c.github.io/user-timing) * which is not widely supported yet in other runtimes. * * Check out the * [Performance API](https://developer.mozilla.org/en-US/docs/Web/API/Performance) * documentation on MDN for further information about how to use the API. * * @category Performance */ interface Performance { /** Stores a timestamp with the associated name (a "mark"). */ mark(markName: string, options?: PerformanceMarkOptions): PerformanceMark; /** Stores the `DOMHighResTimeStamp` duration between two marks along with the * associated name (a "measure"). */ measure( measureName: string, options?: PerformanceMeasureOptions ): PerformanceMeasure; } /** * Options which are used in conjunction with `performance.mark`. Check out the * MDN * [`performance.mark()`](https://developer.mozilla.org/en-US/docs/Web/API/Performance/mark#markoptions) * documentation for more details. * * @category Performance */ interface PerformanceMarkOptions { /** Metadata to be included in the mark. */ // deno-lint-ignore no-explicit-any detail?: any; /** Timestamp to be used as the mark time. */ startTime?: number; } /** * Options which are used in conjunction with `performance.measure`. Check out the * MDN * [`performance.mark()`](https://developer.mozilla.org/en-US/docs/Web/API/Performance/measure#measureoptions) * documentation for more details. * * @category Performance */ interface PerformanceMeasureOptions { /** Metadata to be included in the measure. */ // deno-lint-ignore no-explicit-any detail?: any; /** Timestamp to be used as the start time or string to be used as start * mark. */ start?: string | number; /** Duration between the start and end times. */ duration?: number; /** Timestamp to be used as the end time or string to be used as end mark. */ end?: string | number; } /** The global namespace where Deno specific, non-standard APIs are located. */ declare namespace Deno { /** A set of error constructors that are raised by Deno APIs. * * Can be used to provide more specific handling of failures within code * which is using Deno APIs. For example, handling attempting to open a file * which does not exist: * * ```ts * try { * const file = await Deno.open("./some/file.txt"); * } catch (error) { * if (error instanceof Deno.errors.NotFound) { * console.error("the file was not found"); * } else { * // otherwise re-throw * throw error; * } * } * ``` * * @category Errors */ export namespace errors { /** * Raised when the underlying operating system indicates that the file * was not found. * * @category Errors */ export class NotFound extends Error {} /** * Raised when the underlying operating system indicates the current user * which the Deno process is running under does not have the appropriate * permissions to a file or resource. * * Before Deno 2.0, this error was raised when the user _did not_ provide * required `--allow-*` flag. As of Deno 2.0, that case is now handled by * the {@link NotCapable} error. * * @category Errors */ export class PermissionDenied extends Error {} /** * Raised when the underlying operating system reports that a connection to * a resource is refused. * * @category Errors */ export class ConnectionRefused extends Error {} /** * Raised when the underlying operating system reports that a connection has * been reset. With network servers, it can be a _normal_ occurrence where a * client will abort a connection instead of properly shutting it down. * * @category Errors */ export class ConnectionReset extends Error {} /** * Raised when the underlying operating system reports an `ECONNABORTED` * error. * * @category Errors */ export class ConnectionAborted extends Error {} /** * Raised when the underlying operating system reports an `ENOTCONN` error. * * @category Errors */ export class NotConnected extends Error {} /** * Raised when attempting to open a server listener on an address and port * that already has a listener. * * @category Errors */ export class AddrInUse extends Error {} /** * Raised when the underlying operating system reports an `EADDRNOTAVAIL` * error. * * @category Errors */ export class AddrNotAvailable extends Error {} /** * Raised when trying to write to a resource and a broken pipe error occurs. * This can happen when trying to write directly to `stdout` or `stderr` * and the operating system is unable to pipe the output for a reason * external to the Deno runtime. * * @category Errors */ export class BrokenPipe extends Error {} /** * Raised when trying to create a resource, like a file, that already * exits. * * @category Errors */ export class AlreadyExists extends Error {} /** * Raised when an operation to returns data that is invalid for the * operation being performed. * * @category Errors */ export class InvalidData extends Error {} /** * Raised when the underlying operating system reports that an I/O operation * has timed out (`ETIMEDOUT`). * * @category Errors */ export class TimedOut extends Error {} /** * Raised when the underlying operating system reports an `EINTR` error. In * many cases, this underlying IO error will be handled internally within * Deno, or result in an @{link BadResource} error instead. * * @category Errors */ export class Interrupted extends Error {} /** * Raised when the underlying operating system would need to block to * complete but an asynchronous (non-blocking) API is used. * * @category Errors */ export class WouldBlock extends Error {} /** * Raised when expecting to write to a IO buffer resulted in zero bytes * being written. * * @category Errors */ export class WriteZero extends Error {} /** * Raised when attempting to read bytes from a resource, but the EOF was * unexpectedly encountered. * * @category Errors */ export class UnexpectedEof extends Error {} /** * The underlying IO resource is invalid or closed, and so the operation * could not be performed. * * @category Errors */ export class BadResource extends Error {} /** * Raised in situations where when attempting to load a dynamic import, * too many redirects were encountered. * * @category Errors */ export class Http extends Error {} /** * Raised when the underlying IO resource is not available because it is * being awaited on in another block of code. * * @category Errors */ export class Busy extends Error {} /** * Raised when the underlying Deno API is asked to perform a function that * is not currently supported. * * @category Errors */ export class NotSupported extends Error {} /** * Raised when too many symbolic links were encountered when resolving the * filename. * * @category Errors */ export class FilesystemLoop extends Error {} /** * Raised when trying to open, create or write to a directory. * * @category Errors */ export class IsADirectory extends Error {} /** * Raised when performing a socket operation but the remote host is * not reachable. * * @category Errors */ export class NetworkUnreachable extends Error {} /** * Raised when trying to perform an operation on a path that is not a * directory, when directory is required. * * @category Errors */ export class NotADirectory extends Error {} /** * Raised when trying to perform an operation while the relevant Deno * permission (like `--allow-read`) has not been granted. * * Before Deno 2.0, this condition was covered by the {@link PermissionDenied} * error. * * @category Errors */ export class NotCapable extends Error {} export {}; // only export exports } /** The current process ID of this instance of the Deno CLI. * * ```ts * console.log(Deno.pid); * ``` * * @category Runtime */ export const pid: number; /** * The process ID of parent process of this instance of the Deno CLI. * * ```ts * console.log(Deno.ppid); * ``` * * @category Runtime */ export const ppid: number; /** @category Runtime */ export interface MemoryUsage { /** The number of bytes of the current Deno's process resident set size, * which is the amount of memory occupied in main memory (RAM). */ rss: number; /** The total size of the heap for V8, in bytes. */ heapTotal: number; /** The amount of the heap used for V8, in bytes. */ heapUsed: number; /** Memory, in bytes, associated with JavaScript objects outside of the * JavaScript isolate. */ external: number; } /** * Returns an object describing the memory usage of the Deno process and the * V8 subsystem measured in bytes. * * @category Runtime */ export function memoryUsage(): MemoryUsage; /** * Get the `hostname` of the machine the Deno process is running on. * * ```ts * console.log(Deno.hostname()); * ``` * * Requires `allow-sys` permission. * * @tags allow-sys * @category Runtime */ export function hostname(): string; /** * Returns an array containing the 1, 5, and 15 minute load averages. The * load average is a measure of CPU and IO utilization of the last one, five, * and 15 minute periods expressed as a fractional number. Zero means there * is no load. On Windows, the three values are always the same and represent * the current load, not the 1, 5 and 15 minute load averages. * * ```ts * console.log(Deno.loadavg()); // e.g. [ 0.71, 0.44, 0.44 ] * ``` * * Requires `allow-sys` permission. * * On Windows there is no API available to retrieve this information and this method returns `[ 0, 0, 0 ]`. * * @tags allow-sys * @category Runtime */ export function loadavg(): number[]; /** * The information for a network interface returned from a call to * {@linkcode Deno.networkInterfaces}. * * @category Network */ export interface NetworkInterfaceInfo { /** The network interface name. */ name: string; /** The IP protocol version. */ family: "IPv4" | "IPv6"; /** The IP address bound to the interface. */ address: string; /** The netmask applied to the interface. */ netmask: string; /** The IPv6 scope id or `null`. */ scopeid: number | null; /** The CIDR range. */ cidr: string; /** The MAC address. */ mac: string; } /** * Returns an array of the network interface information. * * ```ts * console.log(Deno.networkInterfaces()); * ``` * * Requires `allow-sys` permission. * * @tags allow-sys * @category Network */ export function networkInterfaces(): NetworkInterfaceInfo[]; /** * Displays the total amount of free and used physical and swap memory in the * system, as well as the buffers and caches used by the kernel. * * This is similar to the `free` command in Linux * * ```ts * console.log(Deno.systemMemoryInfo()); * ``` * * Requires `allow-sys` permission. * * @tags allow-sys * @category Runtime */ export function systemMemoryInfo(): SystemMemoryInfo; /** * Information returned from a call to {@linkcode Deno.systemMemoryInfo}. * * @category Runtime */ export interface SystemMemoryInfo { /** Total installed memory in bytes. */ total: number; /** Unused memory in bytes. */ free: number; /** Estimation of how much memory, in bytes, is available for starting new * applications, without swapping. Unlike the data provided by the cache or * free fields, this field takes into account page cache and also that not * all reclaimable memory will be reclaimed due to items being in use. */ available: number; /** Memory used by kernel buffers. */ buffers: number; /** Memory used by the page cache and slabs. */ cached: number; /** Total swap memory. */ swapTotal: number; /** Unused swap memory. */ swapFree: number; } /** Reflects the `NO_COLOR` environment variable at program start. * * When the value is `true`, the Deno CLI will attempt to not send color codes * to `stderr` or `stdout` and other command line programs should also attempt * to respect this value. * * See: https://no-color.org/ * * @category Runtime */ export const noColor: boolean; /** * Returns the release version of the Operating System. * * ```ts * console.log(Deno.osRelease()); * ``` * * Requires `allow-sys` permission. * Under consideration to possibly move to Deno.build or Deno.versions and if * it should depend sys-info, which may not be desirable. * * @tags allow-sys * @category Runtime */ export function osRelease(): string; /** * Returns the Operating System uptime in number of seconds. * * ```ts * console.log(Deno.osUptime()); * ``` * * Requires `allow-sys` permission. * * @tags allow-sys * @category Runtime */ export function osUptime(): number; /** * Options which define the permissions within a test or worker context. * * `"inherit"` ensures that all permissions of the parent process will be * applied to the test context. `"none"` ensures the test context has no * permissions. A `PermissionOptionsObject` provides a more specific * set of permissions to the test context. * * @category Permissions */ export type PermissionOptions = "inherit" | "none" | PermissionOptionsObject; /** * A set of options which can define the permissions within a test or worker * context at a highly specific level. * * @category Permissions */ export interface PermissionOptionsObject { /** Specifies if the `env` permission should be requested or revoked. * If set to `"inherit"`, the current `env` permission will be inherited. * If set to `true`, the global `env` permission will be requested. * If set to `false`, the global `env` permission will be revoked. * * @default {false} */ env?: "inherit" | boolean | string[]; /** Specifies if the `sys` permission should be requested or revoked. * If set to `"inherit"`, the current `sys` permission will be inherited. * If set to `true`, the global `sys` permission will be requested. * If set to `false`, the global `sys` permission will be revoked. * * @default {false} */ sys?: "inherit" | boolean | string[]; /** Specifies if the `net` permission should be requested or revoked. * if set to `"inherit"`, the current `net` permission will be inherited. * if set to `true`, the global `net` permission will be requested. * if set to `false`, the global `net` permission will be revoked. * if set to `string[]`, the `net` permission will be requested with the * specified host strings with the format `"[:]`. * * @default {false} * * Examples: * * ```ts * import { assertEquals } from "jsr:@std/assert"; * * Deno.test({ * name: "inherit", * permissions: { * net: "inherit", * }, * async fn() { * const status = await Deno.permissions.query({ name: "net" }) * assertEquals(status.state, "granted"); * }, * }); * ``` * * ```ts * import { assertEquals } from "jsr:@std/assert"; * * Deno.test({ * name: "true", * permissions: { * net: true, * }, * async fn() { * const status = await Deno.permissions.query({ name: "net" }); * assertEquals(status.state, "granted"); * }, * }); * ``` * * ```ts * import { assertEquals } from "jsr:@std/assert"; * * Deno.test({ * name: "false", * permissions: { * net: false, * }, * async fn() { * const status = await Deno.permissions.query({ name: "net" }); * assertEquals(status.state, "denied"); * }, * }); * ``` * * ```ts * import { assertEquals } from "jsr:@std/assert"; * * Deno.test({ * name: "localhost:8080", * permissions: { * net: ["localhost:8080"], * }, * async fn() { * const status = await Deno.permissions.query({ name: "net", host: "localhost:8080" }); * assertEquals(status.state, "granted"); * }, * }); * ``` */ net?: "inherit" | boolean | string[]; /** Specifies if the `ffi` permission should be requested or revoked. * If set to `"inherit"`, the current `ffi` permission will be inherited. * If set to `true`, the global `ffi` permission will be requested. * If set to `false`, the global `ffi` permission will be revoked. * * @default {false} */ ffi?: "inherit" | boolean | Array; /** Specifies if the `read` permission should be requested or revoked. * If set to `"inherit"`, the current `read` permission will be inherited. * If set to `true`, the global `read` permission will be requested. * If set to `false`, the global `read` permission will be revoked. * If set to `Array`, the `read` permission will be requested with the * specified file paths. * * @default {false} */ read?: "inherit" | boolean | Array; /** Specifies if the `run` permission should be requested or revoked. * If set to `"inherit"`, the current `run` permission will be inherited. * If set to `true`, the global `run` permission will be requested. * If set to `false`, the global `run` permission will be revoked. * * @default {false} */ run?: "inherit" | boolean | Array; /** Specifies if the `write` permission should be requested or revoked. * If set to `"inherit"`, the current `write` permission will be inherited. * If set to `true`, the global `write` permission will be requested. * If set to `false`, the global `write` permission will be revoked. * If set to `Array`, the `write` permission will be requested with the * specified file paths. * * @default {false} */ write?: "inherit" | boolean | Array; } /** * Context that is passed to a testing function, which can be used to either * gain information about the current test, or register additional test * steps within the current test. * * @category Testing */ export interface TestContext { /** The current test name. */ name: string; /** The string URL of the current test. */ origin: string; /** If the current test is a step of another test, the parent test context * will be set here. */ parent?: TestContext; /** Run a sub step of the parent test or step. Returns a promise * that resolves to a boolean signifying if the step completed successfully. * * The returned promise never rejects unless the arguments are invalid. * * If the test was ignored the promise returns `false`. * * ```ts * Deno.test({ * name: "a parent test", * async fn(t) { * console.log("before the step"); * await t.step({ * name: "step 1", * fn(t) { * console.log("current step:", t.name); * } * }); * console.log("after the step"); * } * }); * ``` */ step(definition: TestStepDefinition): Promise; /** Run a sub step of the parent test or step. Returns a promise * that resolves to a boolean signifying if the step completed successfully. * * The returned promise never rejects unless the arguments are invalid. * * If the test was ignored the promise returns `false`. * * ```ts * Deno.test( * "a parent test", * async (t) => { * console.log("before the step"); * await t.step( * "step 1", * (t) => { * console.log("current step:", t.name); * } * ); * console.log("after the step"); * } * ); * ``` */ step( name: string, fn: (t: TestContext) => void | Promise ): Promise; /** Run a sub step of the parent test or step. Returns a promise * that resolves to a boolean signifying if the step completed successfully. * * The returned promise never rejects unless the arguments are invalid. * * If the test was ignored the promise returns `false`. * * ```ts * Deno.test(async function aParentTest(t) { * console.log("before the step"); * await t.step(function step1(t) { * console.log("current step:", t.name); * }); * console.log("after the step"); * }); * ``` */ step(fn: (t: TestContext) => void | Promise): Promise; } /** @category Testing */ export interface TestStepDefinition { /** The test function that will be tested when this step is executed. The * function can take an argument which will provide information about the * current step's context. */ fn: (t: TestContext) => void | Promise; /** The name of the step. */ name: string; /** If truthy the current test step will be ignored. * * This is a quick way to skip over a step, but also can be used for * conditional logic, like determining if an environment feature is present. */ ignore?: boolean; /** Check that the number of async completed operations after the test step * is the same as number of dispatched operations. This ensures that the * code tested does not start async operations which it then does * not await. This helps in preventing logic errors and memory leaks * in the application code. * * Defaults to the parent test or step's value. */ sanitizeOps?: boolean; /** Ensure the test step does not "leak" resources - like open files or * network connections - by ensuring the open resources at the start of the * step match the open resources at the end of the step. * * Defaults to the parent test or step's value. */ sanitizeResources?: boolean; /** Ensure the test step does not prematurely cause the process to exit, * for example via a call to {@linkcode Deno.exit}. * * Defaults to the parent test or step's value. */ sanitizeExit?: boolean; } /** @category Testing */ export interface TestDefinition { fn: (t: TestContext) => void | Promise; /** The name of the test. */ name: string; /** If truthy the current test step will be ignored. * * It is a quick way to skip over a step, but also can be used for * conditional logic, like determining if an environment feature is present. */ ignore?: boolean; /** If at least one test has `only` set to `true`, only run tests that have * `only` set to `true` and fail the test suite. */ only?: boolean; /** Check that the number of async completed operations after the test step * is the same as number of dispatched operations. This ensures that the * code tested does not start async operations which it then does * not await. This helps in preventing logic errors and memory leaks * in the application code. * * @default {true} */ sanitizeOps?: boolean; /** Ensure the test step does not "leak" resources - like open files or * network connections - by ensuring the open resources at the start of the * test match the open resources at the end of the test. * * @default {true} */ sanitizeResources?: boolean; /** Ensure the test case does not prematurely cause the process to exit, * for example via a call to {@linkcode Deno.exit}. * * @default {true} */ sanitizeExit?: boolean; /** Specifies the permissions that should be used to run the test. * * Set this to "inherit" to keep the calling runtime permissions, set this * to "none" to revoke all permissions, or set a more specific set of * permissions using a {@linkcode PermissionOptionsObject}. * * @default {"inherit"} */ permissions?: PermissionOptions; } /** Register a test which will be run when `deno test` is used on the command * line and the containing module looks like a test module. * * `fn` can be async if required. * * ```ts * import { assertEquals } from "jsr:@std/assert"; * * Deno.test({ * name: "example test", * fn() { * assertEquals("world", "world"); * }, * }); * * Deno.test({ * name: "example ignored test", * ignore: Deno.build.os === "windows", * fn() { * // This test is ignored only on Windows machines * }, * }); * * Deno.test({ * name: "example async test", * async fn() { * const decoder = new TextDecoder("utf-8"); * const data = await Deno.readFile("hello_world.txt"); * assertEquals(decoder.decode(data), "Hello world"); * } * }); * ``` * * @category Testing */ export const test: DenoTest; /** * @category Testing */ export interface DenoTest { /** Register a test which will be run when `deno test` is used on the command * line and the containing module looks like a test module. * * `fn` can be async if required. * * ```ts * import { assertEquals } from "jsr:@std/assert"; * * Deno.test({ * name: "example test", * fn() { * assertEquals("world", "world"); * }, * }); * * Deno.test({ * name: "example ignored test", * ignore: Deno.build.os === "windows", * fn() { * // This test is ignored only on Windows machines * }, * }); * * Deno.test({ * name: "example async test", * async fn() { * const decoder = new TextDecoder("utf-8"); * const data = await Deno.readFile("hello_world.txt"); * assertEquals(decoder.decode(data), "Hello world"); * } * }); * ``` * * @category Testing */ (t: TestDefinition): void; /** Register a test which will be run when `deno test` is used on the command * line and the containing module looks like a test module. * * `fn` can be async if required. * * ```ts * import { assertEquals } from "jsr:@std/assert"; * * Deno.test("My test description", () => { * assertEquals("hello", "hello"); * }); * * Deno.test("My async test description", async () => { * const decoder = new TextDecoder("utf-8"); * const data = await Deno.readFile("hello_world.txt"); * assertEquals(decoder.decode(data), "Hello world"); * }); * ``` * * @category Testing */ (name: string, fn: (t: TestContext) => void | Promise): void; /** Register a test which will be run when `deno test` is used on the command * line and the containing module looks like a test module. * * `fn` can be async if required. Declared function must have a name. * * ```ts * import { assertEquals } from "jsr:@std/assert"; * * Deno.test(function myTestName() { * assertEquals("hello", "hello"); * }); * * Deno.test(async function myOtherTestName() { * const decoder = new TextDecoder("utf-8"); * const data = await Deno.readFile("hello_world.txt"); * assertEquals(decoder.decode(data), "Hello world"); * }); * ``` * * @category Testing */ (fn: (t: TestContext) => void | Promise): void; /** Register a test which will be run when `deno test` is used on the command * line and the containing module looks like a test module. * * `fn` can be async if required. * * ```ts * import { assert, fail, assertEquals } from "jsr:@std/assert"; * * Deno.test("My test description", { permissions: { read: true } }, (): void => { * assertEquals("hello", "hello"); * }); * * Deno.test("My async test description", { permissions: { read: false } }, async (): Promise => { * const decoder = new TextDecoder("utf-8"); * const data = await Deno.readFile("hello_world.txt"); * assertEquals(decoder.decode(data), "Hello world"); * }); * ``` * * @category Testing */ ( name: string, options: Omit, fn: (t: TestContext) => void | Promise ): void; /** Register a test which will be run when `deno test` is used on the command * line and the containing module looks like a test module. * * `fn` can be async if required. * * ```ts * import { assertEquals } from "jsr:@std/assert"; * * Deno.test( * { * name: "My test description", * permissions: { read: true }, * }, * () => { * assertEquals("hello", "hello"); * }, * ); * * Deno.test( * { * name: "My async test description", * permissions: { read: false }, * }, * async () => { * const decoder = new TextDecoder("utf-8"); * const data = await Deno.readFile("hello_world.txt"); * assertEquals(decoder.decode(data), "Hello world"); * }, * ); * ``` * * @category Testing */ ( options: Omit, fn: (t: TestContext) => void | Promise ): void; /** Register a test which will be run when `deno test` is used on the command * line and the containing module looks like a test module. * * `fn` can be async if required. Declared function must have a name. * * ```ts * import { assertEquals } from "jsr:@std/assert"; * * Deno.test( * { permissions: { read: true } }, * function myTestName() { * assertEquals("hello", "hello"); * }, * ); * * Deno.test( * { permissions: { read: false } }, * async function myOtherTestName() { * const decoder = new TextDecoder("utf-8"); * const data = await Deno.readFile("hello_world.txt"); * assertEquals(decoder.decode(data), "Hello world"); * }, * ); * ``` * * @category Testing */ ( options: Omit, fn: (t: TestContext) => void | Promise ): void; /** Shorthand property for ignoring a particular test case. * * @category Testing */ ignore(t: Omit): void; /** Shorthand property for ignoring a particular test case. * * @category Testing */ ignore(name: string, fn: (t: TestContext) => void | Promise): void; /** Shorthand property for ignoring a particular test case. * * @category Testing */ ignore(fn: (t: TestContext) => void | Promise): void; /** Shorthand property for ignoring a particular test case. * * @category Testing */ ignore( name: string, options: Omit, fn: (t: TestContext) => void | Promise ): void; /** Shorthand property for ignoring a particular test case. * * @category Testing */ ignore( options: Omit, fn: (t: TestContext) => void | Promise ): void; /** Shorthand property for ignoring a particular test case. * * @category Testing */ ignore( options: Omit, fn: (t: TestContext) => void | Promise ): void; /** Shorthand property for focusing a particular test case. * * @category Testing */ only(t: Omit): void; /** Shorthand property for focusing a particular test case. * * @category Testing */ only(name: string, fn: (t: TestContext) => void | Promise): void; /** Shorthand property for focusing a particular test case. * * @category Testing */ only(fn: (t: TestContext) => void | Promise): void; /** Shorthand property for focusing a particular test case. * * @category Testing */ only( name: string, options: Omit, fn: (t: TestContext) => void | Promise ): void; /** Shorthand property for focusing a particular test case. * * @category Testing */ only( options: Omit, fn: (t: TestContext) => void | Promise ): void; /** Shorthand property for focusing a particular test case. * * @category Testing */ only( options: Omit, fn: (t: TestContext) => void | Promise ): void; } /** * Context that is passed to a benchmarked function. The instance is shared * between iterations of the benchmark. Its methods can be used for example * to override of the measured portion of the function. * * @category Testing */ export interface BenchContext { /** The current benchmark name. */ name: string; /** The string URL of the current benchmark. */ origin: string; /** Restarts the timer for the bench measurement. This should be called * after doing setup work which should not be measured. * * Warning: This method should not be used for benchmarks averaging less * than 10μs per iteration. In such cases it will be disabled but the call * will still have noticeable overhead, resulting in a warning. * * ```ts * Deno.bench("foo", async (t) => { * const data = await Deno.readFile("data.txt"); * t.start(); * // some operation on `data`... * }); * ``` */ start(): void; /** End the timer early for the bench measurement. This should be called * before doing teardown work which should not be measured. * * Warning: This method should not be used for benchmarks averaging less * than 10μs per iteration. In such cases it will be disabled but the call * will still have noticeable overhead, resulting in a warning. * * ```ts * Deno.bench("foo", async (t) => { * using file = await Deno.open("data.txt"); * t.start(); * // some operation on `file`... * t.end(); * }); * ``` */ end(): void; } /** * The interface for defining a benchmark test using {@linkcode Deno.bench}. * * @category Testing */ export interface BenchDefinition { /** The test function which will be benchmarked. */ fn: (b: BenchContext) => void | Promise; /** The name of the test, which will be used in displaying the results. */ name: string; /** If truthy, the benchmark test will be ignored/skipped. */ ignore?: boolean; /** Group name for the benchmark. * * Grouped benchmarks produce a group time summary, where the difference * in performance between each test of the group is compared. */ group?: string; /** Benchmark should be used as the baseline for other benchmarks. * * If there are multiple baselines in a group, the first one is used as the * baseline. */ baseline?: boolean; /** If at least one bench has `only` set to true, only run benches that have * `only` set to `true` and fail the bench suite. */ only?: boolean; /** Ensure the bench case does not prematurely cause the process to exit, * for example via a call to {@linkcode Deno.exit}. * * @default {true} */ sanitizeExit?: boolean; /** Specifies the permissions that should be used to run the bench. * * Set this to `"inherit"` to keep the calling thread's permissions. * * Set this to `"none"` to revoke all permissions. * * @default {"inherit"} */ permissions?: PermissionOptions; } /** * Register a benchmark test which will be run when `deno bench` is used on * the command line and the containing module looks like a bench module. * * If the test function (`fn`) returns a promise or is async, the test runner * will await resolution to consider the test complete. * * ```ts * import { assertEquals } from "jsr:@std/assert"; * * Deno.bench({ * name: "example test", * fn() { * assertEquals("world", "world"); * }, * }); * * Deno.bench({ * name: "example ignored test", * ignore: Deno.build.os === "windows", * fn() { * // This test is ignored only on Windows machines * }, * }); * * Deno.bench({ * name: "example async test", * async fn() { * const decoder = new TextDecoder("utf-8"); * const data = await Deno.readFile("hello_world.txt"); * assertEquals(decoder.decode(data), "Hello world"); * } * }); * ``` * * @category Testing */ export function bench(b: BenchDefinition): void; /** * Register a benchmark test which will be run when `deno bench` is used on * the command line and the containing module looks like a bench module. * * If the test function (`fn`) returns a promise or is async, the test runner * will await resolution to consider the test complete. * * ```ts * import { assertEquals } from "jsr:@std/assert"; * * Deno.bench("My test description", () => { * assertEquals("hello", "hello"); * }); * * Deno.bench("My async test description", async () => { * const decoder = new TextDecoder("utf-8"); * const data = await Deno.readFile("hello_world.txt"); * assertEquals(decoder.decode(data), "Hello world"); * }); * ``` * * @category Testing */ export function bench( name: string, fn: (b: BenchContext) => void | Promise ): void; /** * Register a benchmark test which will be run when `deno bench` is used on * the command line and the containing module looks like a bench module. * * If the test function (`fn`) returns a promise or is async, the test runner * will await resolution to consider the test complete. * * ```ts * import { assertEquals } from "jsr:@std/assert"; * * Deno.bench(function myTestName() { * assertEquals("hello", "hello"); * }); * * Deno.bench(async function myOtherTestName() { * const decoder = new TextDecoder("utf-8"); * const data = await Deno.readFile("hello_world.txt"); * assertEquals(decoder.decode(data), "Hello world"); * }); * ``` * * @category Testing */ export function bench(fn: (b: BenchContext) => void | Promise): void; /** * Register a benchmark test which will be run when `deno bench` is used on * the command line and the containing module looks like a bench module. * * If the test function (`fn`) returns a promise or is async, the test runner * will await resolution to consider the test complete. * * ```ts * import { assertEquals } from "jsr:@std/assert"; * * Deno.bench( * "My test description", * { permissions: { read: true } }, * () => { * assertEquals("hello", "hello"); * } * ); * * Deno.bench( * "My async test description", * { permissions: { read: false } }, * async () => { * const decoder = new TextDecoder("utf-8"); * const data = await Deno.readFile("hello_world.txt"); * assertEquals(decoder.decode(data), "Hello world"); * } * ); * ``` * * @category Testing */ export function bench( name: string, options: Omit, fn: (b: BenchContext) => void | Promise ): void; /** * Register a benchmark test which will be run when `deno bench` is used on * the command line and the containing module looks like a bench module. * * If the test function (`fn`) returns a promise or is async, the test runner * will await resolution to consider the test complete. * * ```ts * import { assertEquals } from "jsr:@std/assert"; * * Deno.bench( * { name: "My test description", permissions: { read: true } }, * () => { * assertEquals("hello", "hello"); * } * ); * * Deno.bench( * { name: "My async test description", permissions: { read: false } }, * async () => { * const decoder = new TextDecoder("utf-8"); * const data = await Deno.readFile("hello_world.txt"); * assertEquals(decoder.decode(data), "Hello world"); * } * ); * ``` * * @category Testing */ export function bench( options: Omit, fn: (b: BenchContext) => void | Promise ): void; /** * Register a benchmark test which will be run when `deno bench` is used on * the command line and the containing module looks like a bench module. * * If the test function (`fn`) returns a promise or is async, the test runner * will await resolution to consider the test complete. * * ```ts * import { assertEquals } from "jsr:@std/assert"; * * Deno.bench( * { permissions: { read: true } }, * function myTestName() { * assertEquals("hello", "hello"); * } * ); * * Deno.bench( * { permissions: { read: false } }, * async function myOtherTestName() { * const decoder = new TextDecoder("utf-8"); * const data = await Deno.readFile("hello_world.txt"); * assertEquals(decoder.decode(data), "Hello world"); * } * ); * ``` * * @category Testing */ export function bench( options: Omit, fn: (b: BenchContext) => void | Promise ): void; /** Exit the Deno process with optional exit code. * * If no exit code is supplied then Deno will exit with return code of `0`. * * In worker contexts this is an alias to `self.close();`. * * ```ts * Deno.exit(5); * ``` * * @category Runtime */ export function exit(code?: number): never; /** The exit code for the Deno process. * * If no exit code has been supplied, then Deno will assume a return code of `0`. * * When setting an exit code value, a number or non-NaN string must be provided, * otherwise a TypeError will be thrown. * * ```ts * console.log(Deno.exitCode); //-> 0 * Deno.exitCode = 1; * console.log(Deno.exitCode); //-> 1 * ``` * * @category Runtime */ export var exitCode: number; /** An interface containing methods to interact with the process environment * variables. * * @tags allow-env * @category Runtime */ export interface Env { /** Retrieve the value of an environment variable. * * Returns `undefined` if the supplied environment variable is not defined. * * ```ts * console.log(Deno.env.get("HOME")); // e.g. outputs "/home/alice" * console.log(Deno.env.get("MADE_UP_VAR")); // outputs "undefined" * ``` * * Requires `allow-env` permission. * * @tags allow-env */ get(key: string): string | undefined; /** Set the value of an environment variable. * * ```ts * Deno.env.set("SOME_VAR", "Value"); * Deno.env.get("SOME_VAR"); // outputs "Value" * ``` * * Requires `allow-env` permission. * * @tags allow-env */ set(key: string, value: string): void; /** Delete the value of an environment variable. * * ```ts * Deno.env.set("SOME_VAR", "Value"); * Deno.env.delete("SOME_VAR"); // outputs "undefined" * ``` * * Requires `allow-env` permission. * * @tags allow-env */ delete(key: string): void; /** Check whether an environment variable is present or not. * * ```ts * Deno.env.set("SOME_VAR", "Value"); * Deno.env.has("SOME_VAR"); // outputs true * ``` * * Requires `allow-env` permission. * * @tags allow-env */ has(key: string): boolean; /** Returns a snapshot of the environment variables at invocation as a * simple object of keys and values. * * ```ts * Deno.env.set("TEST_VAR", "A"); * const myEnv = Deno.env.toObject(); * console.log(myEnv.SHELL); * Deno.env.set("TEST_VAR", "B"); * console.log(myEnv.TEST_VAR); // outputs "A" * ``` * * Requires `allow-env` permission. * * @tags allow-env */ toObject(): { [index: string]: string }; } /** An interface containing methods to interact with the process environment * variables. * * @tags allow-env * @category Runtime */ export const env: Env; /** * Returns the path to the current deno executable. * * ```ts * console.log(Deno.execPath()); // e.g. "/home/alice/.local/bin/deno" * ``` * * Requires `allow-read` permission. * * @tags allow-read * @category Runtime */ export function execPath(): string; /** * Change the current working directory to the specified path. * * ```ts * Deno.chdir("/home/userA"); * Deno.chdir("../userB"); * Deno.chdir("C:\\Program Files (x86)\\Java"); * ``` * * Throws {@linkcode Deno.errors.NotFound} if directory not found. * * Throws {@linkcode Deno.errors.PermissionDenied} if the user does not have * operating system file access rights. * * Requires `allow-read` permission. * * @tags allow-read * @category Runtime */ export function chdir(directory: string | URL): void; /** * Return a string representing the current working directory. * * If the current directory can be reached via multiple paths (due to symbolic * links), `cwd()` may return any one of them. * * ```ts * const currentWorkingDirectory = Deno.cwd(); * ``` * * Throws {@linkcode Deno.errors.NotFound} if directory not available. * * Requires `allow-read` permission. * * @tags allow-read * @category Runtime */ export function cwd(): string; /** * Creates `newpath` as a hard link to `oldpath`. * * ```ts * await Deno.link("old/name", "new/name"); * ``` * * Requires `allow-read` and `allow-write` permissions. * * @tags allow-read, allow-write * @category File System */ export function link(oldpath: string, newpath: string): Promise; /** * Synchronously creates `newpath` as a hard link to `oldpath`. * * ```ts * Deno.linkSync("old/name", "new/name"); * ``` * * Requires `allow-read` and `allow-write` permissions. * * @tags allow-read, allow-write * @category File System */ export function linkSync(oldpath: string, newpath: string): void; /** * A enum which defines the seek mode for IO related APIs that support * seeking. * * @category I/O */ export enum SeekMode { /* Seek from the start of the file/resource. */ Start = 0, /* Seek from the current position within the file/resource. */ Current = 1, /* Seek from the end of the current file/resource. */ End = 2, } /** Open a file and resolve to an instance of {@linkcode Deno.FsFile}. The * file does not need to previously exist if using the `create` or `createNew` * open options. The caller may have the resulting file automatically closed * by the runtime once it's out of scope by declaring the file variable with * the `using` keyword. * * ```ts * using file = await Deno.open("/foo/bar.txt", { read: true, write: true }); * // Do work with file * ``` * * Alternatively, the caller may manually close the resource when finished with * it. * * ```ts * const file = await Deno.open("/foo/bar.txt", { read: true, write: true }); * // Do work with file * file.close(); * ``` * * Requires `allow-read` and/or `allow-write` permissions depending on * options. * * @tags allow-read, allow-write * @category File System */ export function open( path: string | URL, options?: OpenOptions ): Promise; /** Synchronously open a file and return an instance of * {@linkcode Deno.FsFile}. The file does not need to previously exist if * using the `create` or `createNew` open options. The caller may have the * resulting file automatically closed by the runtime once it's out of scope * by declaring the file variable with the `using` keyword. * * ```ts * using file = Deno.openSync("/foo/bar.txt", { read: true, write: true }); * // Do work with file * ``` * * Alternatively, the caller may manually close the resource when finished with * it. * * ```ts * const file = Deno.openSync("/foo/bar.txt", { read: true, write: true }); * // Do work with file * file.close(); * ``` * * Requires `allow-read` and/or `allow-write` permissions depending on * options. * * @tags allow-read, allow-write * @category File System */ export function openSync(path: string | URL, options?: OpenOptions): FsFile; /** Creates a file if none exists or truncates an existing file and resolves to * an instance of {@linkcode Deno.FsFile}. * * ```ts * const file = await Deno.create("/foo/bar.txt"); * ``` * * Requires `allow-read` and `allow-write` permissions. * * @tags allow-read, allow-write * @category File System */ export function create(path: string | URL): Promise; /** Creates a file if none exists or truncates an existing file and returns * an instance of {@linkcode Deno.FsFile}. * * ```ts * const file = Deno.createSync("/foo/bar.txt"); * ``` * * Requires `allow-read` and `allow-write` permissions. * * @tags allow-read, allow-write * @category File System */ export function createSync(path: string | URL): FsFile; /** The Deno abstraction for reading and writing files. * * This is the most straight forward way of handling files within Deno and is * recommended over using the discrete functions within the `Deno` namespace. * * ```ts * using file = await Deno.open("/foo/bar.txt", { read: true }); * const fileInfo = await file.stat(); * if (fileInfo.isFile) { * const buf = new Uint8Array(100); * const numberOfBytesRead = await file.read(buf); // 11 bytes * const text = new TextDecoder().decode(buf); // "hello world" * } * ``` * * @category File System */ export class FsFile implements Disposable { /** A {@linkcode ReadableStream} instance representing to the byte contents * of the file. This makes it easy to interoperate with other web streams * based APIs. * * ```ts * using file = await Deno.open("my_file.txt", { read: true }); * const decoder = new TextDecoder(); * for await (const chunk of file.readable) { * console.log(decoder.decode(chunk)); * } * ``` */ readonly readable: ReadableStream; /** A {@linkcode WritableStream} instance to write the contents of the * file. This makes it easy to interoperate with other web streams based * APIs. * * ```ts * const items = ["hello", "world"]; * using file = await Deno.open("my_file.txt", { write: true }); * const encoder = new TextEncoder(); * const writer = file.writable.getWriter(); * for (const item of items) { * await writer.write(encoder.encode(item)); * } * ``` */ readonly writable: WritableStream; /** Write the contents of the array buffer (`p`) to the file. * * Resolves to the number of bytes written. * * **It is not guaranteed that the full buffer will be written in a single * call.** * * ```ts * const encoder = new TextEncoder(); * const data = encoder.encode("Hello world"); * using file = await Deno.open("/foo/bar.txt", { write: true }); * const bytesWritten = await file.write(data); // 11 * ``` * * @category I/O */ write(p: Uint8Array): Promise; /** Synchronously write the contents of the array buffer (`p`) to the file. * * Returns the number of bytes written. * * **It is not guaranteed that the full buffer will be written in a single * call.** * * ```ts * const encoder = new TextEncoder(); * const data = encoder.encode("Hello world"); * using file = Deno.openSync("/foo/bar.txt", { write: true }); * const bytesWritten = file.writeSync(data); // 11 * ``` */ writeSync(p: Uint8Array): number; /** Truncates (or extends) the file to reach the specified `len`. If `len` * is not specified, then the entire file contents are truncated. * * ### Truncate the entire file * * ```ts * using file = await Deno.open("my_file.txt", { write: true }); * await file.truncate(); * ``` * * ### Truncate part of the file * * ```ts * // if "my_file.txt" contains the text "hello world": * using file = await Deno.open("my_file.txt", { write: true }); * await file.truncate(7); * const buf = new Uint8Array(100); * await file.read(buf); * const text = new TextDecoder().decode(buf); // "hello w" * ``` */ truncate(len?: number): Promise; /** Synchronously truncates (or extends) the file to reach the specified * `len`. If `len` is not specified, then the entire file contents are * truncated. * * ### Truncate the entire file * * ```ts * using file = Deno.openSync("my_file.txt", { write: true }); * file.truncateSync(); * ``` * * ### Truncate part of the file * * ```ts * // if "my_file.txt" contains the text "hello world": * using file = Deno.openSync("my_file.txt", { write: true }); * file.truncateSync(7); * const buf = new Uint8Array(100); * file.readSync(buf); * const text = new TextDecoder().decode(buf); // "hello w" * ``` */ truncateSync(len?: number): void; /** Read the file into an array buffer (`p`). * * Resolves to either the number of bytes read during the operation or EOF * (`null`) if there was nothing more to read. * * It is possible for a read to successfully return with `0` bytes. This * does not indicate EOF. * * **It is not guaranteed that the full buffer will be read in a single * call.** * * ```ts * // if "/foo/bar.txt" contains the text "hello world": * using file = await Deno.open("/foo/bar.txt"); * const buf = new Uint8Array(100); * const numberOfBytesRead = await file.read(buf); // 11 bytes * const text = new TextDecoder().decode(buf); // "hello world" * ``` */ read(p: Uint8Array): Promise; /** Synchronously read from the file into an array buffer (`p`). * * Returns either the number of bytes read during the operation or EOF * (`null`) if there was nothing more to read. * * It is possible for a read to successfully return with `0` bytes. This * does not indicate EOF. * * **It is not guaranteed that the full buffer will be read in a single * call.** * * ```ts * // if "/foo/bar.txt" contains the text "hello world": * using file = Deno.openSync("/foo/bar.txt"); * const buf = new Uint8Array(100); * const numberOfBytesRead = file.readSync(buf); // 11 bytes * const text = new TextDecoder().decode(buf); // "hello world" * ``` */ readSync(p: Uint8Array): number | null; /** Seek to the given `offset` under mode given by `whence`. The call * resolves to the new position within the resource (bytes from the start). * * ```ts * // Given the file contains "Hello world" text, which is 11 bytes long: * using file = await Deno.open( * "hello.txt", * { read: true, write: true, truncate: true, create: true }, * ); * await file.write(new TextEncoder().encode("Hello world")); * * // advance cursor 6 bytes * const cursorPosition = await file.seek(6, Deno.SeekMode.Start); * console.log(cursorPosition); // 6 * const buf = new Uint8Array(100); * await file.read(buf); * console.log(new TextDecoder().decode(buf)); // "world" * ``` * * The seek modes work as follows: * * ```ts * // Given the file contains "Hello world" text, which is 11 bytes long: * const file = await Deno.open( * "hello.txt", * { read: true, write: true, truncate: true, create: true }, * ); * await file.write(new TextEncoder().encode("Hello world")); * * // Seek 6 bytes from the start of the file * console.log(await file.seek(6, Deno.SeekMode.Start)); // "6" * // Seek 2 more bytes from the current position * console.log(await file.seek(2, Deno.SeekMode.Current)); // "8" * // Seek backwards 2 bytes from the end of the file * console.log(await file.seek(-2, Deno.SeekMode.End)); // "9" (i.e. 11-2) * ``` */ seek(offset: number | bigint, whence: SeekMode): Promise; /** Synchronously seek to the given `offset` under mode given by `whence`. * The new position within the resource (bytes from the start) is returned. * * ```ts * using file = Deno.openSync( * "hello.txt", * { read: true, write: true, truncate: true, create: true }, * ); * file.writeSync(new TextEncoder().encode("Hello world")); * * // advance cursor 6 bytes * const cursorPosition = file.seekSync(6, Deno.SeekMode.Start); * console.log(cursorPosition); // 6 * const buf = new Uint8Array(100); * file.readSync(buf); * console.log(new TextDecoder().decode(buf)); // "world" * ``` * * The seek modes work as follows: * * ```ts * // Given the file contains "Hello world" text, which is 11 bytes long: * using file = Deno.openSync( * "hello.txt", * { read: true, write: true, truncate: true, create: true }, * ); * file.writeSync(new TextEncoder().encode("Hello world")); * * // Seek 6 bytes from the start of the file * console.log(file.seekSync(6, Deno.SeekMode.Start)); // "6" * // Seek 2 more bytes from the current position * console.log(file.seekSync(2, Deno.SeekMode.Current)); // "8" * // Seek backwards 2 bytes from the end of the file * console.log(file.seekSync(-2, Deno.SeekMode.End)); // "9" (i.e. 11-2) * ``` */ seekSync(offset: number | bigint, whence: SeekMode): number; /** Resolves to a {@linkcode Deno.FileInfo} for the file. * * ```ts * import { assert } from "jsr:@std/assert"; * * using file = await Deno.open("hello.txt"); * const fileInfo = await file.stat(); * assert(fileInfo.isFile); * ``` */ stat(): Promise; /** Synchronously returns a {@linkcode Deno.FileInfo} for the file. * * ```ts * import { assert } from "jsr:@std/assert"; * * using file = Deno.openSync("hello.txt") * const fileInfo = file.statSync(); * assert(fileInfo.isFile); * ``` */ statSync(): FileInfo; /** * Flushes any pending data and metadata operations of the given file * stream to disk. * * ```ts * const file = await Deno.open( * "my_file.txt", * { read: true, write: true, create: true }, * ); * await file.write(new TextEncoder().encode("Hello World")); * await file.truncate(1); * await file.sync(); * console.log(await Deno.readTextFile("my_file.txt")); // H * ``` * * @category I/O */ sync(): Promise; /** * Synchronously flushes any pending data and metadata operations of the given * file stream to disk. * * ```ts * const file = Deno.openSync( * "my_file.txt", * { read: true, write: true, create: true }, * ); * file.writeSync(new TextEncoder().encode("Hello World")); * file.truncateSync(1); * file.syncSync(); * console.log(Deno.readTextFileSync("my_file.txt")); // H * ``` * * @category I/O */ syncSync(): void; /** * Flushes any pending data operations of the given file stream to disk. * ```ts * using file = await Deno.open( * "my_file.txt", * { read: true, write: true, create: true }, * ); * await file.write(new TextEncoder().encode("Hello World")); * await file.syncData(); * console.log(await Deno.readTextFile("my_file.txt")); // Hello World * ``` * * @category I/O */ syncData(): Promise; /** * Synchronously flushes any pending data operations of the given file stream * to disk. * * ```ts * using file = Deno.openSync( * "my_file.txt", * { read: true, write: true, create: true }, * ); * file.writeSync(new TextEncoder().encode("Hello World")); * file.syncDataSync(); * console.log(Deno.readTextFileSync("my_file.txt")); // Hello World * ``` * * @category I/O */ syncDataSync(): void; /** * Changes the access (`atime`) and modification (`mtime`) times of the * file stream resource. Given times are either in seconds (UNIX epoch * time) or as `Date` objects. * * ```ts * using file = await Deno.open("file.txt", { create: true, write: true }); * await file.utime(1556495550, new Date()); * ``` * * @category File System */ utime(atime: number | Date, mtime: number | Date): Promise; /** * Synchronously changes the access (`atime`) and modification (`mtime`) * times of the file stream resource. Given times are either in seconds * (UNIX epoch time) or as `Date` objects. * * ```ts * using file = Deno.openSync("file.txt", { create: true, write: true }); * file.utime(1556495550, new Date()); * ``` * * @category File System */ utimeSync(atime: number | Date, mtime: number | Date): void; /** **UNSTABLE**: New API, yet to be vetted. * * Checks if the file resource is a TTY (terminal). * * ```ts * // This example is system and context specific * using file = await Deno.open("/dev/tty6"); * file.isTerminal(); // true * ``` */ isTerminal(): boolean; /** **UNSTABLE**: New API, yet to be vetted. * * Set TTY to be under raw mode or not. In raw mode, characters are read and * returned as is, without being processed. All special processing of * characters by the terminal is disabled, including echoing input * characters. Reading from a TTY device in raw mode is faster than reading * from a TTY device in canonical mode. * * ```ts * using file = await Deno.open("/dev/tty6"); * file.setRaw(true, { cbreak: true }); * ``` */ setRaw(mode: boolean, options?: SetRawOptions): void; /** * Acquire an advisory file-system lock for the file. * * @param [exclusive=false] */ lock(exclusive?: boolean): Promise; /** * Synchronously acquire an advisory file-system lock synchronously for the file. * * @param [exclusive=false] */ lockSync(exclusive?: boolean): void; /** * Release an advisory file-system lock for the file. */ unlock(): Promise; /** * Synchronously release an advisory file-system lock for the file. */ unlockSync(): void; /** Close the file. Closing a file when you are finished with it is * important to avoid leaking resources. * * ```ts * using file = await Deno.open("my_file.txt"); * // do work with "file" object * ``` */ close(): void; [Symbol.dispose](): void; } /** Gets the size of the console as columns/rows. * * ```ts * const { columns, rows } = Deno.consoleSize(); * ``` * * This returns the size of the console window as reported by the operating * system. It's not a reflection of how many characters will fit within the * console window, but can be used as part of that calculation. * * @category I/O */ export function consoleSize(): { columns: number; rows: number; }; /** @category I/O */ export interface SetRawOptions { /** * The `cbreak` option can be used to indicate that characters that * correspond to a signal should still be generated. When disabling raw * mode, this option is ignored. This functionality currently only works on * Linux and Mac OS. */ cbreak: boolean; } /** A reference to `stdin` which can be used to read directly from `stdin`. * * It implements the Deno specific * {@linkcode https://jsr.io/@std/io/doc/types/~/Reader | Reader}, * {@linkcode https://jsr.io/@std/io/doc/types/~/ReaderSync | ReaderSync}, * and {@linkcode https://jsr.io/@std/io/doc/types/~/Closer | Closer} * interfaces as well as provides a {@linkcode ReadableStream} interface. * * ### Reading chunks from the readable stream * * ```ts * const decoder = new TextDecoder(); * for await (const chunk of Deno.stdin.readable) { * const text = decoder.decode(chunk); * // do something with the text * } * ``` * * @category I/O */ export const stdin: { /** Read the incoming data from `stdin` into an array buffer (`p`). * * Resolves to either the number of bytes read during the operation or EOF * (`null`) if there was nothing more to read. * * It is possible for a read to successfully return with `0` bytes. This * does not indicate EOF. * * **It is not guaranteed that the full buffer will be read in a single * call.** * * ```ts * // If the text "hello world" is piped into the script: * const buf = new Uint8Array(100); * const numberOfBytesRead = await Deno.stdin.read(buf); // 11 bytes * const text = new TextDecoder().decode(buf); // "hello world" * ``` * * @category I/O */ read(p: Uint8Array): Promise; /** Synchronously read from the incoming data from `stdin` into an array * buffer (`p`). * * Returns either the number of bytes read during the operation or EOF * (`null`) if there was nothing more to read. * * It is possible for a read to successfully return with `0` bytes. This * does not indicate EOF. * * **It is not guaranteed that the full buffer will be read in a single * call.** * * ```ts * // If the text "hello world" is piped into the script: * const buf = new Uint8Array(100); * const numberOfBytesRead = Deno.stdin.readSync(buf); // 11 bytes * const text = new TextDecoder().decode(buf); // "hello world" * ``` * * @category I/O */ readSync(p: Uint8Array): number | null; /** Closes `stdin`, freeing the resource. * * ```ts * Deno.stdin.close(); * ``` */ close(): void; /** A readable stream interface to `stdin`. */ readonly readable: ReadableStream; /** * Set TTY to be under raw mode or not. In raw mode, characters are read and * returned as is, without being processed. All special processing of * characters by the terminal is disabled, including echoing input * characters. Reading from a TTY device in raw mode is faster than reading * from a TTY device in canonical mode. * * ```ts * Deno.stdin.setRaw(true, { cbreak: true }); * ``` * * @category I/O */ setRaw(mode: boolean, options?: SetRawOptions): void; /** * Checks if `stdin` is a TTY (terminal). * * ```ts * // This example is system and context specific * Deno.stdin.isTerminal(); // true * ``` * * @category I/O */ isTerminal(): boolean; }; /** A reference to `stdout` which can be used to write directly to `stdout`. * It implements the Deno specific * {@linkcode https://jsr.io/@std/io/doc/types/~/Writer | Writer}, * {@linkcode https://jsr.io/@std/io/doc/types/~/WriterSync | WriterSync}, * and {@linkcode https://jsr.io/@std/io/doc/types/~/Closer | Closer} interfaces as well as provides a * {@linkcode WritableStream} interface. * * These are low level constructs, and the {@linkcode console} interface is a * more straight forward way to interact with `stdout` and `stderr`. * * @category I/O */ export const stdout: { /** Write the contents of the array buffer (`p`) to `stdout`. * * Resolves to the number of bytes written. * * **It is not guaranteed that the full buffer will be written in a single * call.** * * ```ts * const encoder = new TextEncoder(); * const data = encoder.encode("Hello world"); * const bytesWritten = await Deno.stdout.write(data); // 11 * ``` * * @category I/O */ write(p: Uint8Array): Promise; /** Synchronously write the contents of the array buffer (`p`) to `stdout`. * * Returns the number of bytes written. * * **It is not guaranteed that the full buffer will be written in a single * call.** * * ```ts * const encoder = new TextEncoder(); * const data = encoder.encode("Hello world"); * const bytesWritten = Deno.stdout.writeSync(data); // 11 * ``` */ writeSync(p: Uint8Array): number; /** Closes `stdout`, freeing the resource. * * ```ts * Deno.stdout.close(); * ``` */ close(): void; /** A writable stream interface to `stdout`. */ readonly writable: WritableStream; /** * Checks if `stdout` is a TTY (terminal). * * ```ts * // This example is system and context specific * Deno.stdout.isTerminal(); // true * ``` * * @category I/O */ isTerminal(): boolean; }; /** A reference to `stderr` which can be used to write directly to `stderr`. * It implements the Deno specific * {@linkcode https://jsr.io/@std/io/doc/types/~/Writer | Writer}, * {@linkcode https://jsr.io/@std/io/doc/types/~/WriterSync | WriterSync}, * and {@linkcode https://jsr.io/@std/io/doc/types/~/Closer | Closer} interfaces as well as provides a * {@linkcode WritableStream} interface. * * These are low level constructs, and the {@linkcode console} interface is a * more straight forward way to interact with `stdout` and `stderr`. * * @category I/O */ export const stderr: { /** Write the contents of the array buffer (`p`) to `stderr`. * * Resolves to the number of bytes written. * * **It is not guaranteed that the full buffer will be written in a single * call.** * * ```ts * const encoder = new TextEncoder(); * const data = encoder.encode("Hello world"); * const bytesWritten = await Deno.stderr.write(data); // 11 * ``` * * @category I/O */ write(p: Uint8Array): Promise; /** Synchronously write the contents of the array buffer (`p`) to `stderr`. * * Returns the number of bytes written. * * **It is not guaranteed that the full buffer will be written in a single * call.** * * ```ts * const encoder = new TextEncoder(); * const data = encoder.encode("Hello world"); * const bytesWritten = Deno.stderr.writeSync(data); // 11 * ``` */ writeSync(p: Uint8Array): number; /** Closes `stderr`, freeing the resource. * * ```ts * Deno.stderr.close(); * ``` */ close(): void; /** A writable stream interface to `stderr`. */ readonly writable: WritableStream; /** * Checks if `stderr` is a TTY (terminal). * * ```ts * // This example is system and context specific * Deno.stderr.isTerminal(); // true * ``` * * @category I/O */ isTerminal(): boolean; }; /** * Options which can be set when doing {@linkcode Deno.open} and * {@linkcode Deno.openSync}. * * @category File System */ export interface OpenOptions { /** Sets the option for read access. This option, when `true`, means that * the file should be read-able if opened. * * @default {true} */ read?: boolean; /** Sets the option for write access. This option, when `true`, means that * the file should be write-able if opened. If the file already exists, * any write calls on it will overwrite its contents, by default without * truncating it. * * @default {false} */ write?: boolean; /** Sets the option for the append mode. This option, when `true`, means * that writes will append to a file instead of overwriting previous * contents. * * Note that setting `{ write: true, append: true }` has the same effect as * setting only `{ append: true }`. * * @default {false} */ append?: boolean; /** Sets the option for truncating a previous file. If a file is * successfully opened with this option set it will truncate the file to `0` * size if it already exists. The file must be opened with write access * for truncate to work. * * @default {false} */ truncate?: boolean; /** Sets the option to allow creating a new file, if one doesn't already * exist at the specified path. Requires write or append access to be * used. * * @default {false} */ create?: boolean; /** If set to `true`, no file, directory, or symlink is allowed to exist at * the target location. Requires write or append access to be used. When * createNew is set to `true`, create and truncate are ignored. * * @default {false} */ createNew?: boolean; /** Permissions to use if creating the file (defaults to `0o666`, before * the process's umask). * * Ignored on Windows. */ mode?: number; } /** * Options which can be set when using {@linkcode Deno.readFile} or * {@linkcode Deno.readFileSync}. * * @category File System */ export interface ReadFileOptions { /** * An abort signal to allow cancellation of the file read operation. * If the signal becomes aborted the readFile operation will be stopped * and the promise returned will be rejected with an AbortError. */ signal?: AbortSignal; } /** * Options which can be set when using {@linkcode Deno.mkdir} and * {@linkcode Deno.mkdirSync}. * * @category File System */ export interface MkdirOptions { /** If set to `true`, means that any intermediate directories will also be * created (as with the shell command `mkdir -p`). * * Intermediate directories are created with the same permissions. * * When recursive is set to `true`, succeeds silently (without changing any * permissions) if a directory already exists at the path, or if the path * is a symlink to an existing directory. * * @default {false} */ recursive?: boolean; /** Permissions to use when creating the directory (defaults to `0o777`, * before the process's umask). * * Ignored on Windows. */ mode?: number; } /** Creates a new directory with the specified path. * * ```ts * await Deno.mkdir("new_dir"); * await Deno.mkdir("nested/directories", { recursive: true }); * await Deno.mkdir("restricted_access_dir", { mode: 0o700 }); * ``` * * Defaults to throwing error if the directory already exists. * * Requires `allow-write` permission. * * @tags allow-write * @category File System */ export function mkdir( path: string | URL, options?: MkdirOptions ): Promise; /** Synchronously creates a new directory with the specified path. * * ```ts * Deno.mkdirSync("new_dir"); * Deno.mkdirSync("nested/directories", { recursive: true }); * Deno.mkdirSync("restricted_access_dir", { mode: 0o700 }); * ``` * * Defaults to throwing error if the directory already exists. * * Requires `allow-write` permission. * * @tags allow-write * @category File System */ export function mkdirSync(path: string | URL, options?: MkdirOptions): void; /** * Options which can be set when using {@linkcode Deno.makeTempDir}, * {@linkcode Deno.makeTempDirSync}, {@linkcode Deno.makeTempFile}, and * {@linkcode Deno.makeTempFileSync}. * * @category File System */ export interface MakeTempOptions { /** Directory where the temporary directory should be created (defaults to * the env variable `TMPDIR`, or the system's default, usually `/tmp`). * * Note that if the passed `dir` is relative, the path returned by * `makeTempFile()` and `makeTempDir()` will also be relative. Be mindful of * this when changing working directory. */ dir?: string; /** String that should precede the random portion of the temporary * directory's name. */ prefix?: string; /** String that should follow the random portion of the temporary * directory's name. */ suffix?: string; } /** Creates a new temporary directory in the default directory for temporary * files, unless `dir` is specified. Other optional options include * prefixing and suffixing the directory name with `prefix` and `suffix` * respectively. * * This call resolves to the full path to the newly created directory. * * Multiple programs calling this function simultaneously will create different * directories. It is the caller's responsibility to remove the directory when * no longer needed. * * ```ts * const tempDirName0 = await Deno.makeTempDir(); // e.g. /tmp/2894ea76 * const tempDirName1 = await Deno.makeTempDir({ prefix: 'my_temp' }); // e.g. /tmp/my_temp339c944d * ``` * * Requires `allow-write` permission. * * @tags allow-write * @category File System */ // TODO(ry) Doesn't check permissions. export function makeTempDir(options?: MakeTempOptions): Promise; /** Synchronously creates a new temporary directory in the default directory * for temporary files, unless `dir` is specified. Other optional options * include prefixing and suffixing the directory name with `prefix` and * `suffix` respectively. * * The full path to the newly created directory is returned. * * Multiple programs calling this function simultaneously will create different * directories. It is the caller's responsibility to remove the directory when * no longer needed. * * ```ts * const tempDirName0 = Deno.makeTempDirSync(); // e.g. /tmp/2894ea76 * const tempDirName1 = Deno.makeTempDirSync({ prefix: 'my_temp' }); // e.g. /tmp/my_temp339c944d * ``` * * Requires `allow-write` permission. * * @tags allow-write * @category File System */ // TODO(ry) Doesn't check permissions. export function makeTempDirSync(options?: MakeTempOptions): string; /** Creates a new temporary file in the default directory for temporary * files, unless `dir` is specified. * * Other options include prefixing and suffixing the directory name with * `prefix` and `suffix` respectively. * * This call resolves to the full path to the newly created file. * * Multiple programs calling this function simultaneously will create * different files. It is the caller's responsibility to remove the file when * no longer needed. * * ```ts * const tmpFileName0 = await Deno.makeTempFile(); // e.g. /tmp/419e0bf2 * const tmpFileName1 = await Deno.makeTempFile({ prefix: 'my_temp' }); // e.g. /tmp/my_temp754d3098 * ``` * * Requires `allow-write` permission. * * @tags allow-write * @category File System */ export function makeTempFile(options?: MakeTempOptions): Promise; /** Synchronously creates a new temporary file in the default directory for * temporary files, unless `dir` is specified. * * Other options include prefixing and suffixing the directory name with * `prefix` and `suffix` respectively. * * The full path to the newly created file is returned. * * Multiple programs calling this function simultaneously will create * different files. It is the caller's responsibility to remove the file when * no longer needed. * * ```ts * const tempFileName0 = Deno.makeTempFileSync(); // e.g. /tmp/419e0bf2 * const tempFileName1 = Deno.makeTempFileSync({ prefix: 'my_temp' }); // e.g. /tmp/my_temp754d3098 * ``` * * Requires `allow-write` permission. * * @tags allow-write * @category File System */ export function makeTempFileSync(options?: MakeTempOptions): string; /** Changes the permission of a specific file/directory of specified path. * Ignores the process's umask. * * ```ts * await Deno.chmod("/path/to/file", 0o666); * ``` * * The mode is a sequence of 3 octal numbers. The first/left-most number * specifies the permissions for the owner. The second number specifies the * permissions for the group. The last/right-most number specifies the * permissions for others. For example, with a mode of 0o764, the owner (7) * can read/write/execute, the group (6) can read/write and everyone else (4) * can read only. * * | Number | Description | * | ------ | ----------- | * | 7 | read, write, and execute | * | 6 | read and write | * | 5 | read and execute | * | 4 | read only | * | 3 | write and execute | * | 2 | write only | * | 1 | execute only | * | 0 | no permission | * * NOTE: This API currently throws on Windows * * Requires `allow-write` permission. * * @tags allow-write * @category File System */ export function chmod(path: string | URL, mode: number): Promise; /** Synchronously changes the permission of a specific file/directory of * specified path. Ignores the process's umask. * * ```ts * Deno.chmodSync("/path/to/file", 0o666); * ``` * * For a full description, see {@linkcode Deno.chmod}. * * NOTE: This API currently throws on Windows * * Requires `allow-write` permission. * * @tags allow-write * @category File System */ export function chmodSync(path: string | URL, mode: number): void; /** Change owner of a regular file or directory. * * This functionality is not available on Windows. * * ```ts * await Deno.chown("myFile.txt", 1000, 1002); * ``` * * Requires `allow-write` permission. * * Throws Error (not implemented) if executed on Windows. * * @tags allow-write * @category File System * * @param path path to the file * @param uid user id (UID) of the new owner, or `null` for no change * @param gid group id (GID) of the new owner, or `null` for no change */ export function chown( path: string | URL, uid: number | null, gid: number | null ): Promise; /** Synchronously change owner of a regular file or directory. * * This functionality is not available on Windows. * * ```ts * Deno.chownSync("myFile.txt", 1000, 1002); * ``` * * Requires `allow-write` permission. * * Throws Error (not implemented) if executed on Windows. * * @tags allow-write * @category File System * * @param path path to the file * @param uid user id (UID) of the new owner, or `null` for no change * @param gid group id (GID) of the new owner, or `null` for no change */ export function chownSync( path: string | URL, uid: number | null, gid: number | null ): void; /** * Options which can be set when using {@linkcode Deno.remove} and * {@linkcode Deno.removeSync}. * * @category File System */ export interface RemoveOptions { /** If set to `true`, path will be removed even if it's a non-empty directory. * * @default {false} */ recursive?: boolean; } /** Removes the named file or directory. * * ```ts * await Deno.remove("/path/to/empty_dir/or/file"); * await Deno.remove("/path/to/populated_dir/or/file", { recursive: true }); * ``` * * Throws error if permission denied, path not found, or path is a non-empty * directory and the `recursive` option isn't set to `true`. * * Requires `allow-write` permission. * * @tags allow-write * @category File System */ export function remove( path: string | URL, options?: RemoveOptions ): Promise; /** Synchronously removes the named file or directory. * * ```ts * Deno.removeSync("/path/to/empty_dir/or/file"); * Deno.removeSync("/path/to/populated_dir/or/file", { recursive: true }); * ``` * * Throws error if permission denied, path not found, or path is a non-empty * directory and the `recursive` option isn't set to `true`. * * Requires `allow-write` permission. * * @tags allow-write * @category File System */ export function removeSync(path: string | URL, options?: RemoveOptions): void; /** Synchronously renames (moves) `oldpath` to `newpath`. Paths may be files or * directories. If `newpath` already exists and is not a directory, * `renameSync()` replaces it. OS-specific restrictions may apply when * `oldpath` and `newpath` are in different directories. * * ```ts * Deno.renameSync("old/path", "new/path"); * ``` * * On Unix-like OSes, this operation does not follow symlinks at either path. * * It varies between platforms when the operation throws errors, and if so what * they are. It's always an error to rename anything to a non-empty directory. * * Requires `allow-read` and `allow-write` permissions. * * @tags allow-read, allow-write * @category File System */ export function renameSync( oldpath: string | URL, newpath: string | URL ): void; /** Renames (moves) `oldpath` to `newpath`. Paths may be files or directories. * If `newpath` already exists and is not a directory, `rename()` replaces it. * OS-specific restrictions may apply when `oldpath` and `newpath` are in * different directories. * * ```ts * await Deno.rename("old/path", "new/path"); * ``` * * On Unix-like OSes, this operation does not follow symlinks at either path. * * It varies between platforms when the operation throws errors, and if so * what they are. It's always an error to rename anything to a non-empty * directory. * * Requires `allow-read` and `allow-write` permissions. * * @tags allow-read, allow-write * @category File System */ export function rename( oldpath: string | URL, newpath: string | URL ): Promise; /** Asynchronously reads and returns the entire contents of a file as an UTF-8 * decoded string. Reading a directory throws an error. * * ```ts * const data = await Deno.readTextFile("hello.txt"); * console.log(data); * ``` * * Requires `allow-read` permission. * * @tags allow-read * @category File System */ export function readTextFile( path: string | URL, options?: ReadFileOptions ): Promise; /** Synchronously reads and returns the entire contents of a file as an UTF-8 * decoded string. Reading a directory throws an error. * * ```ts * const data = Deno.readTextFileSync("hello.txt"); * console.log(data); * ``` * * Requires `allow-read` permission. * * @tags allow-read * @category File System */ export function readTextFileSync(path: string | URL): string; /** Reads and resolves to the entire contents of a file as an array of bytes. * `TextDecoder` can be used to transform the bytes to string if required. * Reading a directory returns an empty data array. * * ```ts * const decoder = new TextDecoder("utf-8"); * const data = await Deno.readFile("hello.txt"); * console.log(decoder.decode(data)); * ``` * * Requires `allow-read` permission. * * @tags allow-read * @category File System */ export function readFile( path: string | URL, options?: ReadFileOptions ): Promise; /** Synchronously reads and returns the entire contents of a file as an array * of bytes. `TextDecoder` can be used to transform the bytes to string if * required. Reading a directory returns an empty data array. * * ```ts * const decoder = new TextDecoder("utf-8"); * const data = Deno.readFileSync("hello.txt"); * console.log(decoder.decode(data)); * ``` * * Requires `allow-read` permission. * * @tags allow-read * @category File System */ export function readFileSync(path: string | URL): Uint8Array; /** Provides information about a file and is returned by * {@linkcode Deno.stat}, {@linkcode Deno.lstat}, {@linkcode Deno.statSync}, * and {@linkcode Deno.lstatSync} or from calling `stat()` and `statSync()` * on an {@linkcode Deno.FsFile} instance. * * @category File System */ export interface FileInfo { /** True if this is info for a regular file. Mutually exclusive to * `FileInfo.isDirectory` and `FileInfo.isSymlink`. */ isFile: boolean; /** True if this is info for a regular directory. Mutually exclusive to * `FileInfo.isFile` and `FileInfo.isSymlink`. */ isDirectory: boolean; /** True if this is info for a symlink. Mutually exclusive to * `FileInfo.isFile` and `FileInfo.isDirectory`. */ isSymlink: boolean; /** The size of the file, in bytes. */ size: number; /** The last modification time of the file. This corresponds to the `mtime` * field from `stat` on Linux/Mac OS and `ftLastWriteTime` on Windows. This * may not be available on all platforms. */ mtime: Date | null; /** The last access time of the file. This corresponds to the `atime` * field from `stat` on Unix and `ftLastAccessTime` on Windows. This may not * be available on all platforms. */ atime: Date | null; /** The creation time of the file. This corresponds to the `birthtime` * field from `stat` on Mac/BSD and `ftCreationTime` on Windows. This may * not be available on all platforms. */ birthtime: Date | null; /** ID of the device containing the file. */ dev: number; /** Inode number. * * _Linux/Mac OS only._ */ ino: number | null; /** The underlying raw `st_mode` bits that contain the standard Unix * permissions for this file/directory. * * _Linux/Mac OS only._ */ mode: number | null; /** Number of hard links pointing to this file. * * _Linux/Mac OS only._ */ nlink: number | null; /** User ID of the owner of this file. * * _Linux/Mac OS only._ */ uid: number | null; /** Group ID of the owner of this file. * * _Linux/Mac OS only._ */ gid: number | null; /** Device ID of this file. * * _Linux/Mac OS only._ */ rdev: number | null; /** Blocksize for filesystem I/O. * * _Linux/Mac OS only._ */ blksize: number | null; /** Number of blocks allocated to the file, in 512-byte units. * * _Linux/Mac OS only._ */ blocks: number | null; /** True if this is info for a block device. * * _Linux/Mac OS only._ */ isBlockDevice: boolean | null; /** True if this is info for a char device. * * _Linux/Mac OS only._ */ isCharDevice: boolean | null; /** True if this is info for a fifo. * * _Linux/Mac OS only._ */ isFifo: boolean | null; /** True if this is info for a socket. * * _Linux/Mac OS only._ */ isSocket: boolean | null; } /** Resolves to the absolute normalized path, with symbolic links resolved. * * ```ts * // e.g. given /home/alice/file.txt and current directory /home/alice * await Deno.symlink("file.txt", "symlink_file.txt"); * const realPath = await Deno.realPath("./file.txt"); * const realSymLinkPath = await Deno.realPath("./symlink_file.txt"); * console.log(realPath); // outputs "/home/alice/file.txt" * console.log(realSymLinkPath); // outputs "/home/alice/file.txt" * ``` * * Requires `allow-read` permission for the target path. * * Also requires `allow-read` permission for the `CWD` if the target path is * relative. * * @tags allow-read * @category File System */ export function realPath(path: string | URL): Promise; /** Synchronously returns absolute normalized path, with symbolic links * resolved. * * ```ts * // e.g. given /home/alice/file.txt and current directory /home/alice * Deno.symlinkSync("file.txt", "symlink_file.txt"); * const realPath = Deno.realPathSync("./file.txt"); * const realSymLinkPath = Deno.realPathSync("./symlink_file.txt"); * console.log(realPath); // outputs "/home/alice/file.txt" * console.log(realSymLinkPath); // outputs "/home/alice/file.txt" * ``` * * Requires `allow-read` permission for the target path. * * Also requires `allow-read` permission for the `CWD` if the target path is * relative. * * @tags allow-read * @category File System */ export function realPathSync(path: string | URL): string; /** * Information about a directory entry returned from {@linkcode Deno.readDir} * and {@linkcode Deno.readDirSync}. * * @category File System */ export interface DirEntry { /** The file name of the entry. It is just the entity name and does not * include the full path. */ name: string; /** True if this is info for a regular file. Mutually exclusive to * `DirEntry.isDirectory` and `DirEntry.isSymlink`. */ isFile: boolean; /** True if this is info for a regular directory. Mutually exclusive to * `DirEntry.isFile` and `DirEntry.isSymlink`. */ isDirectory: boolean; /** True if this is info for a symlink. Mutually exclusive to * `DirEntry.isFile` and `DirEntry.isDirectory`. */ isSymlink: boolean; } /** Reads the directory given by `path` and returns an async iterable of * {@linkcode Deno.DirEntry}. The order of entries is not guaranteed. * * ```ts * for await (const dirEntry of Deno.readDir("/")) { * console.log(dirEntry.name); * } * ``` * * Throws error if `path` is not a directory. * * Requires `allow-read` permission. * * @tags allow-read * @category File System */ export function readDir(path: string | URL): AsyncIterable; /** Synchronously reads the directory given by `path` and returns an iterable * of {@linkcode Deno.DirEntry}. The order of entries is not guaranteed. * * ```ts * for (const dirEntry of Deno.readDirSync("/")) { * console.log(dirEntry.name); * } * ``` * * Throws error if `path` is not a directory. * * Requires `allow-read` permission. * * @tags allow-read * @category File System */ export function readDirSync(path: string | URL): Iterable; /** Copies the contents and permissions of one file to another specified path, * by default creating a new file if needed, else overwriting. Fails if target * path is a directory or is unwritable. * * ```ts * await Deno.copyFile("from.txt", "to.txt"); * ``` * * Requires `allow-read` permission on `fromPath`. * * Requires `allow-write` permission on `toPath`. * * @tags allow-read, allow-write * @category File System */ export function copyFile( fromPath: string | URL, toPath: string | URL ): Promise; /** Synchronously copies the contents and permissions of one file to another * specified path, by default creating a new file if needed, else overwriting. * Fails if target path is a directory or is unwritable. * * ```ts * Deno.copyFileSync("from.txt", "to.txt"); * ``` * * Requires `allow-read` permission on `fromPath`. * * Requires `allow-write` permission on `toPath`. * * @tags allow-read, allow-write * @category File System */ export function copyFileSync( fromPath: string | URL, toPath: string | URL ): void; /** Resolves to the full path destination of the named symbolic link. * * ```ts * await Deno.symlink("./test.txt", "./test_link.txt"); * const target = await Deno.readLink("./test_link.txt"); // full path of ./test.txt * ``` * * Throws TypeError if called with a hard link. * * Requires `allow-read` permission. * * @tags allow-read * @category File System */ export function readLink(path: string | URL): Promise; /** Synchronously returns the full path destination of the named symbolic * link. * * ```ts * Deno.symlinkSync("./test.txt", "./test_link.txt"); * const target = Deno.readLinkSync("./test_link.txt"); // full path of ./test.txt * ``` * * Throws TypeError if called with a hard link. * * Requires `allow-read` permission. * * @tags allow-read * @category File System */ export function readLinkSync(path: string | URL): string; /** Resolves to a {@linkcode Deno.FileInfo} for the specified `path`. If * `path` is a symlink, information for the symlink will be returned instead * of what it points to. * * ```ts * import { assert } from "jsr:@std/assert"; * const fileInfo = await Deno.lstat("hello.txt"); * assert(fileInfo.isFile); * ``` * * Requires `allow-read` permission. * * @tags allow-read * @category File System */ export function lstat(path: string | URL): Promise; /** Synchronously returns a {@linkcode Deno.FileInfo} for the specified * `path`. If `path` is a symlink, information for the symlink will be * returned instead of what it points to. * * ```ts * import { assert } from "jsr:@std/assert"; * const fileInfo = Deno.lstatSync("hello.txt"); * assert(fileInfo.isFile); * ``` * * Requires `allow-read` permission. * * @tags allow-read * @category File System */ export function lstatSync(path: string | URL): FileInfo; /** Resolves to a {@linkcode Deno.FileInfo} for the specified `path`. Will * always follow symlinks. * * ```ts * import { assert } from "jsr:@std/assert"; * const fileInfo = await Deno.stat("hello.txt"); * assert(fileInfo.isFile); * ``` * * Requires `allow-read` permission. * * @tags allow-read * @category File System */ export function stat(path: string | URL): Promise; /** Synchronously returns a {@linkcode Deno.FileInfo} for the specified * `path`. Will always follow symlinks. * * ```ts * import { assert } from "jsr:@std/assert"; * const fileInfo = Deno.statSync("hello.txt"); * assert(fileInfo.isFile); * ``` * * Requires `allow-read` permission. * * @tags allow-read * @category File System */ export function statSync(path: string | URL): FileInfo; /** Options for writing to a file. * * @category File System */ export interface WriteFileOptions { /** If set to `true`, will append to a file instead of overwriting previous * contents. * * @default {false} */ append?: boolean; /** Sets the option to allow creating a new file, if one doesn't already * exist at the specified path. * * @default {true} */ create?: boolean; /** If set to `true`, no file, directory, or symlink is allowed to exist at * the target location. When createNew is set to `true`, `create` is ignored. * * @default {false} */ createNew?: boolean; /** Permissions always applied to file. */ mode?: number; /** An abort signal to allow cancellation of the file write operation. * * If the signal becomes aborted the write file operation will be stopped * and the promise returned will be rejected with an {@linkcode AbortError}. */ signal?: AbortSignal; } /** Write `data` to the given `path`, by default creating a new file if * needed, else overwriting. * * ```ts * const encoder = new TextEncoder(); * const data = encoder.encode("Hello world\n"); * await Deno.writeFile("hello1.txt", data); // overwrite "hello1.txt" or create it * await Deno.writeFile("hello2.txt", data, { create: false }); // only works if "hello2.txt" exists * await Deno.writeFile("hello3.txt", data, { mode: 0o777 }); // set permissions on new file * await Deno.writeFile("hello4.txt", data, { append: true }); // add data to the end of the file * ``` * * Requires `allow-write` permission, and `allow-read` if `options.create` is * `false`. * * @tags allow-read, allow-write * @category File System */ export function writeFile( path: string | URL, data: Uint8Array | ReadableStream, options?: WriteFileOptions ): Promise; /** Synchronously write `data` to the given `path`, by default creating a new * file if needed, else overwriting. * * ```ts * const encoder = new TextEncoder(); * const data = encoder.encode("Hello world\n"); * Deno.writeFileSync("hello1.txt", data); // overwrite "hello1.txt" or create it * Deno.writeFileSync("hello2.txt", data, { create: false }); // only works if "hello2.txt" exists * Deno.writeFileSync("hello3.txt", data, { mode: 0o777 }); // set permissions on new file * Deno.writeFileSync("hello4.txt", data, { append: true }); // add data to the end of the file * ``` * * Requires `allow-write` permission, and `allow-read` if `options.create` is * `false`. * * @tags allow-read, allow-write * @category File System */ export function writeFileSync( path: string | URL, data: Uint8Array, options?: WriteFileOptions ): void; /** Write string `data` to the given `path`, by default creating a new file if * needed, else overwriting. * * ```ts * await Deno.writeTextFile("hello1.txt", "Hello world\n"); // overwrite "hello1.txt" or create it * ``` * * Requires `allow-write` permission, and `allow-read` if `options.create` is * `false`. * * @tags allow-read, allow-write * @category File System */ export function writeTextFile( path: string | URL, data: string | ReadableStream, options?: WriteFileOptions ): Promise; /** Synchronously write string `data` to the given `path`, by default creating * a new file if needed, else overwriting. * * ```ts * Deno.writeTextFileSync("hello1.txt", "Hello world\n"); // overwrite "hello1.txt" or create it * ``` * * Requires `allow-write` permission, and `allow-read` if `options.create` is * `false`. * * @tags allow-read, allow-write * @category File System */ export function writeTextFileSync( path: string | URL, data: string, options?: WriteFileOptions ): void; /** Truncates (or extends) the specified file, to reach the specified `len`. * If `len` is not specified then the entire file contents are truncated. * * ### Truncate the entire file * ```ts * await Deno.truncate("my_file.txt"); * ``` * * ### Truncate part of the file * * ```ts * const file = await Deno.makeTempFile(); * await Deno.writeTextFile(file, "Hello World"); * await Deno.truncate(file, 7); * const data = await Deno.readFile(file); * console.log(new TextDecoder().decode(data)); // "Hello W" * ``` * * Requires `allow-write` permission. * * @tags allow-write * @category File System */ export function truncate(name: string, len?: number): Promise; /** Synchronously truncates (or extends) the specified file, to reach the * specified `len`. If `len` is not specified then the entire file contents * are truncated. * * ### Truncate the entire file * * ```ts * Deno.truncateSync("my_file.txt"); * ``` * * ### Truncate part of the file * * ```ts * const file = Deno.makeTempFileSync(); * Deno.writeFileSync(file, new TextEncoder().encode("Hello World")); * Deno.truncateSync(file, 7); * const data = Deno.readFileSync(file); * console.log(new TextDecoder().decode(data)); * ``` * * Requires `allow-write` permission. * * @tags allow-write * @category File System */ export function truncateSync(name: string, len?: number): void; /** @category Runtime * * @deprecated This will be removed in Deno 2.0. */ export interface OpMetrics { opsDispatched: number; opsDispatchedSync: number; opsDispatchedAsync: number; opsDispatchedAsyncUnref: number; opsCompleted: number; opsCompletedSync: number; opsCompletedAsync: number; opsCompletedAsyncUnref: number; bytesSentControl: number; bytesSentData: number; bytesReceived: number; } /** * Additional information for FsEvent objects with the "other" kind. * * - `"rescan"`: rescan notices indicate either a lapse in the events or a * change in the filesystem such that events received so far can no longer * be relied on to represent the state of the filesystem now. An * application that simply reacts to file changes may not care about this. * An application that keeps an in-memory representation of the filesystem * will need to care, and will need to refresh that representation directly * from the filesystem. * * @category File System */ export type FsEventFlag = "rescan"; /** * Represents a unique file system event yielded by a * {@linkcode Deno.FsWatcher}. * * @category File System */ export interface FsEvent { /** The kind/type of the file system event. */ kind: | "any" | "access" | "create" | "modify" | "rename" | "remove" | "other"; /** An array of paths that are associated with the file system event. */ paths: string[]; /** Any additional flags associated with the event. */ flag?: FsEventFlag; } /** * Returned by {@linkcode Deno.watchFs}. It is an async iterator yielding up * system events. To stop watching the file system by calling `.close()` * method. * * @category File System */ export interface FsWatcher extends AsyncIterable, Disposable { /** Stops watching the file system and closes the watcher resource. */ close(): void; /** * Stops watching the file system and closes the watcher resource. */ return?(value?: any): Promise>; [Symbol.asyncIterator](): AsyncIterableIterator; } /** Watch for file system events against one or more `paths`, which can be * files or directories. These paths must exist already. One user action (e.g. * `touch test.file`) can generate multiple file system events. Likewise, * one user action can result in multiple file paths in one event (e.g. `mv * old_name.txt new_name.txt`). * * The recursive option is `true` by default and, for directories, will watch * the specified directory and all sub directories. * * Note that the exact ordering of the events can vary between operating * systems. * * ```ts * const watcher = Deno.watchFs("/"); * for await (const event of watcher) { * console.log(">>>> event", event); * // { kind: "create", paths: [ "/foo.txt" ] } * } * ``` * * Call `watcher.close()` to stop watching. * * ```ts * const watcher = Deno.watchFs("/"); * * setTimeout(() => { * watcher.close(); * }, 5000); * * for await (const event of watcher) { * console.log(">>>> event", event); * } * ``` * * Requires `allow-read` permission. * * @tags allow-read * @category File System */ export function watchFs( paths: string | string[], options?: { recursive: boolean } ): FsWatcher; /** Operating signals which can be listened for or sent to sub-processes. What * signals and what their standard behaviors are OS dependent. * * @category Runtime */ export type Signal = | "SIGABRT" | "SIGALRM" | "SIGBREAK" | "SIGBUS" | "SIGCHLD" | "SIGCONT" | "SIGEMT" | "SIGFPE" | "SIGHUP" | "SIGILL" | "SIGINFO" | "SIGINT" | "SIGIO" | "SIGPOLL" | "SIGUNUSED" | "SIGKILL" | "SIGPIPE" | "SIGPROF" | "SIGPWR" | "SIGQUIT" | "SIGSEGV" | "SIGSTKFLT" | "SIGSTOP" | "SIGSYS" | "SIGTERM" | "SIGTRAP" | "SIGTSTP" | "SIGTTIN" | "SIGTTOU" | "SIGURG" | "SIGUSR1" | "SIGUSR2" | "SIGVTALRM" | "SIGWINCH" | "SIGXCPU" | "SIGXFSZ"; /** Registers the given function as a listener of the given signal event. * * ```ts * Deno.addSignalListener( * "SIGTERM", * () => { * console.log("SIGTERM!") * } * ); * ``` * * _Note_: On Windows only `"SIGINT"` (CTRL+C) and `"SIGBREAK"` (CTRL+Break) * are supported. * * @category Runtime */ export function addSignalListener(signal: Signal, handler: () => void): void; /** Removes the given signal listener that has been registered with * {@linkcode Deno.addSignalListener}. * * ```ts * const listener = () => { * console.log("SIGTERM!") * }; * Deno.addSignalListener("SIGTERM", listener); * Deno.removeSignalListener("SIGTERM", listener); * ``` * * _Note_: On Windows only `"SIGINT"` (CTRL+C) and `"SIGBREAK"` (CTRL+Break) * are supported. * * @category Runtime */ export function removeSignalListener( signal: Signal, handler: () => void ): void; /** Create a child process. * * If any stdio options are not set to `"piped"`, accessing the corresponding * field on the `Command` or its `CommandOutput` will throw a `TypeError`. * * If `stdin` is set to `"piped"`, the `stdin` {@linkcode WritableStream} * needs to be closed manually. * * `Command` acts as a builder. Each call to {@linkcode Command.spawn} or * {@linkcode Command.output} will spawn a new subprocess. * * @example Spawn a subprocess and pipe the output to a file * * ```ts * const command = new Deno.Command(Deno.execPath(), { * args: [ * "eval", * "console.log('Hello World')", * ], * stdin: "piped", * stdout: "piped", * }); * const child = command.spawn(); * * // open a file and pipe the subprocess output to it. * child.stdout.pipeTo( * Deno.openSync("output", { write: true, create: true }).writable, * ); * * // manually close stdin * child.stdin.close(); * const status = await child.status; * ``` * * @example Spawn a subprocess and collect its output * * ```ts * const command = new Deno.Command(Deno.execPath(), { * args: [ * "eval", * "console.log('hello'); console.error('world')", * ], * }); * const { code, stdout, stderr } = await command.output(); * console.assert(code === 0); * console.assert("hello\n" === new TextDecoder().decode(stdout)); * console.assert("world\n" === new TextDecoder().decode(stderr)); * ``` * * @example Spawn a subprocess and collect its output synchronously * * ```ts * const command = new Deno.Command(Deno.execPath(), { * args: [ * "eval", * "console.log('hello'); console.error('world')", * ], * }); * const { code, stdout, stderr } = command.outputSync(); * console.assert(code === 0); * console.assert("hello\n" === new TextDecoder().decode(stdout)); * console.assert("world\n" === new TextDecoder().decode(stderr)); * ``` * * @tags allow-run * @category Subprocess */ export class Command { constructor(command: string | URL, options?: CommandOptions); /** * Executes the {@linkcode Deno.Command}, waiting for it to finish and * collecting all of its output. * * Will throw an error if `stdin: "piped"` is set. * * If options `stdout` or `stderr` are not set to `"piped"`, accessing the * corresponding field on {@linkcode Deno.CommandOutput} will throw a `TypeError`. */ output(): Promise; /** * Synchronously executes the {@linkcode Deno.Command}, waiting for it to * finish and collecting all of its output. * * Will throw an error if `stdin: "piped"` is set. * * If options `stdout` or `stderr` are not set to `"piped"`, accessing the * corresponding field on {@linkcode Deno.CommandOutput} will throw a `TypeError`. */ outputSync(): CommandOutput; /** * Spawns a streamable subprocess, allowing to use the other methods. */ spawn(): ChildProcess; } /** * The interface for handling a child process returned from * {@linkcode Deno.Command.spawn}. * * @category Subprocess */ export class ChildProcess implements Disposable { get stdin(): WritableStream; get stdout(): ReadableStream; get stderr(): ReadableStream; readonly pid: number; /** Get the status of the child. */ readonly status: Promise; /** Waits for the child to exit completely, returning all its output and * status. */ output(): Promise; /** Kills the process with given {@linkcode Deno.Signal}. * * Defaults to `SIGTERM` if no signal is provided. * * @param [signo="SIGTERM"] */ kill(signo?: Signal): void; /** Ensure that the status of the child process prevents the Deno process * from exiting. */ ref(): void; /** Ensure that the status of the child process does not block the Deno * process from exiting. */ unref(): void; [Symbol.asyncDispose](): Promise; } /** * Options which can be set when calling {@linkcode Deno.Command}. * * @category Subprocess */ export interface CommandOptions { /** Arguments to pass to the process. */ args?: string[]; /** * The working directory of the process. * * If not specified, the `cwd` of the parent process is used. */ cwd?: string | URL; /** * Clear environmental variables from parent process. * * Doesn't guarantee that only `env` variables are present, as the OS may * set environmental variables for processes. * * @default {false} */ clearEnv?: boolean; /** Environmental variables to pass to the subprocess. */ env?: Record; /** * Sets the child process’s user ID. This translates to a setuid call in the * child process. Failure in the set uid call will cause the spawn to fail. */ uid?: number; /** Similar to `uid`, but sets the group ID of the child process. */ gid?: number; /** * An {@linkcode AbortSignal} that allows closing the process using the * corresponding {@linkcode AbortController} by sending the process a * SIGTERM signal. * * Not supported in {@linkcode Deno.Command.outputSync}. */ signal?: AbortSignal; /** How `stdin` of the spawned process should be handled. * * Defaults to `"inherit"` for `output` & `outputSync`, * and `"inherit"` for `spawn`. */ stdin?: "piped" | "inherit" | "null"; /** How `stdout` of the spawned process should be handled. * * Defaults to `"piped"` for `output` & `outputSync`, * and `"inherit"` for `spawn`. */ stdout?: "piped" | "inherit" | "null"; /** How `stderr` of the spawned process should be handled. * * Defaults to `"piped"` for `output` & `outputSync`, * and `"inherit"` for `spawn`. */ stderr?: "piped" | "inherit" | "null"; /** Skips quoting and escaping of the arguments on windows. This option * is ignored on non-windows platforms. * * @default {false} */ windowsRawArguments?: boolean; } /** * @category Subprocess */ export interface CommandStatus { /** If the child process exits with a 0 status code, `success` will be set * to `true`, otherwise `false`. */ success: boolean; /** The exit code of the child process. */ code: number; /** The signal associated with the child process. */ signal: Signal | null; } /** * The interface returned from calling {@linkcode Deno.Command.output} or * {@linkcode Deno.Command.outputSync} which represents the result of spawning the * child process. * * @category Subprocess */ export interface CommandOutput extends CommandStatus { /** The buffered output from the child process' `stdout`. */ readonly stdout: Uint8Array; /** The buffered output from the child process' `stderr`. */ readonly stderr: Uint8Array; } /** Option which can be specified when performing {@linkcode Deno.inspect}. * * @category I/O */ export interface InspectOptions { /** Stylize output with ANSI colors. * * @default {false} */ colors?: boolean; /** Try to fit more than one entry of a collection on the same line. * * @default {true} */ compact?: boolean; /** Traversal depth for nested objects. * * @default {4} */ depth?: number; /** The maximum length for an inspection to take up a single line. * * @default {80} */ breakLength?: number; /** Whether or not to escape sequences. * * @default {true} */ escapeSequences?: boolean; /** The maximum number of iterable entries to print. * * @default {100} */ iterableLimit?: number; /** Show a Proxy's target and handler. * * @default {false} */ showProxy?: boolean; /** Sort Object, Set and Map entries by key. * * @default {false} */ sorted?: boolean; /** Add a trailing comma for multiline collections. * * @default {false} */ trailingComma?: boolean; /** Evaluate the result of calling getters. * * @default {false} */ getters?: boolean; /** Show an object's non-enumerable properties. * * @default {false} */ showHidden?: boolean; /** The maximum length of a string before it is truncated with an * ellipsis. */ strAbbreviateSize?: number; } /** Converts the input into a string that has the same format as printed by * `console.log()`. * * ```ts * const obj = { * a: 10, * b: "hello", * }; * const objAsString = Deno.inspect(obj); // { a: 10, b: "hello" } * console.log(obj); // prints same value as objAsString, e.g. { a: 10, b: "hello" } * ``` * * A custom inspect functions can be registered on objects, via the symbol * `Symbol.for("Deno.customInspect")`, to control and customize the output * of `inspect()` or when using `console` logging: * * ```ts * class A { * x = 10; * y = "hello"; * [Symbol.for("Deno.customInspect")]() { * return `x=${this.x}, y=${this.y}`; * } * } * * const inStringFormat = Deno.inspect(new A()); // "x=10, y=hello" * console.log(inStringFormat); // prints "x=10, y=hello" * ``` * * A depth can be specified by using the `depth` option: * * ```ts * Deno.inspect({a: {b: {c: {d: 'hello'}}}}, {depth: 2}); // { a: { b: [Object] } } * ``` * * @category I/O */ export function inspect(value: unknown, options?: InspectOptions): string; /** The name of a privileged feature which needs permission. * * @category Permissions */ export type PermissionName = | "run" | "read" | "write" | "net" | "env" | "sys" | "ffi"; /** The current status of the permission: * * - `"granted"` - the permission has been granted. * - `"denied"` - the permission has been explicitly denied. * - `"prompt"` - the permission has not explicitly granted nor denied. * * @category Permissions */ export type PermissionState = "granted" | "denied" | "prompt"; /** The permission descriptor for the `allow-run` and `deny-run` permissions, which controls * access to what sub-processes can be executed by Deno. The option `command` * allows scoping the permission to a specific executable. * * **Warning, in practice, `allow-run` is effectively the same as `allow-all` * in the sense that malicious code could execute any arbitrary code on the * host.** * * @category Permissions */ export interface RunPermissionDescriptor { name: "run"; /** An `allow-run` or `deny-run` permission can be scoped to a specific executable, * which would be relative to the start-up CWD of the Deno CLI. */ command?: string | URL; } /** The permission descriptor for the `allow-read` and `deny-read` permissions, which controls * access to reading resources from the local host. The option `path` allows * scoping the permission to a specific path (and if the path is a directory * any sub paths). * * Permission granted under `allow-read` only allows runtime code to attempt * to read, the underlying operating system may apply additional permissions. * * @category Permissions */ export interface ReadPermissionDescriptor { name: "read"; /** An `allow-read` or `deny-read` permission can be scoped to a specific path (and if * the path is a directory, any sub paths). */ path?: string | URL; } /** The permission descriptor for the `allow-write` and `deny-write` permissions, which * controls access to writing to resources from the local host. The option * `path` allow scoping the permission to a specific path (and if the path is * a directory any sub paths). * * Permission granted under `allow-write` only allows runtime code to attempt * to write, the underlying operating system may apply additional permissions. * * @category Permissions */ export interface WritePermissionDescriptor { name: "write"; /** An `allow-write` or `deny-write` permission can be scoped to a specific path (and if * the path is a directory, any sub paths). */ path?: string | URL; } /** The permission descriptor for the `allow-net` and `deny-net` permissions, which controls * access to opening network ports and connecting to remote hosts via the * network. The option `host` allows scoping the permission for outbound * connection to a specific host and port. * * @category Permissions */ export interface NetPermissionDescriptor { name: "net"; /** Optional host string of the form `"[:]"`. Examples: * * "github.com" * "deno.land:8080" */ host?: string; } /** The permission descriptor for the `allow-env` and `deny-env` permissions, which controls * access to being able to read and write to the process environment variables * as well as access other information about the environment. The option * `variable` allows scoping the permission to a specific environment * variable. * * @category Permissions */ export interface EnvPermissionDescriptor { name: "env"; /** Optional environment variable name (e.g. `PATH`). */ variable?: string; } /** The permission descriptor for the `allow-sys` and `deny-sys` permissions, which controls * access to sensitive host system information, which malicious code might * attempt to exploit. The option `kind` allows scoping the permission to a * specific piece of information. * * @category Permissions */ export interface SysPermissionDescriptor { name: "sys"; /** The specific information to scope the permission to. */ kind?: | "loadavg" | "hostname" | "systemMemoryInfo" | "networkInterfaces" | "osRelease" | "osUptime" | "uid" | "gid" | "username" | "cpus" | "homedir" | "statfs" | "getPriority" | "setPriority"; } /** The permission descriptor for the `allow-ffi` and `deny-ffi` permissions, which controls * access to loading _foreign_ code and interfacing with it via the * [Foreign Function Interface API](https://docs.deno.com/runtime/manual/runtime/ffi_api) * available in Deno. The option `path` allows scoping the permission to a * specific path on the host. * * @category Permissions */ export interface FfiPermissionDescriptor { name: "ffi"; /** Optional path on the local host to scope the permission to. */ path?: string | URL; } /** Permission descriptors which define a permission and can be queried, * requested, or revoked. * * View the specifics of the individual descriptors for more information about * each permission kind. * * @category Permissions */ export type PermissionDescriptor = | RunPermissionDescriptor | ReadPermissionDescriptor | WritePermissionDescriptor | NetPermissionDescriptor | EnvPermissionDescriptor | SysPermissionDescriptor | FfiPermissionDescriptor; /** The interface which defines what event types are supported by * {@linkcode PermissionStatus} instances. * * @category Permissions */ export interface PermissionStatusEventMap { change: Event; } /** An {@linkcode EventTarget} returned from the {@linkcode Deno.permissions} * API which can provide updates to any state changes of the permission. * * @category Permissions */ export class PermissionStatus extends EventTarget { // deno-lint-ignore no-explicit-any onchange: ((this: PermissionStatus, ev: Event) => any) | null; readonly state: PermissionState; /** * Describes if permission is only granted partially, eg. an access * might be granted to "/foo" directory, but denied for "/foo/bar". * In such case this field will be set to `true` when querying for * read permissions of "/foo" directory. */ readonly partial: boolean; addEventListener( type: K, listener: ( this: PermissionStatus, ev: PermissionStatusEventMap[K] ) => any, options?: boolean | AddEventListenerOptions ): void; addEventListener( type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions ): void; removeEventListener( type: K, listener: ( this: PermissionStatus, ev: PermissionStatusEventMap[K] ) => any, options?: boolean | EventListenerOptions ): void; removeEventListener( type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions ): void; } /** * Deno's permission management API. * * The class which provides the interface for the {@linkcode Deno.permissions} * global instance and is based on the web platform * [Permissions API](https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API), * though some proposed parts of the API which are useful in a server side * runtime context were removed or abandoned in the web platform specification * which is why it was chosen to locate it in the {@linkcode Deno} namespace * instead. * * By default, if the `stdin`/`stdout` is TTY for the Deno CLI (meaning it can * send and receive text), then the CLI will prompt the user to grant * permission when an un-granted permission is requested. This behavior can * be changed by using the `--no-prompt` command at startup. When prompting * the CLI will request the narrowest permission possible, potentially making * it annoying to the user. The permissions APIs allow the code author to * request a wider set of permissions at one time in order to provide a better * user experience. * * @category Permissions */ export class Permissions { /** Resolves to the current status of a permission. * * Note, if the permission is already granted, `request()` will not prompt * the user again, therefore `query()` is only necessary if you are going * to react differently existing permissions without wanting to modify them * or prompt the user to modify them. * * ```ts * const status = await Deno.permissions.query({ name: "read", path: "/etc" }); * console.log(status.state); * ``` */ query(desc: PermissionDescriptor): Promise; /** Returns the current status of a permission. * * Note, if the permission is already granted, `request()` will not prompt * the user again, therefore `querySync()` is only necessary if you are going * to react differently existing permissions without wanting to modify them * or prompt the user to modify them. * * ```ts * const status = Deno.permissions.querySync({ name: "read", path: "/etc" }); * console.log(status.state); * ``` */ querySync(desc: PermissionDescriptor): PermissionStatus; /** Revokes a permission, and resolves to the state of the permission. * * ```ts * import { assert } from "jsr:@std/assert"; * * const status = await Deno.permissions.revoke({ name: "run" }); * assert(status.state !== "granted") * ``` */ revoke(desc: PermissionDescriptor): Promise; /** Revokes a permission, and returns the state of the permission. * * ```ts * import { assert } from "jsr:@std/assert"; * * const status = Deno.permissions.revokeSync({ name: "run" }); * assert(status.state !== "granted") * ``` */ revokeSync(desc: PermissionDescriptor): PermissionStatus; /** Requests the permission, and resolves to the state of the permission. * * If the permission is already granted, the user will not be prompted to * grant the permission again. * * ```ts * const status = await Deno.permissions.request({ name: "env" }); * if (status.state === "granted") { * console.log("'env' permission is granted."); * } else { * console.log("'env' permission is denied."); * } * ``` */ request(desc: PermissionDescriptor): Promise; /** Requests the permission, and returns the state of the permission. * * If the permission is already granted, the user will not be prompted to * grant the permission again. * * ```ts * const status = Deno.permissions.requestSync({ name: "env" }); * if (status.state === "granted") { * console.log("'env' permission is granted."); * } else { * console.log("'env' permission is denied."); * } * ``` */ requestSync(desc: PermissionDescriptor): PermissionStatus; } /** Deno's permission management API. * * It is a singleton instance of the {@linkcode Permissions} object and is * based on the web platform * [Permissions API](https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API), * though some proposed parts of the API which are useful in a server side * runtime context were removed or abandoned in the web platform specification * which is why it was chosen to locate it in the {@linkcode Deno} namespace * instead. * * By default, if the `stdin`/`stdout` is TTY for the Deno CLI (meaning it can * send and receive text), then the CLI will prompt the user to grant * permission when an un-granted permission is requested. This behavior can * be changed by using the `--no-prompt` command at startup. When prompting * the CLI will request the narrowest permission possible, potentially making * it annoying to the user. The permissions APIs allow the code author to * request a wider set of permissions at one time in order to provide a better * user experience. * * Requesting already granted permissions will not prompt the user and will * return that the permission was granted. * * ### Querying * * ```ts * const status = await Deno.permissions.query({ name: "read", path: "/etc" }); * console.log(status.state); * ``` * * ```ts * const status = Deno.permissions.querySync({ name: "read", path: "/etc" }); * console.log(status.state); * ``` * * ### Revoking * * ```ts * import { assert } from "jsr:@std/assert"; * * const status = await Deno.permissions.revoke({ name: "run" }); * assert(status.state !== "granted") * ``` * * ```ts * import { assert } from "jsr:@std/assert"; * * const status = Deno.permissions.revokeSync({ name: "run" }); * assert(status.state !== "granted") * ``` * * ### Requesting * * ```ts * const status = await Deno.permissions.request({ name: "env" }); * if (status.state === "granted") { * console.log("'env' permission is granted."); * } else { * console.log("'env' permission is denied."); * } * ``` * * ```ts * const status = Deno.permissions.requestSync({ name: "env" }); * if (status.state === "granted") { * console.log("'env' permission is granted."); * } else { * console.log("'env' permission is denied."); * } * ``` * * @category Permissions */ export const permissions: Permissions; /** Information related to the build of the current Deno runtime. * * Users are discouraged from code branching based on this information, as * assumptions about what is available in what build environment might change * over time. Developers should specifically sniff out the features they * intend to use. * * The intended use for the information is for logging and debugging purposes. * * @category Runtime */ export const build: { /** The [LLVM](https://llvm.org/) target triple, which is the combination * of `${arch}-${vendor}-${os}` and represent the specific build target that * the current runtime was built for. */ target: string; /** Instruction set architecture that the Deno CLI was built for. */ arch: "x86_64" | "aarch64"; /** The operating system that the Deno CLI was built for. `"darwin"` is * also known as OSX or MacOS. */ os: | "darwin" | "linux" | "android" | "windows" | "freebsd" | "netbsd" | "aix" | "solaris" | "illumos"; /** The computer vendor that the Deno CLI was built for. */ vendor: string; /** Optional environment flags that were set for this build of Deno CLI. */ env?: string; }; /** Version information related to the current Deno CLI runtime environment. * * Users are discouraged from code branching based on this information, as * assumptions about what is available in what build environment might change * over time. Developers should specifically sniff out the features they * intend to use. * * The intended use for the information is for logging and debugging purposes. * * @category Runtime */ export const version: { /** Deno CLI's version. For example: `"1.26.0"`. */ deno: string; /** The V8 version used by Deno. For example: `"10.7.100.0"`. * * V8 is the underlying JavaScript runtime platform that Deno is built on * top of. */ v8: string; /** The TypeScript version used by Deno. For example: `"4.8.3"`. * * A version of the TypeScript type checker and language server is built-in * to the Deno CLI. */ typescript: string; }; /** Returns the script arguments to the program. * * Give the following command line invocation of Deno: * * ```sh * deno run --allow-read https://examples.deno.land/command-line-arguments.ts Sushi * ``` * * Then `Deno.args` will contain: * * ```ts * [ "Sushi" ] * ``` * * If you are looking for a structured way to parse arguments, there is * [`parseArgs()`](https://jsr.io/@std/cli/doc/parse-args/~/parseArgs) from * the Deno Standard Library. * * @category Runtime */ export const args: string[]; /** The URL of the entrypoint module entered from the command-line. It * requires read permission to the CWD. * * Also see {@linkcode ImportMeta} for other related information. * * @tags allow-read * @category Runtime */ export const mainModule: string; /** Options that can be used with {@linkcode symlink} and * {@linkcode symlinkSync}. * * @category File System */ export interface SymlinkOptions { /** Specify the symbolic link type as file, directory or NTFS junction. This * option only applies to Windows and is ignored on other operating systems. */ type: "file" | "dir" | "junction"; } /** * Creates `newpath` as a symbolic link to `oldpath`. * * The `options.type` parameter can be set to `"file"`, `"dir"` or `"junction"`. * This argument is only available on Windows and ignored on other platforms. * * ```ts * await Deno.symlink("old/name", "new/name"); * ``` * * Requires full `allow-read` and `allow-write` permissions. * * @tags allow-read, allow-write * @category File System */ export function symlink( oldpath: string | URL, newpath: string | URL, options?: SymlinkOptions ): Promise; /** * Creates `newpath` as a symbolic link to `oldpath`. * * The `options.type` parameter can be set to `"file"`, `"dir"` or `"junction"`. * This argument is only available on Windows and ignored on other platforms. * * ```ts * Deno.symlinkSync("old/name", "new/name"); * ``` * * Requires full `allow-read` and `allow-write` permissions. * * @tags allow-read, allow-write * @category File System */ export function symlinkSync( oldpath: string | URL, newpath: string | URL, options?: SymlinkOptions ): void; /** * Synchronously changes the access (`atime`) and modification (`mtime`) times * of a file system object referenced by `path`. Given times are either in * seconds (UNIX epoch time) or as `Date` objects. * * ```ts * Deno.utimeSync("myfile.txt", 1556495550, new Date()); * ``` * * Requires `allow-write` permission. * * @tags allow-write * @category File System */ export function utimeSync( path: string | URL, atime: number | Date, mtime: number | Date ): void; /** * Changes the access (`atime`) and modification (`mtime`) times of a file * system object referenced by `path`. Given times are either in seconds * (UNIX epoch time) or as `Date` objects. * * ```ts * await Deno.utime("myfile.txt", 1556495550, new Date()); * ``` * * Requires `allow-write` permission. * * @tags allow-write * @category File System */ export function utime( path: string | URL, atime: number | Date, mtime: number | Date ): Promise; /** Retrieve the process umask. If `mask` is provided, sets the process umask. * This call always returns what the umask was before the call. * * ```ts * console.log(Deno.umask()); // e.g. 18 (0o022) * const prevUmaskValue = Deno.umask(0o077); // e.g. 18 (0o022) * console.log(Deno.umask()); // e.g. 63 (0o077) * ``` * * This API is under consideration to determine if permissions are required to * call it. * * *Note*: This API is not implemented on Windows * * @category File System */ export function umask(mask?: number): number; /** The object that is returned from a {@linkcode Deno.upgradeWebSocket} * request. * * @category Web Sockets */ export interface WebSocketUpgrade { /** The response object that represents the HTTP response to the client, * which should be used to the {@linkcode RequestEvent} `.respondWith()` for * the upgrade to be successful. */ response: Response; /** The {@linkcode WebSocket} interface to communicate to the client via a * web socket. */ socket: WebSocket; } /** Options which can be set when performing a * {@linkcode Deno.upgradeWebSocket} upgrade of a {@linkcode Request} * * @category Web Sockets */ export interface UpgradeWebSocketOptions { /** Sets the `.protocol` property on the client side web socket to the * value provided here, which should be one of the strings specified in the * `protocols` parameter when requesting the web socket. This is intended * for clients and servers to specify sub-protocols to use to communicate to * each other. */ protocol?: string; /** If the client does not respond to this frame with a * `pong` within the timeout specified, the connection is deemed * unhealthy and is closed. The `close` and `error` event will be emitted. * * The unit is seconds, with a default of 30. * Set to `0` to disable timeouts. */ idleTimeout?: number; } /** * Upgrade an incoming HTTP request to a WebSocket. * * Given a {@linkcode Request}, returns a pair of {@linkcode WebSocket} and * {@linkcode Response} instances. The original request must be responded to * with the returned response for the websocket upgrade to be successful. * * ```ts * Deno.serve((req) => { * if (req.headers.get("upgrade") !== "websocket") { * return new Response(null, { status: 501 }); * } * const { socket, response } = Deno.upgradeWebSocket(req); * socket.addEventListener("open", () => { * console.log("a client connected!"); * }); * socket.addEventListener("message", (event) => { * if (event.data === "ping") { * socket.send("pong"); * } * }); * return response; * }); * ``` * * If the request body is disturbed (read from) before the upgrade is * completed, upgrading fails. * * This operation does not yet consume the request or open the websocket. This * only happens once the returned response has been passed to `respondWith()`. * * @category Web Sockets */ export function upgradeWebSocket( request: Request, options?: UpgradeWebSocketOptions ): WebSocketUpgrade; /** Send a signal to process under given `pid`. The value and meaning of the * `signal` to the process is operating system and process dependant. * {@linkcode Signal} provides the most common signals. Default signal * is `"SIGTERM"`. * * The term `kill` is adopted from the UNIX-like command line command `kill` * which also signals processes. * * If `pid` is negative, the signal will be sent to the process group * identified by `pid`. An error will be thrown if a negative `pid` is used on * Windows. * * ```ts * const command = new Deno.Command("sleep", { args: ["10000"] }); * const child = command.spawn(); * * Deno.kill(child.pid, "SIGINT"); * ``` * * Requires `allow-run` permission. * * @tags allow-run * @category Subprocess */ export function kill(pid: number, signo?: Signal): void; /** The type of the resource record to resolve via DNS using * {@linkcode Deno.resolveDns}. * * Only the listed types are supported currently. * * @category Network */ export type RecordType = | "A" | "AAAA" | "ANAME" | "CAA" | "CNAME" | "MX" | "NAPTR" | "NS" | "PTR" | "SOA" | "SRV" | "TXT"; /** * Options which can be set when using {@linkcode Deno.resolveDns}. * * @category Network */ export interface ResolveDnsOptions { /** The name server to be used for lookups. * * If not specified, defaults to the system configuration. For example * `/etc/resolv.conf` on Unix-like systems. */ nameServer?: { /** The IP address of the name server. */ ipAddr: string; /** The port number the query will be sent to. * * @default {53} */ port?: number; }; /** * An abort signal to allow cancellation of the DNS resolution operation. * If the signal becomes aborted the resolveDns operation will be stopped * and the promise returned will be rejected with an AbortError. */ signal?: AbortSignal; } /** If {@linkcode Deno.resolveDns} is called with `"CAA"` record type * specified, it will resolve with an array of objects with this interface. * * @category Network */ export interface CaaRecord { /** If `true`, indicates that the corresponding property tag **must** be * understood if the semantics of the CAA record are to be correctly * interpreted by an issuer. * * Issuers **must not** issue certificates for a domain if the relevant CAA * Resource Record set contains unknown property tags that have `critical` * set. */ critical: boolean; /** An string that represents the identifier of the property represented by * the record. */ tag: string; /** The value associated with the tag. */ value: string; } /** If {@linkcode Deno.resolveDns} is called with `"MX"` record type * specified, it will return an array of objects with this interface. * * @category Network */ export interface MxRecord { /** A priority value, which is a relative value compared to the other * preferences of MX records for the domain. */ preference: number; /** The server that mail should be delivered to. */ exchange: string; } /** If {@linkcode Deno.resolveDns} is called with `"NAPTR"` record type * specified, it will return an array of objects with this interface. * * @category Network */ export interface NaptrRecord { order: number; preference: number; flags: string; services: string; regexp: string; replacement: string; } /** If {@linkcode Deno.resolveDns} is called with `"SOA"` record type * specified, it will return an array of objects with this interface. * * @category Network */ export interface SoaRecord { mname: string; rname: string; serial: number; refresh: number; retry: number; expire: number; minimum: number; } /** If {@linkcode Deno.resolveDns} is called with `"SRV"` record type * specified, it will return an array of objects with this interface. * * @category Network */ export interface SrvRecord { priority: number; weight: number; port: number; target: string; } /** * Performs DNS resolution against the given query, returning resolved * records. * * Fails in the cases such as: * * - the query is in invalid format. * - the options have an invalid parameter. For example `nameServer.port` is * beyond the range of 16-bit unsigned integer. * - the request timed out. * * ```ts * const a = await Deno.resolveDns("example.com", "A"); * * const aaaa = await Deno.resolveDns("example.com", "AAAA", { * nameServer: { ipAddr: "8.8.8.8", port: 53 }, * }); * ``` * * Requires `allow-net` permission. * * @tags allow-net * @category Network */ export function resolveDns( query: string, recordType: "A" | "AAAA" | "ANAME" | "CNAME" | "NS" | "PTR", options?: ResolveDnsOptions ): Promise; /** * Performs DNS resolution against the given query, returning resolved * records. * * Fails in the cases such as: * * - the query is in invalid format. * - the options have an invalid parameter. For example `nameServer.port` is * beyond the range of 16-bit unsigned integer. * - the request timed out. * * ```ts * const a = await Deno.resolveDns("example.com", "A"); * * const aaaa = await Deno.resolveDns("example.com", "AAAA", { * nameServer: { ipAddr: "8.8.8.8", port: 53 }, * }); * ``` * * Requires `allow-net` permission. * * @tags allow-net * @category Network */ export function resolveDns( query: string, recordType: "CAA", options?: ResolveDnsOptions ): Promise; /** * Performs DNS resolution against the given query, returning resolved * records. * * Fails in the cases such as: * * - the query is in invalid format. * - the options have an invalid parameter. For example `nameServer.port` is * beyond the range of 16-bit unsigned integer. * - the request timed out. * * ```ts * const a = await Deno.resolveDns("example.com", "A"); * * const aaaa = await Deno.resolveDns("example.com", "AAAA", { * nameServer: { ipAddr: "8.8.8.8", port: 53 }, * }); * ``` * * Requires `allow-net` permission. * * @tags allow-net * @category Network */ export function resolveDns( query: string, recordType: "MX", options?: ResolveDnsOptions ): Promise; /** * Performs DNS resolution against the given query, returning resolved * records. * * Fails in the cases such as: * * - the query is in invalid format. * - the options have an invalid parameter. For example `nameServer.port` is * beyond the range of 16-bit unsigned integer. * - the request timed out. * * ```ts * const a = await Deno.resolveDns("example.com", "A"); * * const aaaa = await Deno.resolveDns("example.com", "AAAA", { * nameServer: { ipAddr: "8.8.8.8", port: 53 }, * }); * ``` * * Requires `allow-net` permission. * * @tags allow-net * @category Network */ export function resolveDns( query: string, recordType: "NAPTR", options?: ResolveDnsOptions ): Promise; /** * Performs DNS resolution against the given query, returning resolved * records. * * Fails in the cases such as: * * - the query is in invalid format. * - the options have an invalid parameter. For example `nameServer.port` is * beyond the range of 16-bit unsigned integer. * - the request timed out. * * ```ts * const a = await Deno.resolveDns("example.com", "A"); * * const aaaa = await Deno.resolveDns("example.com", "AAAA", { * nameServer: { ipAddr: "8.8.8.8", port: 53 }, * }); * ``` * * Requires `allow-net` permission. * * @tags allow-net * @category Network */ export function resolveDns( query: string, recordType: "SOA", options?: ResolveDnsOptions ): Promise; /** * Performs DNS resolution against the given query, returning resolved * records. * * Fails in the cases such as: * * - the query is in invalid format. * - the options have an invalid parameter. For example `nameServer.port` is * beyond the range of 16-bit unsigned integer. * - the request timed out. * * ```ts * const a = await Deno.resolveDns("example.com", "A"); * * const aaaa = await Deno.resolveDns("example.com", "AAAA", { * nameServer: { ipAddr: "8.8.8.8", port: 53 }, * }); * ``` * * Requires `allow-net` permission. * * @tags allow-net * @category Network */ export function resolveDns( query: string, recordType: "SRV", options?: ResolveDnsOptions ): Promise; /** * Performs DNS resolution against the given query, returning resolved * records. * * Fails in the cases such as: * * - the query is in invalid format. * - the options have an invalid parameter. For example `nameServer.port` is * beyond the range of 16-bit unsigned integer. * - the request timed out. * * ```ts * const a = await Deno.resolveDns("example.com", "A"); * * const aaaa = await Deno.resolveDns("example.com", "AAAA", { * nameServer: { ipAddr: "8.8.8.8", port: 53 }, * }); * ``` * * Requires `allow-net` permission. * * @tags allow-net * @category Network */ export function resolveDns( query: string, recordType: "TXT", options?: ResolveDnsOptions ): Promise; /** * Performs DNS resolution against the given query, returning resolved * records. * * Fails in the cases such as: * * - the query is in invalid format. * - the options have an invalid parameter. For example `nameServer.port` is * beyond the range of 16-bit unsigned integer. * - the request timed out. * * ```ts * const a = await Deno.resolveDns("example.com", "A"); * * const aaaa = await Deno.resolveDns("example.com", "AAAA", { * nameServer: { ipAddr: "8.8.8.8", port: 53 }, * }); * ``` * * Requires `allow-net` permission. * * @tags allow-net * @category Network */ export function resolveDns( query: string, recordType: RecordType, options?: ResolveDnsOptions ): Promise< | string[] | CaaRecord[] | MxRecord[] | NaptrRecord[] | SoaRecord[] | SrvRecord[] | string[][] >; /** * Make the timer of the given `id` block the event loop from finishing. * * @category Runtime */ export function refTimer(id: number): void; /** * Make the timer of the given `id` not block the event loop from finishing. * * @category Runtime */ export function unrefTimer(id: number): void; /** * Returns the user id of the process on POSIX platforms. Returns null on Windows. * * ```ts * console.log(Deno.uid()); * ``` * * Requires `allow-sys` permission. * * @tags allow-sys * @category Runtime */ export function uid(): number | null; /** * Returns the group id of the process on POSIX platforms. Returns null on windows. * * ```ts * console.log(Deno.gid()); * ``` * * Requires `allow-sys` permission. * * @tags allow-sys * @category Runtime */ export function gid(): number | null; /** Additional information for an HTTP request and its connection. * * @category HTTP Server */ export interface ServeHandlerInfo { /** The remote address of the connection. */ remoteAddr: Addr; /** The completion promise */ completed: Promise; } /** A handler for HTTP requests. Consumes a request and returns a response. * * If a handler throws, the server calling the handler will assume the impact * of the error is isolated to the individual request. It will catch the error * and if necessary will close the underlying connection. * * @category HTTP Server */ export type ServeHandler = ( request: Request, info: ServeHandlerInfo ) => Response | Promise; /** Interface that module run with `deno serve` subcommand must conform to. * * To ensure your code is type-checked properly, make sure to add `satisfies Deno.ServeDefaultExport` * to the `export default { ... }` like so: * * ```ts * export default { * fetch(req) { * return new Response("Hello world"); * } * } satisfies Deno.ServeDefaultExport; * ``` * * @category HTTP Server */ export interface ServeDefaultExport { /** A handler for HTTP requests. Consumes a request and returns a response. * * If a handler throws, the server calling the handler will assume the impact * of the error is isolated to the individual request. It will catch the error * and if necessary will close the underlying connection. * * @category HTTP Server */ fetch: ServeHandler; } /** Options which can be set when calling {@linkcode Deno.serve}. * * @category HTTP Server */ export interface ServeOptions { /** An {@linkcode AbortSignal} to close the server and all connections. */ signal?: AbortSignal; /** The handler to invoke when route handlers throw an error. */ onError?: (error: unknown) => Response | Promise; /** The callback which is called when the server starts listening. */ onListen?: (localAddr: Addr) => void; } /** * Options that can be passed to `Deno.serve` to create a server listening on * a TCP port. * * @category HTTP Server */ export interface ServeTcpOptions extends ServeOptions { /** The transport to use. */ transport?: "tcp"; /** The port to listen on. * * Set to `0` to listen on any available port. * * @default {8000} */ port?: number; /** A literal IP address or host name that can be resolved to an IP address. * * __Note about `0.0.0.0`__ While listening `0.0.0.0` works on all platforms, * the browsers on Windows don't work with the address `0.0.0.0`. * You should show the message like `server running on localhost:8080` instead of * `server running on 0.0.0.0:8080` if your program supports Windows. * * @default {"0.0.0.0"} */ hostname?: string; /** Sets `SO_REUSEPORT` on POSIX systems. */ reusePort?: boolean; } /** * Options that can be passed to `Deno.serve` to create a server listening on * a Unix domain socket. * * @category HTTP Server */ export interface ServeUnixOptions extends ServeOptions { /** The transport to use. */ transport?: "unix"; /** The unix domain socket path to listen on. */ path: string; } /** * @category HTTP Server */ export interface ServeInit { /** The handler to invoke to process each incoming request. */ handler: ServeHandler; } /** An instance of the server created using `Deno.serve()` API. * * @category HTTP Server */ export interface HttpServer extends AsyncDisposable { /** A promise that resolves once server finishes - eg. when aborted using * the signal passed to {@linkcode ServeOptions.signal}. */ finished: Promise; /** The local address this server is listening on. */ addr: Addr; /** * Make the server block the event loop from finishing. * * Note: the server blocks the event loop from finishing by default. * This method is only meaningful after `.unref()` is called. */ ref(): void; /** Make the server not block the event loop from finishing. */ unref(): void; /** Gracefully close the server. No more new connections will be accepted, * while pending requests will be allowed to finish. */ shutdown(): Promise; } /** Serves HTTP requests with the given handler. * * The below example serves with the port `8000` on hostname `"127.0.0.1"`. * * ```ts * Deno.serve((_req) => new Response("Hello, world")); * ``` * * @category HTTP Server */ export function serve( handler: ServeHandler ): HttpServer; /** Serves HTTP requests with the given option bag and handler. * * You can specify the socket path with `path` option. * * ```ts * Deno.serve( * { path: "path/to/socket" }, * (_req) => new Response("Hello, world") * ); * ``` * * You can stop the server with an {@linkcode AbortSignal}. The abort signal * needs to be passed as the `signal` option in the options bag. The server * aborts when the abort signal is aborted. To wait for the server to close, * await the promise returned from the `Deno.serve` API. * * ```ts * const ac = new AbortController(); * * const server = Deno.serve( * { signal: ac.signal, path: "path/to/socket" }, * (_req) => new Response("Hello, world") * ); * server.finished.then(() => console.log("Server closed")); * * console.log("Closing server..."); * ac.abort(); * ``` * * By default `Deno.serve` prints the message * `Listening on path/to/socket` on listening. If you like to * change this behavior, you can specify a custom `onListen` callback. * * ```ts * Deno.serve({ * onListen({ path }) { * console.log(`Server started at ${path}`); * // ... more info specific to your server .. * }, * path: "path/to/socket", * }, (_req) => new Response("Hello, world")); * ``` * * @category HTTP Server */ export function serve( options: ServeUnixOptions, handler: ServeHandler ): HttpServer; /** Serves HTTP requests with the given option bag and handler. * * You can specify an object with a port and hostname option, which is the * address to listen on. The default is port `8000` on hostname `"0.0.0.0"`. * * You can change the address to listen on using the `hostname` and `port` * options. The below example serves on port `3000` and hostname `"127.0.0.1"`. * * ```ts * Deno.serve( * { port: 3000, hostname: "127.0.0.1" }, * (_req) => new Response("Hello, world") * ); * ``` * * You can stop the server with an {@linkcode AbortSignal}. The abort signal * needs to be passed as the `signal` option in the options bag. The server * aborts when the abort signal is aborted. To wait for the server to close, * await the promise returned from the `Deno.serve` API. * * ```ts * const ac = new AbortController(); * * const server = Deno.serve( * { signal: ac.signal }, * (_req) => new Response("Hello, world") * ); * server.finished.then(() => console.log("Server closed")); * * console.log("Closing server..."); * ac.abort(); * ``` * * By default `Deno.serve` prints the message * `Listening on http://:/` on listening. If you like to * change this behavior, you can specify a custom `onListen` callback. * * ```ts * Deno.serve({ * onListen({ port, hostname }) { * console.log(`Server started at http://${hostname}:${port}`); * // ... more info specific to your server .. * }, * }, (_req) => new Response("Hello, world")); * ``` * * To enable TLS you must specify the `key` and `cert` options. * * ```ts * const cert = "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"; * const key = "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"; * Deno.serve({ cert, key }, (_req) => new Response("Hello, world")); * ``` * * @category HTTP Server */ export function serve( options: ServeTcpOptions | (ServeTcpOptions & TlsCertifiedKeyPem), handler: ServeHandler ): HttpServer; /** Serves HTTP requests with the given option bag. * * You can specify an object with the path option, which is the * unix domain socket to listen on. * * ```ts * const ac = new AbortController(); * * const server = Deno.serve({ * path: "path/to/socket", * handler: (_req) => new Response("Hello, world"), * signal: ac.signal, * onListen({ path }) { * console.log(`Server started at ${path}`); * }, * }); * server.finished.then(() => console.log("Server closed")); * * console.log("Closing server..."); * ac.abort(); * ``` * * @category HTTP Server */ export function serve( options: ServeUnixOptions & ServeInit ): HttpServer; /** Serves HTTP requests with the given option bag. * * You can specify an object with a port and hostname option, which is the * address to listen on. The default is port `8000` on hostname `"0.0.0.0"`. * * ```ts * const ac = new AbortController(); * * const server = Deno.serve({ * port: 3000, * hostname: "127.0.0.1", * handler: (_req) => new Response("Hello, world"), * signal: ac.signal, * onListen({ port, hostname }) { * console.log(`Server started at http://${hostname}:${port}`); * }, * }); * server.finished.then(() => console.log("Server closed")); * * console.log("Closing server..."); * ac.abort(); * ``` * * @category HTTP Server */ export function serve( options: (ServeTcpOptions | (ServeTcpOptions & TlsCertifiedKeyPem)) & ServeInit ): HttpServer; /** All plain number types for interfacing with foreign functions. * * @category FFI */ export type NativeNumberType = | "u8" | "i8" | "u16" | "i16" | "u32" | "i32" | "f32" | "f64"; /** All BigInt number types for interfacing with foreign functions. * * @category FFI */ export type NativeBigIntType = "u64" | "i64" | "usize" | "isize"; /** The native boolean type for interfacing to foreign functions. * * @category FFI */ export type NativeBooleanType = "bool"; /** The native pointer type for interfacing to foreign functions. * * @category FFI */ export type NativePointerType = "pointer"; /** The native buffer type for interfacing to foreign functions. * * @category FFI */ export type NativeBufferType = "buffer"; /** The native function type for interfacing with foreign functions. * * @category FFI */ export type NativeFunctionType = "function"; /** The native void type for interfacing with foreign functions. * * @category FFI */ export type NativeVoidType = "void"; /** The native struct type for interfacing with foreign functions. * * @category FFI */ export interface NativeStructType { readonly struct: readonly NativeType[]; } /** * @category FFI */ export const brand: unique symbol; /** * @category FFI */ export type NativeU8Enum = "u8" & { [brand]: T }; /** * @category FFI */ export type NativeI8Enum = "i8" & { [brand]: T }; /** * @category FFI */ export type NativeU16Enum = "u16" & { [brand]: T }; /** * @category FFI */ export type NativeI16Enum = "i16" & { [brand]: T }; /** * @category FFI */ export type NativeU32Enum = "u32" & { [brand]: T }; /** * @category FFI */ export type NativeI32Enum = "i32" & { [brand]: T }; /** * @category FFI */ export type NativeTypedPointer = "pointer" & { [brand]: T; }; /** * @category FFI */ export type NativeTypedFunction = "function" & { [brand]: T; }; /** All supported types for interfacing with foreign functions. * * @category FFI */ export type NativeType = | NativeNumberType | NativeBigIntType | NativeBooleanType | NativePointerType | NativeBufferType | NativeFunctionType | NativeStructType; /** @category FFI */ export type NativeResultType = NativeType | NativeVoidType; /** Type conversion for foreign symbol parameters and unsafe callback return * types. * * @category FFI */ export type ToNativeType = T extends NativeStructType ? BufferSource : T extends NativeNumberType ? T extends NativeU8Enum ? U : T extends NativeI8Enum ? U : T extends NativeU16Enum ? U : T extends NativeI16Enum ? U : T extends NativeU32Enum ? U : T extends NativeI32Enum ? U : number : T extends NativeBigIntType ? bigint : T extends NativeBooleanType ? boolean : T extends NativePointerType ? T extends NativeTypedPointer ? U | null : PointerValue : T extends NativeFunctionType ? T extends NativeTypedFunction ? PointerValue | null : PointerValue : T extends NativeBufferType ? BufferSource | null : never; /** Type conversion for unsafe callback return types. * * @category FFI */ export type ToNativeResultType< T extends NativeResultType = NativeResultType, > = T extends NativeStructType ? BufferSource : T extends NativeNumberType ? T extends NativeU8Enum ? U : T extends NativeI8Enum ? U : T extends NativeU16Enum ? U : T extends NativeI16Enum ? U : T extends NativeU32Enum ? U : T extends NativeI32Enum ? U : number : T extends NativeBigIntType ? bigint : T extends NativeBooleanType ? boolean : T extends NativePointerType ? T extends NativeTypedPointer ? U | null : PointerValue : T extends NativeFunctionType ? T extends NativeTypedFunction ? PointerObject | null : PointerValue : T extends NativeBufferType ? BufferSource | null : T extends NativeVoidType ? void : never; /** A utility type for conversion of parameter types of foreign functions. * * @category FFI */ export type ToNativeParameterTypes = // [T[number][]] extends [T] ? ToNativeType[] : [readonly T[number][]] extends [T] ? readonly ToNativeType[] : T extends readonly [...NativeType[]] ? { [K in keyof T]: ToNativeType; } : never; /** Type conversion for foreign symbol return types and unsafe callback * parameters. * * @category FFI */ export type FromNativeType = T extends NativeStructType ? Uint8Array : T extends NativeNumberType ? T extends NativeU8Enum ? U : T extends NativeI8Enum ? U : T extends NativeU16Enum ? U : T extends NativeI16Enum ? U : T extends NativeU32Enum ? U : T extends NativeI32Enum ? U : number : T extends NativeBigIntType ? bigint : T extends NativeBooleanType ? boolean : T extends NativePointerType ? T extends NativeTypedPointer ? U | null : PointerValue : T extends NativeBufferType ? PointerValue : T extends NativeFunctionType ? T extends NativeTypedFunction ? PointerObject | null : PointerValue : never; /** Type conversion for foreign symbol return types. * * @category FFI */ export type FromNativeResultType< T extends NativeResultType = NativeResultType, > = T extends NativeStructType ? Uint8Array : T extends NativeNumberType ? T extends NativeU8Enum ? U : T extends NativeI8Enum ? U : T extends NativeU16Enum ? U : T extends NativeI16Enum ? U : T extends NativeU32Enum ? U : T extends NativeI32Enum ? U : number : T extends NativeBigIntType ? bigint : T extends NativeBooleanType ? boolean : T extends NativePointerType ? T extends NativeTypedPointer ? U | null : PointerValue : T extends NativeBufferType ? PointerValue : T extends NativeFunctionType ? T extends NativeTypedFunction ? PointerObject | null : PointerValue : T extends NativeVoidType ? void : never; /** @category FFI */ export type FromNativeParameterTypes = // [T[number][]] extends [T] ? FromNativeType[] : [readonly T[number][]] extends [T] ? readonly FromNativeType[] : T extends readonly [...NativeType[]] ? { [K in keyof T]: FromNativeType; } : never; /** The interface for a foreign function as defined by its parameter and result * types. * * @category FFI */ export interface ForeignFunction< Parameters extends readonly NativeType[] = readonly NativeType[], Result extends NativeResultType = NativeResultType, NonBlocking extends boolean = boolean, > { /** Name of the symbol. * * Defaults to the key name in symbols object. */ name?: string; /** The parameters of the foreign function. */ parameters: Parameters; /** The result (return value) of the foreign function. */ result: Result; /** When `true`, function calls will run on a dedicated blocking thread and * will return a `Promise` resolving to the `result`. */ nonblocking?: NonBlocking; /** When `true`, dlopen will not fail if the symbol is not found. * Instead, the symbol will be set to `null`. * * @default {false} */ optional?: boolean; } /** @category FFI */ export interface ForeignStatic { /** Name of the symbol, defaults to the key name in symbols object. */ name?: string; /** The type of the foreign static value. */ type: Type; /** When `true`, dlopen will not fail if the symbol is not found. * Instead, the symbol will be set to `null`. * * @default {false} */ optional?: boolean; } /** A foreign library interface descriptor. * * @category FFI */ export interface ForeignLibraryInterface { [name: string]: ForeignFunction | ForeignStatic; } /** A utility type that infers a foreign symbol. * * @category FFI */ export type StaticForeignSymbol = T extends ForeignFunction ? FromForeignFunction : T extends ForeignStatic ? FromNativeType : never; /** @category FFI */ export type FromForeignFunction = T["parameters"] extends readonly [] ? () => StaticForeignSymbolReturnType : ( ...args: ToNativeParameterTypes ) => StaticForeignSymbolReturnType; /** @category FFI */ export type StaticForeignSymbolReturnType = ConditionalAsync>; /** @category FFI */ export type ConditionalAsync< IsAsync extends boolean | undefined, T, > = IsAsync extends true ? Promise : T; /** A utility type that infers a foreign library interface. * * @category FFI */ export type StaticForeignLibraryInterface = { [K in keyof T]: T[K]["optional"] extends true ? StaticForeignSymbol | null : StaticForeignSymbol; }; /** A non-null pointer, represented as an object * at runtime. The object's prototype is `null` * and cannot be changed. The object cannot be * assigned to either and is thus entirely read-only. * * To interact with memory through a pointer use the * {@linkcode UnsafePointerView} class. To create a * pointer from an address or the get the address of * a pointer use the static methods of the * {@linkcode UnsafePointer} class. * * @category FFI */ export interface PointerObject { [brand]: T; } /** Pointers are represented either with a {@linkcode PointerObject} * object or a `null` if the pointer is null. * * @category FFI */ export type PointerValue = null | PointerObject; /** A collection of static functions for interacting with pointer objects. * * @category FFI */ export class UnsafePointer { /** Create a pointer from a numeric value. This one is really dangerous! */ static create(value: bigint): PointerValue; /** Returns `true` if the two pointers point to the same address. */ static equals(a: PointerValue, b: PointerValue): boolean; /** Return the direct memory pointer to the typed array in memory. */ static of( value: Deno.UnsafeCallback | BufferSource ): PointerValue; /** Return a new pointer offset from the original by `offset` bytes. */ static offset( value: PointerObject, offset: number ): PointerValue; /** Get the numeric value of a pointer */ static value(value: PointerValue): bigint; } /** An unsafe pointer view to a memory location as specified by the `pointer` * value. The `UnsafePointerView` API follows the standard built in interface * {@linkcode DataView} for accessing the underlying types at an memory * location (numbers, strings and raw bytes). * * @category FFI */ export class UnsafePointerView { constructor(pointer: PointerObject); pointer: PointerObject; /** Gets a boolean at the specified byte offset from the pointer. */ getBool(offset?: number): boolean; /** Gets an unsigned 8-bit integer at the specified byte offset from the * pointer. */ getUint8(offset?: number): number; /** Gets a signed 8-bit integer at the specified byte offset from the * pointer. */ getInt8(offset?: number): number; /** Gets an unsigned 16-bit integer at the specified byte offset from the * pointer. */ getUint16(offset?: number): number; /** Gets a signed 16-bit integer at the specified byte offset from the * pointer. */ getInt16(offset?: number): number; /** Gets an unsigned 32-bit integer at the specified byte offset from the * pointer. */ getUint32(offset?: number): number; /** Gets a signed 32-bit integer at the specified byte offset from the * pointer. */ getInt32(offset?: number): number; /** Gets an unsigned 64-bit integer at the specified byte offset from the * pointer. */ getBigUint64(offset?: number): bigint; /** Gets a signed 64-bit integer at the specified byte offset from the * pointer. */ getBigInt64(offset?: number): bigint; /** Gets a signed 32-bit float at the specified byte offset from the * pointer. */ getFloat32(offset?: number): number; /** Gets a signed 64-bit float at the specified byte offset from the * pointer. */ getFloat64(offset?: number): number; /** Gets a pointer at the specified byte offset from the pointer */ getPointer(offset?: number): PointerValue; /** Gets a C string (`null` terminated string) at the specified byte offset * from the pointer. */ getCString(offset?: number): string; /** Gets a C string (`null` terminated string) at the specified byte offset * from the specified pointer. */ static getCString(pointer: PointerObject, offset?: number): string; /** Gets an `ArrayBuffer` of length `byteLength` at the specified byte * offset from the pointer. */ getArrayBuffer(byteLength: number, offset?: number): ArrayBuffer; /** Gets an `ArrayBuffer` of length `byteLength` at the specified byte * offset from the specified pointer. */ static getArrayBuffer( pointer: PointerObject, byteLength: number, offset?: number ): ArrayBuffer; /** Copies the memory of the pointer into a typed array. * * Length is determined from the typed array's `byteLength`. * * Also takes optional byte offset from the pointer. */ copyInto(destination: BufferSource, offset?: number): void; /** Copies the memory of the specified pointer into a typed array. * * Length is determined from the typed array's `byteLength`. * * Also takes optional byte offset from the pointer. */ static copyInto( pointer: PointerObject, destination: BufferSource, offset?: number ): void; } /** An unsafe pointer to a function, for calling functions that are not present * as symbols. * * @category FFI */ export class UnsafeFnPointer { /** The pointer to the function. */ pointer: PointerObject; /** The definition of the function. */ definition: Fn; constructor( pointer: PointerObject>>, definition: Fn ); /** Call the foreign function. */ call: FromForeignFunction; } /** Definition of a unsafe callback function. * * @category FFI */ export interface UnsafeCallbackDefinition< Parameters extends readonly NativeType[] = readonly NativeType[], Result extends NativeResultType = NativeResultType, > { /** The parameters of the callbacks. */ parameters: Parameters; /** The current result of the callback. */ result: Result; } /** An unsafe callback function. * * @category FFI */ export type UnsafeCallbackFunction< Parameters extends readonly NativeType[] = readonly NativeType[], Result extends NativeResultType = NativeResultType, > = Parameters extends readonly [] ? () => ToNativeResultType : ( ...args: FromNativeParameterTypes ) => ToNativeResultType; /** An unsafe function pointer for passing JavaScript functions as C function * pointers to foreign function calls. * * The function pointer remains valid until the `close()` method is called. * * All `UnsafeCallback` are always thread safe in that they can be called from * foreign threads without crashing. However, they do not wake up the Deno event * loop by default. * * If a callback is to be called from foreign threads, use the `threadSafe()` * static constructor or explicitly call `ref()` to have the callback wake up * the Deno event loop when called from foreign threads. This also stops * Deno's process from exiting while the callback still exists and is not * unref'ed. * * Use `deref()` to then allow Deno's process to exit. Calling `deref()` on * a ref'ed callback does not stop it from waking up the Deno event loop when * called from foreign threads. * * @category FFI */ export class UnsafeCallback< const Definition extends UnsafeCallbackDefinition = UnsafeCallbackDefinition, > { constructor( definition: Definition, callback: UnsafeCallbackFunction< Definition["parameters"], Definition["result"] > ); /** The pointer to the unsafe callback. */ readonly pointer: PointerObject; /** The definition of the unsafe callback. */ readonly definition: Definition; /** The callback function. */ readonly callback: UnsafeCallbackFunction< Definition["parameters"], Definition["result"] >; /** * Creates an {@linkcode UnsafeCallback} and calls `ref()` once to allow it to * wake up the Deno event loop when called from foreign threads. * * This also stops Deno's process from exiting while the callback still * exists and is not unref'ed. */ static threadSafe< Definition extends UnsafeCallbackDefinition = UnsafeCallbackDefinition, >( definition: Definition, callback: UnsafeCallbackFunction< Definition["parameters"], Definition["result"] > ): UnsafeCallback; /** * Increments the callback's reference counting and returns the new * reference count. * * After `ref()` has been called, the callback always wakes up the * Deno event loop when called from foreign threads. * * If the callback's reference count is non-zero, it keeps Deno's * process from exiting. */ ref(): number; /** * Decrements the callback's reference counting and returns the new * reference count. * * Calling `unref()` does not stop a callback from waking up the Deno * event loop when called from foreign threads. * * If the callback's reference counter is zero, it no longer keeps * Deno's process from exiting. */ unref(): number; /** * Removes the C function pointer associated with this instance. * * Continuing to use the instance or the C function pointer after closing * the `UnsafeCallback` will lead to errors and crashes. * * Calling this method sets the callback's reference counting to zero, * stops the callback from waking up the Deno event loop when called from * foreign threads and no longer keeps Deno's process from exiting. */ close(): void; } /** A dynamic library resource. Use {@linkcode Deno.dlopen} to load a dynamic * library and return this interface. * * @category FFI */ export interface DynamicLibrary { /** All of the registered library along with functions for calling them. */ symbols: StaticForeignLibraryInterface; /** Removes the pointers associated with the library symbols. * * Continuing to use symbols that are part of the library will lead to * errors and crashes. * * Calling this method will also immediately set any references to zero and * will no longer keep Deno's process from exiting. */ close(): void; } /** Opens an external dynamic library and registers symbols, making foreign * functions available to be called. * * Requires `allow-ffi` permission. Loading foreign dynamic libraries can in * theory bypass all of the sandbox permissions. While it is a separate * permission users should acknowledge in practice that is effectively the * same as running with the `allow-all` permission. * * @example Given a C library which exports a foreign function named `add()` * * ```ts * // Determine library extension based on * // your OS. * let libSuffix = ""; * switch (Deno.build.os) { * case "windows": * libSuffix = "dll"; * break; * case "darwin": * libSuffix = "dylib"; * break; * default: * libSuffix = "so"; * break; * } * * const libName = `./libadd.${libSuffix}`; * // Open library and define exported symbols * const dylib = Deno.dlopen( * libName, * { * "add": { parameters: ["isize", "isize"], result: "isize" }, * } as const, * ); * * // Call the symbol `add` * const result = dylib.symbols.add(35n, 34n); // 69n * * console.log(`Result from external addition of 35 and 34: ${result}`); * ``` * * @tags allow-ffi * @category FFI */ export function dlopen( filename: string | URL, symbols: S ): DynamicLibrary; /** * A custom `HttpClient` for use with {@linkcode fetch} function. This is * designed to allow custom certificates or proxies to be used with `fetch()`. * * @example ```ts * const caCert = await Deno.readTextFile("./ca.pem"); * const client = Deno.createHttpClient({ caCerts: [ caCert ] }); * const req = await fetch("https://myserver.com", { client }); * ``` * * @category Fetch */ export class HttpClient implements Disposable { /** Close the HTTP client. */ close(): void; [Symbol.dispose](): void; } /** * The options used when creating a {@linkcode Deno.HttpClient}. * * @category Fetch */ export interface CreateHttpClientOptions { /** A list of root certificates that will be used in addition to the * default root certificates to verify the peer's certificate. * * Must be in PEM format. */ caCerts?: string[]; /** A HTTP proxy to use for new connections. */ proxy?: Proxy; /** Sets the maximum number of idle connections per host allowed in the pool. */ poolMaxIdlePerHost?: number; /** Set an optional timeout for idle sockets being kept-alive. * Set to false to disable the timeout. */ poolIdleTimeout?: number | false; /** * Whether HTTP/1.1 is allowed or not. * * @default {true} */ http1?: boolean; /** Whether HTTP/2 is allowed or not. * * @default {true} */ http2?: boolean; /** Whether setting the host header is allowed or not. * * @default {false} */ allowHost?: boolean; } /** * The definition of a proxy when specifying * {@linkcode Deno.CreateHttpClientOptions}. * * @category Fetch */ export interface Proxy { /** The string URL of the proxy server to use. */ url: string; /** The basic auth credentials to be used against the proxy server. */ basicAuth?: BasicAuth; } /** * Basic authentication credentials to be used with a {@linkcode Deno.Proxy} * server when specifying {@linkcode Deno.CreateHttpClientOptions}. * * @category Fetch */ export interface BasicAuth { /** The username to be used against the proxy server. */ username: string; /** The password to be used against the proxy server. */ password: string; } /** Create a custom HttpClient to use with {@linkcode fetch}. This is an * extension of the web platform Fetch API which allows Deno to use custom * TLS CA certificates and connect via a proxy while using `fetch()`. * * The `cert` and `key` options can be used to specify a client certificate * and key to use when connecting to a server that requires client * authentication (mutual TLS or mTLS). The `cert` and `key` options must be * provided in PEM format. * * @example ```ts * const caCert = await Deno.readTextFile("./ca.pem"); * const client = Deno.createHttpClient({ caCerts: [ caCert ] }); * const response = await fetch("https://myserver.com", { client }); * ``` * * @example ```ts * const client = Deno.createHttpClient({ * proxy: { url: "http://myproxy.com:8080" } * }); * const response = await fetch("https://myserver.com", { client }); * ``` * * @example ```ts * const key = "----BEGIN PRIVATE KEY----..."; * const cert = "----BEGIN CERTIFICATE----..."; * const client = Deno.createHttpClient({ key, cert }); * const response = await fetch("https://myserver.com", { client }); * ``` * * @category Fetch */ export function createHttpClient( options: | CreateHttpClientOptions | (CreateHttpClientOptions & TlsCertifiedKeyPem) ): HttpClient; /** @category Network */ export interface NetAddr { transport: "tcp" | "udp"; hostname: string; port: number; } /** @category Network */ export interface UnixAddr { transport: "unix" | "unixpacket"; path: string; } /** @category Network */ export type Addr = NetAddr | UnixAddr; /** A generic network listener for stream-oriented protocols. * * @category Network */ export interface Listener extends AsyncIterable, Disposable { /** Waits for and resolves to the next connection to the `Listener`. */ accept(): Promise; /** Close closes the listener. Any pending accept promises will be rejected * with errors. */ close(): void; /** Return the address of the `Listener`. */ readonly addr: A; [Symbol.asyncIterator](): AsyncIterableIterator; /** * Make the listener block the event loop from finishing. * * Note: the listener blocks the event loop from finishing by default. * This method is only meaningful after `.unref()` is called. */ ref(): void; /** Make the listener not block the event loop from finishing. */ unref(): void; } /** Specialized listener that accepts TLS connections. * * @category Network */ export type TlsListener = Listener; /** Specialized listener that accepts TCP connections. * * @category Network */ export type TcpListener = Listener; /** Specialized listener that accepts Unix connections. * * @category Network */ export type UnixListener = Listener; /** @category Network */ export interface Conn
extends Disposable { /** Read the incoming data from the connection into an array buffer (`p`). * * Resolves to either the number of bytes read during the operation or EOF * (`null`) if there was nothing more to read. * * It is possible for a read to successfully return with `0` bytes. This * does not indicate EOF. * * **It is not guaranteed that the full buffer will be read in a single * call.** * * ```ts * // If the text "hello world" is received by the client: * const conn = await Deno.connect({ hostname: "example.com", port: 80 }); * const buf = new Uint8Array(100); * const numberOfBytesRead = await conn.read(buf); // 11 bytes * const text = new TextDecoder().decode(buf); // "hello world" * ``` * * @category I/O */ read(p: Uint8Array): Promise; /** Write the contents of the array buffer (`p`) to the connection. * * Resolves to the number of bytes written. * * **It is not guaranteed that the full buffer will be written in a single * call.** * * ```ts * const conn = await Deno.connect({ hostname: "example.com", port: 80 }); * const encoder = new TextEncoder(); * const data = encoder.encode("Hello world"); * const bytesWritten = await conn.write(data); // 11 * ``` * * @category I/O */ write(p: Uint8Array): Promise; /** Closes the connection, freeing the resource. * * ```ts * const conn = await Deno.connect({ hostname: "example.com", port: 80 }); * * // ... * * conn.close(); * ``` */ close(): void; /** The local address of the connection. */ readonly localAddr: A; /** The remote address of the connection. */ readonly remoteAddr: A; /** Shuts down (`shutdown(2)`) the write side of the connection. Most * callers should just use `close()`. */ closeWrite(): Promise; /** Make the connection block the event loop from finishing. * * Note: the connection blocks the event loop from finishing by default. * This method is only meaningful after `.unref()` is called. */ ref(): void; /** Make the connection not block the event loop from finishing. */ unref(): void; readonly readable: ReadableStream; readonly writable: WritableStream; } /** @category Network */ export interface TlsHandshakeInfo { /** * Contains the ALPN protocol selected during negotiation with the server. * If no ALPN protocol selected, returns `null`. */ alpnProtocol: string | null; } /** @category Network */ export interface TlsConn extends Conn { /** Runs the client or server handshake protocol to completion if that has * not happened yet. Calling this method is optional; the TLS handshake * will be completed automatically as soon as data is sent or received. */ handshake(): Promise; } /** @category Network */ export interface ListenOptions { /** The port to listen on. * * Set to `0` to listen on any available port. */ port: number; /** A literal IP address or host name that can be resolved to an IP address. * * __Note about `0.0.0.0`__ While listening `0.0.0.0` works on all platforms, * the browsers on Windows don't work with the address `0.0.0.0`. * You should show the message like `server running on localhost:8080` instead of * `server running on 0.0.0.0:8080` if your program supports Windows. * * @default {"0.0.0.0"} */ hostname?: string; } /** @category Network */ export interface TcpListenOptions extends ListenOptions {} /** Listen announces on the local transport address. * * ```ts * const listener1 = Deno.listen({ port: 80 }) * const listener2 = Deno.listen({ hostname: "192.0.2.1", port: 80 }) * const listener3 = Deno.listen({ hostname: "[2001:db8::1]", port: 80 }); * const listener4 = Deno.listen({ hostname: "golang.org", port: 80, transport: "tcp" }); * ``` * * Requires `allow-net` permission. * * @tags allow-net * @category Network */ export function listen( options: TcpListenOptions & { transport?: "tcp" } ): TcpListener; /** Options which can be set when opening a Unix listener via * {@linkcode Deno.listen} or {@linkcode Deno.listenDatagram}. * * @category Network */ export interface UnixListenOptions { /** A path to the Unix Socket. */ path: string; } /** Listen announces on the local transport address. * * ```ts * const listener = Deno.listen({ path: "/foo/bar.sock", transport: "unix" }) * ``` * * Requires `allow-read` and `allow-write` permission. * * @tags allow-read, allow-write * @category Network */ // deno-lint-ignore adjacent-overload-signatures export function listen( options: UnixListenOptions & { transport: "unix" } ): UnixListener; /** * Provides certified key material from strings. The key material is provided in * `PEM`-format (Privacy Enhanced Mail, https://www.rfc-editor.org/rfc/rfc1422) which can be identified by having * `-----BEGIN-----` and `-----END-----` markers at the beginning and end of the strings. This type of key is not compatible * with `DER`-format keys which are binary. * * Deno supports RSA, EC, and PKCS8-format keys. * * ```ts * const key = { * key: "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n", * cert: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n" } * }; * ``` * * @category Network */ export interface TlsCertifiedKeyPem { /** The format of this key material, which must be PEM. */ keyFormat?: "pem"; /** Private key in `PEM` format. RSA, EC, and PKCS8-format keys are supported. */ key: string; /** Certificate chain in `PEM` format. */ cert: string; } /** @category Network */ export interface ListenTlsOptions extends TcpListenOptions { transport?: "tcp"; /** Application-Layer Protocol Negotiation (ALPN) protocols to announce to * the client. If not specified, no ALPN extension will be included in the * TLS handshake. */ alpnProtocols?: string[]; } /** Listen announces on the local transport address over TLS (transport layer * security). * * ```ts * using listener = Deno.listenTls({ * port: 443, * cert: Deno.readTextFileSync("./server.crt"), * key: Deno.readTextFileSync("./server.key"), * }); * ``` * * Requires `allow-net` permission. * * @tags allow-net * @category Network */ export function listenTls( options: ListenTlsOptions & TlsCertifiedKeyPem ): TlsListener; /** @category Network */ export interface ConnectOptions { /** The port to connect to. */ port: number; /** A literal IP address or host name that can be resolved to an IP address. * If not specified, * * @default {"127.0.0.1"} */ hostname?: string; transport?: "tcp"; } /** * Connects to the hostname (default is "127.0.0.1") and port on the named * transport (default is "tcp"), and resolves to the connection (`Conn`). * * ```ts * const conn1 = await Deno.connect({ port: 80 }); * const conn2 = await Deno.connect({ hostname: "192.0.2.1", port: 80 }); * const conn3 = await Deno.connect({ hostname: "[2001:db8::1]", port: 80 }); * const conn4 = await Deno.connect({ hostname: "golang.org", port: 80, transport: "tcp" }); * ``` * * Requires `allow-net` permission for "tcp". * * @tags allow-net * @category Network */ export function connect(options: ConnectOptions): Promise; /** @category Network */ export interface TcpConn extends Conn { /** * Enable/disable the use of Nagle's algorithm. * * @param [noDelay=true] */ setNoDelay(noDelay?: boolean): void; /** Enable/disable keep-alive functionality. */ setKeepAlive(keepAlive?: boolean): void; } /** @category Network */ export interface UnixConnectOptions { transport: "unix"; path: string; } /** @category Network */ export interface UnixConn extends Conn {} /** Connects to the hostname (default is "127.0.0.1") and port on the named * transport (default is "tcp"), and resolves to the connection (`Conn`). * * ```ts * const conn1 = await Deno.connect({ port: 80 }); * const conn2 = await Deno.connect({ hostname: "192.0.2.1", port: 80 }); * const conn3 = await Deno.connect({ hostname: "[2001:db8::1]", port: 80 }); * const conn4 = await Deno.connect({ hostname: "golang.org", port: 80, transport: "tcp" }); * const conn5 = await Deno.connect({ path: "/foo/bar.sock", transport: "unix" }); * ``` * * Requires `allow-net` permission for "tcp" and `allow-read` for "unix". * * @tags allow-net, allow-read * @category Network */ // deno-lint-ignore adjacent-overload-signatures export function connect(options: UnixConnectOptions): Promise; /** @category Network */ export interface ConnectTlsOptions { /** The port to connect to. */ port: number; /** A literal IP address or host name that can be resolved to an IP address. * * @default {"127.0.0.1"} */ hostname?: string; /** A list of root certificates that will be used in addition to the * default root certificates to verify the peer's certificate. * * Must be in PEM format. */ caCerts?: string[]; /** Application-Layer Protocol Negotiation (ALPN) protocols supported by * the client. If not specified, no ALPN extension will be included in the * TLS handshake. */ alpnProtocols?: string[]; } /** Establishes a secure connection over TLS (transport layer security) using * an optional list of CA certs, hostname (default is "127.0.0.1") and port. * * The CA cert list is optional and if not included Mozilla's root * certificates will be used (see also https://github.com/ctz/webpki-roots for * specifics). * * Mutual TLS (mTLS or client certificates) are supported by providing a * `key` and `cert` in the options as PEM-encoded strings. * * ```ts * const caCert = await Deno.readTextFile("./certs/my_custom_root_CA.pem"); * const conn1 = await Deno.connectTls({ port: 80 }); * const conn2 = await Deno.connectTls({ caCerts: [caCert], hostname: "192.0.2.1", port: 80 }); * const conn3 = await Deno.connectTls({ hostname: "[2001:db8::1]", port: 80 }); * const conn4 = await Deno.connectTls({ caCerts: [caCert], hostname: "golang.org", port: 80}); * * const key = "----BEGIN PRIVATE KEY----..."; * const cert = "----BEGIN CERTIFICATE----..."; * const conn5 = await Deno.connectTls({ port: 80, key, cert }); * ``` * * Requires `allow-net` permission. * * @tags allow-net * @category Network */ export function connectTls( options: ConnectTlsOptions | (ConnectTlsOptions & TlsCertifiedKeyPem) ): Promise; /** @category Network */ export interface StartTlsOptions { /** A literal IP address or host name that can be resolved to an IP address. * * @default {"127.0.0.1"} */ hostname?: string; /** A list of root certificates that will be used in addition to the * default root certificates to verify the peer's certificate. * * Must be in PEM format. */ caCerts?: string[]; /** Application-Layer Protocol Negotiation (ALPN) protocols to announce to * the client. If not specified, no ALPN extension will be included in the * TLS handshake. */ alpnProtocols?: string[]; } /** Start TLS handshake from an existing connection using an optional list of * CA certificates, and hostname (default is "127.0.0.1"). Specifying CA certs * is optional. By default the configured root certificates are used. Using * this function requires that the other end of the connection is prepared for * a TLS handshake. * * Note that this function *consumes* the TCP connection passed to it, thus the * original TCP connection will be unusable after calling this. Additionally, * you need to ensure that the TCP connection is not being used elsewhere when * calling this function in order for the TCP connection to be consumed properly. * For instance, if there is a `Promise` that is waiting for read operation on * the TCP connection to complete, it is considered that the TCP connection is * being used elsewhere. In such a case, this function will fail. * * ```ts * const conn = await Deno.connect({ port: 80, hostname: "127.0.0.1" }); * const caCert = await Deno.readTextFile("./certs/my_custom_root_CA.pem"); * // `conn` becomes unusable after calling `Deno.startTls` * const tlsConn = await Deno.startTls(conn, { caCerts: [caCert], hostname: "localhost" }); * ``` * * Requires `allow-net` permission. * * @tags allow-net * @category Network */ export function startTls( conn: TcpConn, options?: StartTlsOptions ): Promise; export {}; // only export exports } ================================================ FILE: frontend/public/index.d.ts ================================================ import { KomodoClient as Client, Types as KomodoTypes } from "./client/lib.js"; import "./deno.d.ts"; declare global { // ================= // 🔴 Docker Compose // ================= /** * Docker Compose configuration interface */ export interface DockerCompose { /** Version of the Compose file format */ version?: string; /** Defines services within the Docker Compose file */ services: Record; /** Defines volumes in the Docker Compose file */ volumes?: Record; /** Defines networks in the Docker Compose file */ networks?: Record; } /** * Describes a service within Docker Compose */ export interface DockerComposeService { /** Docker image to use */ image?: string; /** Build configuration for the service */ build?: DockerComposeServiceBuild; /** Ports to map, supporting single strings or mappings */ ports?: (string | DockerComposeServicePortMapping)[]; /** Environment variables to set within the container */ environment?: Record; /** Volumes to mount */ volumes?: (string | DockerComposeServiceVolumeMount)[]; /** Networks to attach the service to */ networks?: string[]; /** Dependencies of the service */ depends_on?: string[]; /** Command to override the default CMD */ command?: string | string[]; /** Entrypoint to override the default ENTRYPOINT */ entrypoint?: string | string[]; /** Container name */ container_name?: string; /** Healthcheck configuration for the service */ healthcheck?: DockerComposeServiceHealthcheck; /** Logging options for the service */ logging?: DockerComposeServiceLogging; /** Deployment settings for the service */ deploy?: DockerComposeServiceDeploy; /** Restart policy */ restart?: string; /** Security options */ security_opt?: string[]; /** Ulimits configuration */ ulimits?: Record; /** Secrets to be used by the service */ secrets?: string[]; /** Configuration items */ configs?: string[]; /** Labels to apply to the service */ labels?: Record; /** Number of CPU units assigned */ cpus?: string | number; /** Memory limit */ mem_limit?: string; /** CPU shares for container allocation */ cpu_shares?: number; /** Extra hosts for the service */ extra_hosts?: string[]; [key: string]: unknown; } /** * Configuration for Docker build */ export interface DockerComposeServiceBuild { /** Build context path */ context: string; /** Dockerfile path within the context */ dockerfile?: string; /** Build arguments to pass */ args?: Record; /** Sources for cache imports */ cache_from?: string[]; /** Labels for the build */ labels?: Record; /** Network mode for build process */ network?: string; /** Target build stage */ target?: string; /** Shared memory size */ shm_size?: string; /** Secrets for the build process */ secrets?: string[]; /** Extra hosts for build process */ extra_hosts?: string[]; } /** * Port mapping configuration */ export interface DockerComposeServicePortMapping { /** Target port inside the container */ target: number; /** Published port on the host */ published?: number; /** Protocol used for the port (tcp/udp) */ protocol?: "tcp" | "udp"; /** Mode for port publishing */ mode?: "host" | "ingress"; } /** * Volume mount configuration */ export interface DockerComposeServiceVolumeMount { /** Type of volume mount */ type: "volume" | "bind" | "tmpfs"; /** Source path or name */ source: string; /** Target path within the container */ target: string; /** Whether the volume is read-only */ read_only?: boolean; } /** * Healthcheck configuration for a service */ export interface DockerComposeServiceHealthcheck { /** Command to check health */ test: string | string[]; /** Interval between checks */ interval?: string; /** Timeout for each check */ timeout?: string; /** Maximum number of retries */ retries?: number; /** Initial delay before checks start */ start_period?: string; } /** * Logging configuration for a service */ export interface DockerComposeServiceLogging { /** Logging driver */ driver: string; /** Options for the logging driver */ options?: Record; } /** * Deployment configuration for a service */ export interface DockerComposeServiceDeploy { /** Number of replicas */ replicas?: number; /** Update configuration */ update_config?: DockerComposeServiceDeploy; /** Restart policy */ restart_policy?: DockerComposeServiceDeployRestartPolicy; } /** * Update configuration during deployment */ export interface DockerComposeServiceDeployUpdateConfig { /** Number of containers updated in parallel */ parallelism?: number; /** Delay between updates */ delay?: string; /** Action on failure */ failure_action?: string; /** Order of updates */ order?: string; } /** * Restart policy configuration */ export interface DockerComposeServiceDeployRestartPolicy { /** Condition for restart */ condition: "none" | "on-failure" | "any"; /** Delay before restarting */ delay?: string; /** Maximum number of restart attempts */ max_attempts?: number; /** Time window for restart attempts */ window?: string; } /** * Ulimit configuration */ export interface DockerComposeServiceUlimit { /** Soft limit */ soft: number; /** Hard limit */ hard: number; } /** * Volume configuration in Docker Compose */ export interface DockerComposeVolume { /** Volume driver to use */ driver?: string; /** Driver options */ driver_opts?: Record; /** External volume identifier */ external?: boolean | string; } /** * Network configuration in Docker Compose */ export interface DockerComposeNetwork { /** Network driver */ driver?: string; /** Indicates if network is external */ external?: boolean; } // ===================== // 🔴 YAML De/serializer // ===================== // https://jsr.io/@std/yaml export type YamlSchemaType = | "failsafe" | "json" | "core" | "default" | "extended"; export type YamlStyleVariant = | "lowercase" | "uppercase" | "camelcase" | "decimal" | "binary" | "octal" | "hexadecimal"; /** Options for `YAML.stringify` */ export type YamlStringifyOptions = { /** * Indentation width to use (in spaces). * * @default {2} */ indent?: number; /** * When true, adds an indentation level to array elements. * * @default {true} */ arrayIndent?: boolean; /** * Do not throw on invalid types (like function in the safe schema) and skip * pairs and single values with such types. * * @default {false} */ skipInvalid?: boolean; /** * Specifies level of nesting, when to switch from block to flow style for * collections. `-1` means block style everywhere. * * @default {-1} */ flowLevel?: number; /** Each tag may have own set of styles. - "tag" => "style" map. */ styles?: Record; /** * Name of the schema to use. * * @default {"default"} */ schema?: YamlSchemaType; /** * If true, sort keys when dumping YAML in ascending, ASCII character order. * If a function, use the function to sort the keys. * If a function is specified, the function must return a negative value * if first argument is less than second argument, zero if they're equal * and a positive value otherwise. * * @default {false} */ sortKeys?: boolean | ((a: string, b: string) => number); /** * Set max line width. * * @default {80} */ lineWidth?: number; /** * If false, don't convert duplicate objects into references. * * @default {true} */ useAnchors?: boolean; /** * If false don't try to be compatible with older yaml versions. * Currently: don't quote "yes", "no" and so on, * as required for YAML 1.1. * * @default {true} */ compatMode?: boolean; /** * If true flow sequences will be condensed, omitting the * space between `key: value` or `a, b`. Eg. `'[a,b]'` or `{a:{b:c}}`. * Can be useful when using yaml for pretty URL query params * as spaces are %-encoded. * * @default {false} */ condenseFlow?: boolean; }; /** Options for `YAML.parse` */ export interface YamlParseOptions { /** * Name of the schema to use. * * @default {"default"} */ schema?: YamlSchemaType; /** * If `true`, duplicate keys will overwrite previous values. Otherwise, * duplicate keys will throw a {@linkcode SyntaxError}. * * @default {false} */ allowDuplicateKeys?: boolean; /** * If defined, a function to call on warning messages taking an * {@linkcode Error} as its only argument. */ onWarning?(error: Error): void; } // =============== // 🔴 Cargo TOML 🦀 // =============== /** * Represents the structure of a Cargo.toml manifest file. */ export interface CargoToml { /** * Information about the main package in the Cargo project. */ package?: CargoTomlPackage; /** * Dependencies required by the project, organized into normal, development, and build dependencies. */ dependencies?: CargoTomlDependencies; /** * Development dependencies required by the project. */ devDependencies?: CargoTomlDependencies; /** * Build dependencies required by the project. */ buildDependencies?: CargoTomlDependencies; /** * Features available in the package, each as an array of dependency names or other features. */ features?: Record; /** * Build profiles available in the package, allowing for profile-specific configurations. */ profile?: CargoTomlProfiles; /** * Path to the custom build script for the package, if applicable. */ build?: string; /** * Workspace configuration for multi-package Cargo projects. */ workspace?: CargoTomlWorkspace; /** * Additional metadata ignored by Cargo but potentially used by other tools. */ [key: string]: any; } /** * Metadata for the main package in the Cargo project. */ export interface CargoTomlPackage { /** * The name of the package, used by Cargo and for crate publishing. */ name: string; /** * The version of the package, following Semantic Versioning. */ version: string; /** * List of author names or emails. */ authors?: string[]; /** * The Rust edition for this package. */ edition?: "2015" | "2018" | "2021"; /** * Short description of the package. */ description?: string; /** * The license for the package, specified as a SPDX identifier. */ license?: string; /** * Path to a custom license file for the package. */ licenseFile?: string; /** * URL to the package documentation. */ documentation?: string; /** * URL to the package homepage. */ homepage?: string; /** * URL to the package repository. */ repository?: string; /** * Path to the README file for the package. */ readme?: string; /** * List of keywords for the package, used for search optimization. */ keywords?: string[]; /** * List of categories that the package belongs to. */ categories?: string[]; /** * Workspace that this package belongs to, if any. */ workspace?: string; /** * Path to a build script for the package. */ build?: string; /** * Name of a native library to link with, if applicable. */ links?: string; /** * List of paths to exclude from the package. */ exclude?: string[]; /** * List of paths to include in the package. */ include?: string[]; /** * Indicates whether the package should be published to crates.io. */ publish?: boolean; /** * Arbitrary metadata that is ignored by Cargo but can be used by other tools. */ metadata?: Record; /** * Auto-enable binaries for the package. */ autobins?: boolean; /** * Auto-enable examples for the package. */ autoexamples?: boolean; /** * Auto-enable tests for the package. */ autotests?: boolean; /** * Auto-enable benchmarks for the package. */ autobenches?: boolean; /** * Specifies the version of dependency resolution to use. */ resolver?: "1" | "2"; } /** * A map of dependencies in the Cargo manifest, with each dependency represented by its name. */ export type CargoTomlDependencies = Record; /** * Information about a specific dependency in the Cargo manifest. */ export type CargoTomlDependency = | string | { /** * Version requirement for the dependency. */ version?: string; /** * Path to a local dependency. */ path?: string; /** * Name of the registry to use for this dependency. */ registry?: string; /** * URL to a Git repository for this dependency. */ git?: string; /** * Branch to use for a Git dependency. */ branch?: string; /** * Tag to use for a Git dependency. */ tag?: string; /** * Specific revision to use for a Git dependency. */ rev?: string; /** * Marks this dependency as optional. */ optional?: boolean; /** * Enables default features for this dependency. */ defaultFeatures?: boolean; /** * List of features to enable for this dependency. */ features?: string[]; /** * Renames the dependency package name. */ package?: string; }; /** * Defines available profiles for building the package. */ export interface CargoTomlProfiles { /** * Development profile configuration. */ dev?: CargoTomlProfile; /** * Release profile configuration. */ release?: CargoTomlProfile; /** * Test profile configuration. */ test?: CargoTomlProfile; /** * Benchmark profile configuration. */ bench?: CargoTomlProfile; /** * Documentation profile configuration. */ doc?: CargoTomlProfile; /** * Additional custom profiles. */ [profileName: string]: CargoTomlProfile | undefined; } /** * Configuration for an individual build profile. */ export interface CargoTomlProfile { /** * Profile that this profile inherits from. */ inherits?: string; /** * Optimization level for the profile. */ optLevel?: "0" | "1" | "2" | "3" | "s" | "z"; /** * Enables debug information, either as a boolean or a level. */ debug?: boolean | number; /** * Controls how debug information is split. */ splitDebugInfo?: "unpacked" | "packed" | "off"; /** * Enables or disables debug assertions. */ debugAssertions?: boolean; /** * Enables or disables overflow checks. */ overflowChecks?: boolean; /** * Enables or disables unit testing for the profile. */ test?: boolean; /** * Link-time optimization settings for the profile. */ lto?: boolean | "thin" | "fat"; /** * Panic strategy for the profile. */ panic?: "unwind" | "abort"; /** * Enables or disables incremental compilation. */ incremental?: boolean; /** * Number of code generation units for parallelism. */ codegenUnits?: number; /** * Enables or disables the use of runtime paths. */ rpath?: boolean; /** * Specifies stripping options for the binary. */ strip?: boolean | "debuginfo" | "symbols"; /** * Additional custom profile fields. */ [key: string]: any; } /** * Defines workspace-specific settings for a Cargo project. */ export interface CargoTomlWorkspace { /** * Members of the workspace. */ members?: string[]; /** * Paths to exclude from the workspace. */ exclude?: string[]; /** * Members to include by default when building the workspace. */ defaultMembers?: string[]; /** * Common Information about the packages in the Cargo workspace. */ package?: CargoTomlPackage; /** * Additional custom workspace fields. */ [key: string]: any; } // ===================== // 🔴 TOML De/serializer // ===================== // https://jsr.io/@std/toml export interface TomlStringifyOptions { /** * Define if the keys should be aligned or not. * * @default {false} */ keyAlignment?: boolean; } /** Pre initialized Komodo client */ var komodo: ReturnType; /** KomodoClient initializer */ var KomodoClient: typeof Client; /** All Komodo Types */ export import Types = KomodoTypes; /** The incoming arguments */ var ARGS: { WEBHOOK_BRANCH?: string; WEBHOOK_BODY?: any; } & Record; /** YAML parsing utilities */ var YAML: { /** * Converts a JavaScript object or value to a YAML document string. * * @example Usage * ```ts * const data = { id: 1, name: "Alice" }; * * const yaml = YAML.stringify(data); * * assertEquals(yaml, "id: 1\nname: Alice\n"); * ``` * * @throws {TypeError} If `data` contains invalid types. * @param data The data to serialize. * @param options The options for serialization. * @returns A YAML string. */ stringify: (data: unknown, options?: YamlStringifyOptions) => string; /** * Parse and return a YAML string as a parsed YAML document object. * * Note: This does not support functions. Untrusted data is safe to parse. * * @example Usage * ```ts * const data = YAML.parse(` * id: 1 * name: Alice * `); * * assertEquals(data, { id: 1, name: "Alice" }); * ``` * * @throws {SyntaxError} Throws error on invalid YAML. * @param content YAML string to parse. * @param options Parsing options. * @returns Parsed document. */ parse: (content: string, options?: YamlParseOptions) => unknown; /** * Same as `YAML.parse`, but understands multi-document YAML sources, and * returns multiple parsed YAML document objects. * * @example Usage * ```ts * const data = YAML.parseAll(` * --- * id: 1 * name: Alice * --- * id: 2 * name: Bob * --- * id: 3 * name: Eve * `); * * assertEquals(data, [ { id: 1, name: "Alice" }, { id: 2, name: "Bob" }, { id: 3, name: "Eve" }]); * ``` * * @param content YAML string to parse. * @param options Parsing options. * @returns Array of parsed documents. */ parseAll: (content: string, options?: YamlParseOptions) => unknown; /** * Parse and return a YAML string as a Docker Compose file. * * @example Usage * ```ts * const stack = await komodo.read("GetStack", { stack: "test-stack" }); * const contents = stack?.config?.file_contents; * * const parsed: DockerCompose = YAML.parseDockerCompose(contents) * ``` * * @throws {SyntaxError} Throws error on invalid YAML. * @param content Docker compose file string. * @param options Parsing options. * @returns Parsed document. */ parseDockerCompose: ( content: string, options?: YamlParseOptions ) => DockerCompose; }; /** TOML parsing utilities */ var TOML: { /** * Converts an object to a [TOML string](https://toml.io). * * @example Usage * ```ts * const obj = { * title: "TOML Example", * owner: { * name: "Bob", * bio: "Bob is a cool guy", * } * }; * * const tomlString = TOML.stringify(obj); * * assertEquals(tomlString, `title = "TOML Example"\n\n[owner]\nname = "Bob"\nbio = "Bob is a cool guy"\n`); * ``` * @param obj Source object * @param options Options for stringifying. * @returns TOML string */ stringify: ( obj: Record, options?: TomlStringifyOptions ) => string; /** * Parses a [TOML string](https://toml.io) into an object. * * @example Usage * ```ts * const tomlString = `title = "TOML Example" * [owner] * name = "Alice" * bio = "Alice is a programmer."`; * * const obj = TOML.parse(tomlString); * * assertEquals(obj, { title: "TOML Example", owner: { name: "Alice", bio: "Alice is a programmer." } }); * ``` * @param tomlString TOML string to be parsed. * @returns The parsed JS object. */ parse: (tomlString: string) => Record; /** * Parses Komodo resource.toml contents to an object * for easier handling. * * @example Usage * ```ts * const sync = await komodo.read("GetResourceSync", { sync: "test-sync" }) * const contents = sync?.config?.file_contents; * * const resources: Types.ResourcesToml = TOML.parseResourceToml(contents); * ``` * * @param resourceToml The resource file contents. * @returns Komodo resource.toml contents as JSON */ parseResourceToml: (resourceToml: string) => Types.ResourcesToml; /** * Parses Cargo.toml contents to an object * for easier handling. * * @example Usage * ```ts * const contents = Deno.readTextFile("/path/to/Cargo.toml"); * const cargoToml: CargoToml = TOML.parseCargoToml(contents); * ``` * * @param cargoToml The Cargo.toml contents. * @returns Cargo.toml contents as JSON */ parseCargoToml: (cargoToml: string) => CargoToml; }; } ================================================ FILE: frontend/public/manifest.json ================================================ { "name": "Komodo", "short_name": "Komodo", "icons": [ { "src": "/komodo-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { "src": "/komodo-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } ], "theme_color": "#ffffff", "background_color": "#000000", "display": "standalone" } ================================================ FILE: frontend/public/robots.txt ================================================ # https://www.robotstxt.org/robotstxt.html User-agent: * Disallow: ================================================ FILE: frontend/public/schema/compose-spec.json ================================================ { "$schema": "https://json-schema.org/draft-07/schema", "$id": "compose_spec.json", "type": "object", "title": "Compose Specification", "description": "The Compose file is a YAML file defining a multi-containers based application.", "properties": { "version": { "type": "string", "deprecated": true, "description": "declared for backward compatibility, ignored. Please remove it." }, "name": { "type": "string", "description": "define the Compose project name, until user defines one explicitly." }, "include": { "type": "array", "items": { "$ref": "#/definitions/include" }, "description": "compose sub-projects to be included." }, "services": { "type": "object", "patternProperties": { "^[a-zA-Z0-9._-]+$": { "$ref": "#/definitions/service" } }, "additionalProperties": false, "description": "The services that will be used by your application." }, "models": { "type": "object", "patternProperties": { "^[a-zA-Z0-9._-]+$": { "$ref": "#/definitions/model" } }, "description": "Language models that will be used by your application." }, "networks": { "type": "object", "patternProperties": { "^[a-zA-Z0-9._-]+$": { "$ref": "#/definitions/network" } }, "description": "Networks that are shared among multiple services." }, "volumes": { "type": "object", "patternProperties": { "^[a-zA-Z0-9._-]+$": { "$ref": "#/definitions/volume" } }, "additionalProperties": false, "description": "Named volumes that are shared among multiple services." }, "secrets": { "type": "object", "patternProperties": { "^[a-zA-Z0-9._-]+$": { "$ref": "#/definitions/secret" } }, "additionalProperties": false, "description": "Secrets that are shared among multiple services." }, "configs": { "type": "object", "patternProperties": { "^[a-zA-Z0-9._-]+$": { "$ref": "#/definitions/config" } }, "additionalProperties": false, "description": "Configurations that are shared among multiple services." } }, "patternProperties": {"^x-": {}}, "additionalProperties": false, "definitions": { "service": { "type": "object", "description": "Configuration for a service.", "properties": { "develop": {"$ref": "#/definitions/development"}, "deploy": {"$ref": "#/definitions/deployment"}, "annotations": {"$ref": "#/definitions/list_or_dict"}, "attach": {"type": ["boolean", "string"]}, "build": { "description": "Configuration options for building the service's image.", "oneOf": [ {"type": "string", "description": "Path to the build context. Can be a relative path or a URL."}, { "type": "object", "properties": { "context": {"type": "string", "description": "Path to the build context. Can be a relative path or a URL."}, "dockerfile": {"type": "string", "description": "Name of the Dockerfile to use for building the image."}, "dockerfile_inline": {"type": "string", "description": "Inline Dockerfile content to use instead of a Dockerfile from the build context."}, "entitlements": {"type": "array", "items": {"type": "string"}, "description": "List of extra privileged entitlements to grant to the build process."}, "args": {"$ref": "#/definitions/list_or_dict", "description": "Build-time variables, specified as a map or a list of KEY=VAL pairs."}, "ssh": {"$ref": "#/definitions/list_or_dict", "description": "SSH agent socket or keys to expose to the build. Format is either a string or a list of 'default|[=|[,]]'."}, "labels": {"$ref": "#/definitions/list_or_dict", "description": "Labels to apply to the built image."}, "cache_from": {"type": "array", "items": {"type": "string"}, "description": "List of sources the image builder should use for cache resolution"}, "cache_to": {"type": "array", "items": {"type": "string"}, "description": "Cache destinations for the build cache."}, "no_cache": {"type": ["boolean", "string"], "description": "Do not use cache when building the image."}, "additional_contexts": {"$ref": "#/definitions/list_or_dict", "description": "Additional build contexts to use, specified as a map of name to context path or URL."}, "network": {"type": "string", "description": "Network mode to use for the build. Options include 'default', 'none', 'host', or a network name."}, "pull": {"type": ["boolean", "string"], "description": "Always attempt to pull a newer version of the image."}, "target": {"type": "string", "description": "Build stage to target in a multi-stage Dockerfile."}, "shm_size": {"type": ["integer", "string"], "description": "Size of /dev/shm for the build container. A string value can use suffix like '2g' for 2 gigabytes."}, "extra_hosts": {"$ref": "#/definitions/extra_hosts", "description": "Add hostname mappings for the build container."}, "isolation": {"type": "string", "description": "Container isolation technology to use for the build process."}, "privileged": {"type": ["boolean", "string"], "description": "Give extended privileges to the build container."}, "secrets": {"$ref": "#/definitions/service_config_or_secret", "description": "Secrets to expose to the build. These are accessible at build-time."}, "tags": {"type": "array", "items": {"type": "string"}, "description": "Additional tags to apply to the built image."}, "ulimits": {"$ref": "#/definitions/ulimits", "description": "Override the default ulimits for the build container."}, "platforms": {"type": "array", "items": {"type": "string"}, "description": "Platforms to build for, e.g., 'linux/amd64', 'linux/arm64', or 'windows/amd64'."} }, "additionalProperties": false, "patternProperties": {"^x-": {}} } ] }, "blkio_config": { "type": "object", "description": "Block IO configuration for the service.", "properties": { "device_read_bps": { "type": "array", "description": "Limit read rate (bytes per second) from a device.", "items": {"$ref": "#/definitions/blkio_limit"} }, "device_read_iops": { "type": "array", "description": "Limit read rate (IO per second) from a device.", "items": {"$ref": "#/definitions/blkio_limit"} }, "device_write_bps": { "type": "array", "description": "Limit write rate (bytes per second) to a device.", "items": {"$ref": "#/definitions/blkio_limit"} }, "device_write_iops": { "type": "array", "description": "Limit write rate (IO per second) to a device.", "items": {"$ref": "#/definitions/blkio_limit"} }, "weight": { "type": ["integer", "string"], "description": "Block IO weight (relative weight) for the service, between 10 and 1000." }, "weight_device": { "type": "array", "description": "Block IO weight (relative weight) for specific devices.", "items": {"$ref": "#/definitions/blkio_weight"} } }, "additionalProperties": false }, "cap_add": { "type": "array", "items": {"type": "string"}, "uniqueItems": true, "description": "Add Linux capabilities. For example, 'CAP_SYS_ADMIN', 'SYS_ADMIN', or 'NET_ADMIN'." }, "cap_drop": { "type": "array", "items": {"type": "string"}, "uniqueItems": true, "description": "Drop Linux capabilities. For example, 'CAP_SYS_ADMIN', 'SYS_ADMIN', or 'NET_ADMIN'." }, "cgroup": { "type": "string", "enum": ["host", "private"], "description": "Specify the cgroup namespace to join. Use 'host' to use the host's cgroup namespace, or 'private' to use a private cgroup namespace." }, "cgroup_parent": { "type": "string", "description": "Specify an optional parent cgroup for the container." }, "command": { "$ref": "#/definitions/command", "description": "Override the default command declared by the container image, for example 'CMD' in Dockerfile." }, "configs": { "$ref": "#/definitions/service_config_or_secret", "description": "Grant access to Configs on a per-service basis." }, "container_name": { "type": "string", "description": "Specify a custom container name, rather than a generated default name.", "pattern": "[a-zA-Z0-9][a-zA-Z0-9_.-]+" }, "cpu_count": { "oneOf": [ {"type": "string"}, {"type": "integer", "minimum": 0} ], "description": "Number of usable CPUs." }, "cpu_percent": { "oneOf": [ {"type": "string"}, {"type": "integer", "minimum": 0, "maximum": 100} ], "description": "Percentage of CPU resources to use." }, "cpu_shares": { "type": ["number", "string"], "description": "CPU shares (relative weight) for the container." }, "cpu_quota": { "type": ["number", "string"], "description": "Limit the CPU CFS (Completely Fair Scheduler) quota." }, "cpu_period": { "type": ["number", "string"], "description": "Limit the CPU CFS (Completely Fair Scheduler) period." }, "cpu_rt_period": { "type": ["number", "string"], "description": "Limit the CPU real-time period in microseconds or a duration." }, "cpu_rt_runtime": { "type": ["number", "string"], "description": "Limit the CPU real-time runtime in microseconds or a duration." }, "cpus": { "type": ["number", "string"], "description": "Number of CPUs to use. A floating-point value is supported to request partial CPUs." }, "cpuset": { "type": "string", "description": "CPUs in which to allow execution (0-3, 0,1)." }, "credential_spec": { "type": "object", "description": "Configure the credential spec for managed service account.", "properties": { "config": { "type": "string", "description": "The name of the credential spec Config to use." }, "file": { "type": "string", "description": "Path to a credential spec file." }, "registry": { "type": "string", "description": "Path to a credential spec in the Windows registry." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "depends_on": { "oneOf": [ {"$ref": "#/definitions/list_of_strings"}, { "type": "object", "additionalProperties": false, "patternProperties": { "^[a-zA-Z0-9._-]+$": { "type": "object", "additionalProperties": false, "patternProperties": {"^x-": {}}, "properties": { "restart": { "type": ["boolean", "string"], "description": "Whether to restart dependent services when this service is restarted." }, "required": { "type": "boolean", "default": true, "description": "Whether the dependency is required for the dependent service to start." }, "condition": { "type": "string", "enum": ["service_started", "service_healthy", "service_completed_successfully"], "description": "Condition to wait for. 'service_started' waits until the service has started, 'service_healthy' waits until the service is healthy (as defined by its healthcheck), 'service_completed_successfully' waits until the service has completed successfully." } }, "required": ["condition"] } } } ], "description": "Express dependency between services. Service dependencies cause services to be started in dependency order. The dependent service will wait for the dependency to be ready before starting." }, "device_cgroup_rules": { "$ref": "#/definitions/list_of_strings", "description": "Add rules to the cgroup allowed devices list." }, "devices": { "type": "array", "description": "List of device mappings for the container.", "items": { "oneOf": [ {"type": "string"}, { "type": "object", "required": ["source"], "properties": { "source": { "type": "string", "description": "Path on the host to the device." }, "target": { "type": "string", "description": "Path in the container where the device will be mapped." }, "permissions": { "type": "string", "description": "Cgroup permissions for the device (rwm)." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} } ] } }, "dns": { "$ref": "#/definitions/string_or_list", "description": "Custom DNS servers to set for the service container." }, "dns_opt": { "type": "array", "items": {"type": "string"}, "uniqueItems": true, "description": "Custom DNS options to be passed to the container's DNS resolver." }, "dns_search": { "$ref": "#/definitions/string_or_list", "description": "Custom DNS search domains to set on the service container." }, "domainname": { "type": "string", "description": "Custom domain name to use for the service container." }, "entrypoint": { "$ref": "#/definitions/command", "description": "Override the default entrypoint declared by the container image, for example 'ENTRYPOINT' in Dockerfile." }, "env_file": { "$ref": "#/definitions/env_file", "description": "Add environment variables from a file or multiple files. Can be a single file path or a list of file paths." }, "label_file": { "$ref": "#/definitions/label_file", "description": "Add metadata to containers using files containing Docker labels." }, "environment": { "$ref": "#/definitions/list_or_dict", "description": "Add environment variables. You can use either an array or a list of KEY=VAL pairs." }, "expose": { "type": "array", "items": { "type": ["string", "number"] }, "uniqueItems": true, "description": "Expose ports without publishing them to the host machine - they'll only be accessible to linked services." }, "extends": { "oneOf": [ {"type": "string"}, { "type": "object", "properties": { "service": { "type": "string", "description": "The name of the service to extend." }, "file": { "type": "string", "description": "The file path where the service to extend is defined." } }, "required": ["service"], "additionalProperties": false } ], "description": "Extend another service, in the current file or another file." }, "provider": { "type": "object", "description": "Specify a service which will not be manage by Compose directly, and delegate its management to an external provider.", "required": ["type"], "properties": { "type": { "type": "string", "description": "External component used by Compose to manage setup and teardown lifecycle of the service." }, "options": { "type": "object", "description": "Provider-specific options.", "patternProperties": { "^.+$": {"oneOf": [ { "type": ["string", "number", "boolean"] }, { "type": "array", "items": {"type": ["string", "number", "boolean"]}} ]} } } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "external_links": { "type": "array", "items": {"type": "string"}, "uniqueItems": true, "description": "Link to services started outside this Compose application. Specify services as :." }, "extra_hosts": { "$ref": "#/definitions/extra_hosts", "description": "Add hostname mappings to the container network interface configuration." }, "gpus": { "$ref": "#/definitions/gpus", "description": "Define GPU devices to use. Can be set to 'all' to use all GPUs, or a list of specific GPU devices." }, "group_add": { "type": "array", "items": { "type": ["string", "number"] }, "uniqueItems": true, "description": "Add additional groups which user inside the container should be member of." }, "healthcheck": { "$ref": "#/definitions/healthcheck", "description": "Configure a health check for the container to monitor its health status." }, "hostname": { "type": "string", "description": "Define a custom hostname for the service container." }, "image": { "type": "string", "description": "Specify the image to start the container from. Can be a repository/tag, a digest, or a local image ID." }, "init": { "type": ["boolean", "string"], "description": "Run as an init process inside the container that forwards signals and reaps processes." }, "ipc": { "type": "string", "description": "IPC sharing mode for the service container. Use 'host' to share the host's IPC namespace, 'service:[service_name]' to share with another service, or 'shareable' to allow other services to share this service's IPC namespace." }, "isolation": { "type": "string", "description": "Container isolation technology to use. Supported values are platform-specific." }, "labels": { "$ref": "#/definitions/list_or_dict", "description": "Add metadata to containers using Docker labels. You can use either an array or a list." }, "links": { "type": "array", "items": {"type": "string"}, "uniqueItems": true, "description": "Link to containers in another service. Either specify both the service name and a link alias (SERVICE:ALIAS), or just the service name." }, "logging": { "type": "object", "description": "Logging configuration for the service.", "properties": { "driver": { "type": "string", "description": "Logging driver to use, such as 'json-file', 'syslog', 'journald', etc." }, "options": { "type": "object", "description": "Options for the logging driver.", "patternProperties": { "^.+$": {"type": ["string", "number", "null"]} } } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "mac_address": { "type": "string", "description": "Container MAC address to set." }, "mem_limit": { "type": ["number", "string"], "description": "Memory limit for the container. A string value can use suffix like '2g' for 2 gigabytes." }, "mem_reservation": { "type": ["string", "integer"], "description": "Memory reservation for the container." }, "mem_swappiness": { "type": ["integer", "string"], "description": "Container memory swappiness as percentage (0 to 100)." }, "memswap_limit": { "type": ["number", "string"], "description": "Amount of memory the container is allowed to swap to disk. Set to -1 to enable unlimited swap." }, "network_mode": { "type": "string", "description": "Network mode. Values can be 'bridge', 'host', 'none', 'service:[service name]', or 'container:[container name]'." }, "models": { "oneOf": [ {"$ref": "#/definitions/list_of_strings"}, {"type": "object", "patternProperties": { "^[a-zA-Z0-9._-]+$": { "type": "object", "properties": { "endpoint_var": { "type": "string", "description": "Environment variable set to AI model endpoint." }, "model_var": { "type": "string", "description": "Environment variable set to AI model name." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} } } } ], "description": "AI Models to use, referencing entries under the top-level models key." }, "networks": { "oneOf": [ {"$ref": "#/definitions/list_of_strings"}, { "type": "object", "patternProperties": { "^[a-zA-Z0-9._-]+$": { "oneOf": [ { "type": "object", "properties": { "aliases": { "$ref": "#/definitions/list_of_strings", "description": "Alternative hostnames for this service on the network." }, "interface_name": { "type": "string", "description": "Interface network name used to connect to network" }, "ipv4_address": { "type": "string", "description": "Specify a static IPv4 address for this service on this network." }, "ipv6_address": { "type": "string", "description": "Specify a static IPv6 address for this service on this network." }, "link_local_ips": { "$ref": "#/definitions/list_of_strings", "description": "List of link-local IPs." }, "mac_address": { "type": "string", "description": "Specify a MAC address for this service on this network." }, "driver_opts": { "type": "object", "description": "Driver options for this network.", "patternProperties": { "^.+$": {"type": ["string", "number"]} } }, "priority": { "type": "number", "description": "Specify the priority for the network connection." }, "gw_priority": { "type": "number", "description": "Specify the gateway priority for the network connection." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, {"type": "null"} ] } }, "additionalProperties": false } ], "description": "Networks to join, referencing entries under the top-level networks key. Can be a list of network names or a mapping of network name to network configuration." }, "oom_kill_disable": { "type": ["boolean", "string"], "description": "Disable OOM Killer for the container." }, "oom_score_adj": { "oneOf": [ {"type": "string"}, {"type": "integer", "minimum": -1000, "maximum": 1000} ], "description": "Tune host's OOM preferences for the container (accepts -1000 to 1000)." }, "pid": { "type": ["string", "null"], "description": "PID mode for container." }, "pids_limit": { "type": ["number", "string"], "description": "Tune a container's PIDs limit. Set to -1 for unlimited PIDs." }, "platform": { "type": "string", "description": "Target platform to run on, e.g., 'linux/amd64', 'linux/arm64', or 'windows/amd64'." }, "ports": { "type": "array", "description": "Expose container ports. Short format ([HOST:]CONTAINER[/PROTOCOL]).", "items": { "oneOf": [ {"type": "number"}, {"type": "string"}, { "type": "object", "properties": { "name": { "type": "string", "description": "A human-readable name for this port mapping." }, "mode": { "type": "string", "description": "The port binding mode, either 'host' for publishing a host port or 'ingress' for load balancing." }, "host_ip": { "type": "string", "description": "The host IP to bind to." }, "target": { "type": ["integer", "string"], "description": "The port inside the container." }, "published": { "type": ["string", "integer"], "description": "The publicly exposed port." }, "protocol": { "type": "string", "description": "The port protocol (tcp or udp)." }, "app_protocol": { "type": "string", "description": "Application protocol to use with the port (e.g., http, https, mysql)." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} } ] }, "uniqueItems": true }, "post_start": { "type": "array", "items": {"$ref": "#/definitions/service_hook"}, "description": "Commands to run after the container starts. If any command fails, the container stops." }, "pre_stop": { "type": "array", "items": {"$ref": "#/definitions/service_hook"}, "description": "Commands to run before the container stops. If any command fails, the container stop is aborted." }, "privileged": { "type": ["boolean", "string"], "description": "Give extended privileges to the service container." }, "profiles": { "$ref": "#/definitions/list_of_strings", "description": "List of profiles for this service. When profiles are specified, services are only started when the profile is activated." }, "pull_policy": { "type": "string", "pattern": "always|never|build|if_not_present|missing|refresh|daily|weekly|every_([0-9]+[wdhms])+", "description": "Policy for pulling images. Options include: 'always', 'never', 'if_not_present', 'missing', 'build', or time-based refresh policies." }, "pull_refresh_after": { "type": "string", "description": "Time after which to refresh the image. Used with pull_policy=refresh." }, "read_only": { "type": ["boolean", "string"], "description": "Mount the container's filesystem as read only." }, "restart": { "type": "string", "description": "Restart policy for the service container. Options include: 'no', 'always', 'on-failure', and 'unless-stopped'." }, "runtime": { "type": "string", "description": "Runtime to use for this container, e.g., 'runc'." }, "scale": { "type": ["integer", "string"], "description": "Number of containers to deploy for this service." }, "security_opt": { "type": "array", "items": {"type": "string"}, "uniqueItems": true, "description": "Override the default labeling scheme for each container." }, "shm_size": { "type": ["number", "string"], "description": "Size of /dev/shm. A string value can use suffix like '2g' for 2 gigabytes." }, "secrets": { "$ref": "#/definitions/service_config_or_secret", "description": "Grant access to Secrets on a per-service basis." }, "sysctls": { "$ref": "#/definitions/list_or_dict", "description": "Kernel parameters to set in the container. You can use either an array or a list." }, "stdin_open": { "type": ["boolean", "string"], "description": "Keep STDIN open even if not attached." }, "stop_grace_period": { "type": "string", "description": "Time to wait for the container to stop gracefully before sending SIGKILL (e.g., '1s', '1m30s')." }, "stop_signal": { "type": "string", "description": "Signal to stop the container (e.g., 'SIGTERM', 'SIGINT')." }, "storage_opt": { "type": "object", "description": "Storage driver options for the container." }, "tmpfs": { "$ref": "#/definitions/string_or_list", "description": "Mount a temporary filesystem (tmpfs) into the container. Can be a single value or a list." }, "tty": { "type": ["boolean", "string"], "description": "Allocate a pseudo-TTY to service container." }, "ulimits": { "$ref": "#/definitions/ulimits", "description": "Override the default ulimits for a container." }, "use_api_socket": { "type": "boolean", "description": "Bind mount Docker API socket and required auth." }, "user": { "type": "string", "description": "Username or UID to run the container process as." }, "uts": { "type": "string", "description": "UTS namespace to use. 'host' shares the host's UTS namespace." }, "userns_mode": { "type": "string", "description": "User namespace to use. 'host' shares the host's user namespace." }, "volumes": { "type": "array", "description": "Mount host paths or named volumes accessible to the container. Short syntax (VOLUME:CONTAINER_PATH[:MODE])", "items": { "oneOf": [ {"type": "string"}, { "type": "object", "required": ["type"], "properties": { "type": { "type": "string", "enum": ["bind", "volume", "tmpfs", "cluster", "npipe", "image"], "description": "The mount type: bind for mounting host directories, volume for named volumes, tmpfs for temporary filesystems, cluster for cluster volumes, npipe for named pipes, or image for mounting from an image." }, "source": { "type": "string", "description": "The source of the mount, a path on the host for a bind mount, a docker image reference for an image mount, or the name of a volume defined in the top-level volumes key. Not applicable for a tmpfs mount." }, "target": { "type": "string", "description": "The path in the container where the volume is mounted." }, "read_only": { "type": ["boolean", "string"], "description": "Flag to set the volume as read-only." }, "consistency": { "type": "string", "description": "The consistency requirements for the mount. Available values are platform specific." }, "bind": { "type": "object", "description": "Configuration specific to bind mounts.", "properties": { "propagation": { "type": "string", "description": "The propagation mode for the bind mount: 'shared', 'slave', 'private', 'rshared', 'rslave', or 'rprivate'." }, "create_host_path": { "type": ["boolean", "string"], "description": "Create the host path if it doesn't exist." }, "recursive": { "type": "string", "enum": ["enabled", "disabled", "writable", "readonly"], "description": "Recursively mount the source directory." }, "selinux": { "type": "string", "enum": ["z", "Z"], "description": "SELinux relabeling options: 'z' for shared content, 'Z' for private unshared content." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "volume": { "type": "object", "description": "Configuration specific to volume mounts.", "properties": { "labels": { "$ref": "#/definitions/list_or_dict", "description": "Labels to apply to the volume." }, "nocopy": { "type": ["boolean", "string"], "description": "Flag to disable copying of data from a container when a volume is created." }, "subpath": { "type": "string", "description": "Path within the volume to mount instead of the volume root." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "tmpfs": { "type": "object", "description": "Configuration specific to tmpfs mounts.", "properties": { "size": { "oneOf": [ {"type": "integer", "minimum": 0}, {"type": "string"} ], "description": "Size of the tmpfs mount in bytes." }, "mode": { "type": ["number", "string"], "description": "File mode of the tmpfs in octal." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "image": { "type": "object", "description": "Configuration specific to image mounts.", "properties": { "subpath": { "type": "string", "description": "Path within the image to mount instead of the image root." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} } }, "additionalProperties": false, "patternProperties": {"^x-": {}} } ] }, "uniqueItems": true }, "volumes_from": { "type": "array", "items": {"type": "string"}, "uniqueItems": true, "description": "Mount volumes from another service or container. Optionally specify read-only access (ro) or read-write (rw)." }, "working_dir": { "type": "string", "description": "The working directory in which the entrypoint or command will be run" } }, "patternProperties": {"^x-": {}}, "additionalProperties": false }, "healthcheck": { "type": "object", "description": "Configuration options to determine whether the container is healthy.", "properties": { "disable": { "type": ["boolean", "string"], "description": "Disable any container-specified healthcheck. Set to true to disable." }, "interval": { "type": "string", "description": "Time between running the check (e.g., '1s', '1m30s'). Default: 30s." }, "retries": { "type": ["number", "string"], "description": "Number of consecutive failures needed to consider the container as unhealthy. Default: 3." }, "test": { "oneOf": [ {"type": "string"}, {"type": "array", "items": {"type": "string"}} ], "description": "The test to perform to check container health. Can be a string or a list. The first item is either NONE, CMD, or CMD-SHELL. If it's CMD, the rest of the command is exec'd. If it's CMD-SHELL, the rest is run in the shell." }, "timeout": { "type": "string", "description": "Maximum time to allow one check to run (e.g., '1s', '1m30s'). Default: 30s." }, "start_period": { "type": "string", "description": "Start period for the container to initialize before starting health-retries countdown (e.g., '1s', '1m30s'). Default: 0s." }, "start_interval": { "type": "string", "description": "Time between running the check during the start period (e.g., '1s', '1m30s'). Default: interval value." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "development": { "type": ["object", "null"], "description": "Development configuration for the service, used for development workflows.", "properties": { "watch": { "type": "array", "description": "Configure watch mode for the service, which monitors file changes and performs actions in response.", "items": { "type": "object", "required": ["path", "action"], "properties": { "ignore": { "$ref": "#/definitions/string_or_list", "description": "Patterns to exclude from watching." }, "include": { "$ref": "#/definitions/string_or_list", "description": "Patterns to include in watching." }, "path": { "type": "string", "description": "Path to watch for changes." }, "action": { "type": "string", "enum": ["rebuild", "sync", "restart", "sync+restart", "sync+exec"], "description": "Action to take when a change is detected: rebuild the container, sync files, restart the container, sync and restart, or sync and execute a command." }, "target": { "type": "string", "description": "Target path in the container for sync operations." }, "exec": { "$ref": "#/definitions/service_hook", "description": "Command to execute when a change is detected and action is sync+exec." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} } } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "deployment": { "type": ["object", "null"], "description": "Deployment configuration for the service.", "properties": { "mode": { "type": "string", "description": "Deployment mode for the service: 'replicated' (default) or 'global'." }, "endpoint_mode": { "type": "string", "description": "Endpoint mode for the service: 'vip' (default) or 'dnsrr'." }, "replicas": { "type": ["integer", "string"], "description": "Number of replicas of the service container to run." }, "labels": { "$ref": "#/definitions/list_or_dict", "description": "Labels to apply to the service." }, "rollback_config": { "type": "object", "description": "Configuration for rolling back a service update.", "properties": { "parallelism": { "type": ["integer", "string"], "description": "The number of containers to rollback at a time. If set to 0, all containers rollback simultaneously." }, "delay": { "type": "string", "description": "The time to wait between each container group's rollback (e.g., '1s', '1m30s')." }, "failure_action": { "type": "string", "description": "Action to take if a rollback fails: 'continue', 'pause'." }, "monitor": { "type": "string", "description": "Duration to monitor each task for failures after it is created (e.g., '1s', '1m30s')." }, "max_failure_ratio": { "type": ["number", "string"], "description": "Failure rate to tolerate during a rollback." }, "order": { "type": "string", "enum": ["start-first", "stop-first"], "description": "Order of operations during rollbacks: 'stop-first' (default) or 'start-first'." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "update_config": { "type": "object", "description": "Configuration for updating a service.", "properties": { "parallelism": { "type": ["integer", "string"], "description": "The number of containers to update at a time." }, "delay": { "type": "string", "description": "The time to wait between updating a group of containers (e.g., '1s', '1m30s')." }, "failure_action": { "type": "string", "description": "Action to take if an update fails: 'continue', 'pause', 'rollback'." }, "monitor": { "type": "string", "description": "Duration to monitor each updated task for failures after it is created (e.g., '1s', '1m30s')." }, "max_failure_ratio": { "type": ["number", "string"], "description": "Failure rate to tolerate during an update (0 to 1)." }, "order": { "type": "string", "enum": ["start-first", "stop-first"], "description": "Order of operations during updates: 'stop-first' (default) or 'start-first'." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "resources": { "type": "object", "description": "Resource constraints and reservations for the service.", "properties": { "limits": { "type": "object", "description": "Resource limits for the service containers.", "properties": { "cpus": { "type": ["number", "string"], "description": "Limit for how much of the available CPU resources, as number of cores, a container can use." }, "memory": { "type": "string", "description": "Limit on the amount of memory a container can allocate (e.g., '1g', '1024m')." }, "pids": { "type": ["integer", "string"], "description": "Maximum number of PIDs available to the container." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "reservations": { "type": "object", "description": "Resource reservations for the service containers.", "properties": { "cpus": { "type": ["number", "string"], "description": "Reservation for how much of the available CPU resources, as number of cores, a container can use." }, "memory": { "type": "string", "description": "Reservation on the amount of memory a container can allocate (e.g., '1g', '1024m')." }, "generic_resources": { "$ref": "#/definitions/generic_resources", "description": "User-defined resources to reserve." }, "devices": { "$ref": "#/definitions/devices", "description": "Device reservations for the container." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "restart_policy": { "type": "object", "description": "Restart policy for the service containers.", "properties": { "condition": { "type": "string", "description": "Condition for restarting the container: 'none', 'on-failure', 'any'." }, "delay": { "type": "string", "description": "Delay between restart attempts (e.g., '1s', '1m30s')." }, "max_attempts": { "type": ["integer", "string"], "description": "Maximum number of restart attempts before giving up." }, "window": { "type": "string", "description": "Time window used to evaluate the restart policy (e.g., '1s', '1m30s')." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "placement": { "type": "object", "description": "Constraints and preferences for the platform to select a physical node to run service containers", "properties": { "constraints": { "type": "array", "items": {"type": "string"}, "description": "Placement constraints for the service (e.g., 'node.role==manager')." }, "preferences": { "type": "array", "description": "Placement preferences for the service.", "items": { "type": "object", "properties": { "spread": { "type": "string", "description": "Spread tasks evenly across values of the specified node label." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} } }, "max_replicas_per_node": { "type": ["integer", "string"], "description": "Maximum number of replicas of the service." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "generic_resources": { "type": "array", "description": "User-defined resources for services, allowing services to reserve specialized hardware resources.", "items": { "type": "object", "properties": { "discrete_resource_spec": { "type": "object", "description": "Specification for discrete (countable) resources.", "properties": { "kind": { "type": "string", "description": "Type of resource (e.g., 'GPU', 'FPGA', 'SSD')." }, "value": { "type": ["number", "string"], "description": "Number of resources of this kind to reserve." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} } }, "additionalProperties": false, "patternProperties": {"^x-": {}} } }, "devices": { "type": "array", "description": "Device reservations for containers, allowing services to access specific hardware devices.", "items": { "type": "object", "properties": { "capabilities": { "$ref": "#/definitions/list_of_strings", "description": "List of capabilities the device needs to have (e.g., 'gpu', 'compute', 'utility')." }, "count": { "type": ["string", "integer"], "description": "Number of devices of this type to reserve." }, "device_ids": { "$ref": "#/definitions/list_of_strings", "description": "List of specific device IDs to reserve." }, "driver": { "type": "string", "description": "Device driver to use (e.g., 'nvidia')." }, "options": { "$ref": "#/definitions/list_or_dict", "description": "Driver-specific options for the device." } }, "additionalProperties": false, "patternProperties": {"^x-": {}}, "required": [ "capabilities" ] } }, "gpus": { "oneOf": [ { "type": "string", "enum": ["all"], "description": "Use all available GPUs." }, { "type": "array", "description": "List of specific GPU devices to use.", "items": { "type": "object", "properties": { "capabilities": { "$ref": "#/definitions/list_of_strings", "description": "List of capabilities the GPU needs to have (e.g., 'compute', 'utility')." }, "count": { "type": ["string", "integer"], "description": "Number of GPUs to use." }, "device_ids": { "$ref": "#/definitions/list_of_strings", "description": "List of specific GPU device IDs to use." }, "driver": { "type": "string", "description": "GPU driver to use (e.g., 'nvidia')." }, "options": { "$ref": "#/definitions/list_or_dict", "description": "Driver-specific options for the GPU." } } }, "additionalProperties": false, "patternProperties": {"^x-": {}} } ] }, "include": { "description": "Compose application or sub-projects to be included.", "oneOf": [ {"type": "string"}, { "type": "object", "properties": { "path": { "$ref": "#/definitions/string_or_list", "description": "Path to the Compose application or sub-project files to include." }, "env_file": { "$ref": "#/definitions/string_or_list", "description": "Path to the environment files to use to define default values when interpolating variables in the Compose files being parsed." }, "project_directory": { "type": "string", "description": "Path to resolve relative paths set in the Compose file" } }, "additionalProperties": false } ] }, "network": { "type": ["object", "null"], "description": "Network configuration for the Compose application.", "properties": { "name": { "type": "string", "description": "Custom name for this network." }, "driver": { "type": "string", "description": "Specify which driver should be used for this network. Default is 'bridge'." }, "driver_opts": { "type": "object", "description": "Specify driver-specific options defined as key/value pairs.", "patternProperties": { "^.+$": {"type": ["string", "number"]} } }, "ipam": { "type": "object", "description": "Custom IP Address Management configuration for this network.", "properties": { "driver": { "type": "string", "description": "Custom IPAM driver, instead of the default." }, "config": { "type": "array", "description": "List of IPAM configuration blocks.", "items": { "type": "object", "properties": { "subnet": { "type": "string", "description": "Subnet in CIDR format that represents a network segment." }, "ip_range": { "type": "string", "description": "Range of IPs from which to allocate container IPs." }, "gateway": { "type": "string", "description": "IPv4 or IPv6 gateway for the subnet." }, "aux_addresses": { "type": "object", "description": "Auxiliary IPv4 or IPv6 addresses used by Network driver.", "additionalProperties": false, "patternProperties": {"^.+$": {"type": "string"}} } }, "additionalProperties": false, "patternProperties": {"^x-": {}} } }, "options": { "type": "object", "description": "Driver-specific options for the IPAM driver.", "additionalProperties": false, "patternProperties": {"^.+$": {"type": "string"}} } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "external": { "type": ["boolean", "string", "object"], "description": "Specifies that this network already exists and was created outside of Compose.", "properties": { "name": { "deprecated": true, "type": "string", "description": "Specifies the name of the external network. Deprecated: use the 'name' property instead." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "internal": { "type": ["boolean", "string"], "description": "Create an externally isolated network." }, "enable_ipv4": { "type": ["boolean", "string"], "description": "Enable IPv4 networking." }, "enable_ipv6": { "type": ["boolean", "string"], "description": "Enable IPv6 networking." }, "attachable": { "type": ["boolean", "string"], "description": "If true, standalone containers can attach to this network." }, "labels": { "$ref": "#/definitions/list_or_dict", "description": "Add metadata to the network using labels." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "volume": { "type": ["object", "null"], "description": "Volume configuration for the Compose application.", "properties": { "name": { "type": "string", "description": "Custom name for this volume." }, "driver": { "type": "string", "description": "Specify which volume driver should be used for this volume." }, "driver_opts": { "type": "object", "description": "Specify driver-specific options.", "patternProperties": { "^.+$": {"type": ["string", "number"]} } }, "external": { "type": ["boolean", "string", "object"], "description": "Specifies that this volume already exists and was created outside of Compose.", "properties": { "name": { "deprecated": true, "type": "string", "description": "Specifies the name of the external volume. Deprecated: use the 'name' property instead." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "labels": { "$ref": "#/definitions/list_or_dict", "description": "Add metadata to the volume using labels." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "secret": { "type": "object", "description": "Secret configuration for the Compose application.", "properties": { "name": { "type": "string", "description": "Custom name for this secret." }, "environment": { "type": "string", "description": "Name of an environment variable from which to get the secret value." }, "file": { "type": "string", "description": "Path to a file containing the secret value." }, "external": { "type": ["boolean", "string", "object"], "description": "Specifies that this secret already exists and was created outside of Compose.", "properties": { "name": { "type": "string", "description": "Specifies the name of the external secret." } } }, "labels": { "$ref": "#/definitions/list_or_dict", "description": "Add metadata to the secret using labels." }, "driver": { "type": "string", "description": "Specify which secret driver should be used for this secret." }, "driver_opts": { "type": "object", "description": "Specify driver-specific options.", "patternProperties": { "^.+$": {"type": ["string", "number"]} } }, "template_driver": { "type": "string", "description": "Driver to use for templating the secret's value." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "config": { "type": "object", "description": "Config configuration for the Compose application.", "properties": { "name": { "type": "string", "description": "Custom name for this config." }, "content": { "type": "string", "description": "Inline content of the config." }, "environment": { "type": "string", "description": "Name of an environment variable from which to get the config value." }, "file": { "type": "string", "description": "Path to a file containing the config value." }, "external": { "type": ["boolean", "string", "object"], "description": "Specifies that this config already exists and was created outside of Compose.", "properties": { "name": { "deprecated": true, "type": "string", "description": "Specifies the name of the external config. Deprecated: use the 'name' property instead." } } }, "labels": { "$ref": "#/definitions/list_or_dict", "description": "Add metadata to the config using labels." }, "template_driver": { "type": "string", "description": "Driver to use for templating the config's value." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} }, "model": { "type": "object", "description": "Language Model for the Compose application.", "properties": { "name": { "type": "string", "description": "Custom name for this model." }, "model": { "type": "string", "description": "Language Model to run." }, "context_size": { "type": "integer" }, "runtime_flags": { "type": "array", "items": {"type": "string"}, "description": "Raw runtime flags to pass to the inference engine." } }, "required": ["model"], "additionalProperties": false, "patternProperties": {"^x-": {}} }, "command": { "oneOf": [ { "type": "null", "description": "No command specified, use the container's default command." }, { "type": "string", "description": "Command as a string, which will be executed in a shell (e.g., '/bin/sh -c')." }, { "type": "array", "description": "Command as an array of strings, which will be executed directly without a shell.", "items": { "type": "string", "description": "Part of the command (executable or argument)." } } ], "description": "Command to run in the container, which can be specified as a string (shell form) or array (exec form)." }, "service_hook": { "type": "object", "description": "Configuration for service lifecycle hooks, which are commands executed at specific points in a container's lifecycle.", "properties": { "command": { "$ref": "#/definitions/command", "description": "Command to execute as part of the hook." }, "user": { "type": "string", "description": "User to run the command as." }, "privileged": { "type": ["boolean", "string"], "description": "Whether to run the command with extended privileges." }, "working_dir": { "type": "string", "description": "Working directory for the command." }, "environment": { "$ref": "#/definitions/list_or_dict", "description": "Environment variables for the command." } }, "additionalProperties": false, "patternProperties": {"^x-": {}}, "required": ["command"] }, "env_file": { "oneOf": [ { "type": "string", "description": "Path to a file containing environment variables." }, { "type": "array", "description": "List of paths to files containing environment variables.", "items": { "oneOf": [ { "type": "string", "description": "Path to a file containing environment variables." }, { "type": "object", "description": "Detailed configuration for an environment file.", "additionalProperties": false, "properties": { "path": { "type": "string", "description": "Path to the environment file." }, "format": { "type": "string", "description": "Format attribute lets you to use an alternative file formats for env_file. When not set, env_file is parsed according to Compose rules." }, "required": { "type": ["boolean", "string"], "default": true, "description": "Whether the file is required. If true and the file doesn't exist, an error will be raised." } }, "required": [ "path" ] } ] } } ] }, "label_file": { "oneOf": [ { "type": "string", "description": "Path to a file containing Docker labels." }, { "type": "array", "description": "List of paths to files containing Docker labels.", "items": { "type": "string", "description": "Path to a file containing Docker labels." } } ] }, "string_or_list": { "oneOf": [ { "type": "string", "description": "A single string value." }, { "$ref": "#/definitions/list_of_strings", "description": "A list of string values." } ], "description": "Either a single string or a list of strings." }, "list_of_strings": { "type": "array", "description": "A list of unique string values.", "items": { "type": "string", "description": "A string value in the list." }, "uniqueItems": true }, "list_or_dict": { "oneOf": [ { "type": "object", "description": "A dictionary mapping keys to values.", "patternProperties": { ".+": { "type": ["string", "number", "boolean", "null"], "description": "Value for the key, which can be a string, number, boolean, or null." } }, "additionalProperties": false }, { "type": "array", "description": "A list of unique string values.", "items": { "type": "string", "description": "A string value in the list." }, "uniqueItems": true } ], "description": "Either a dictionary mapping keys to values, or a list of strings." }, "extra_hosts": { "oneOf": [ { "type": "object", "description": "list mapping hostnames to IP addresses.", "patternProperties": { ".+": { "oneOf": [ { "type": "string", "description": "IP address for the hostname." }, { "type": "array", "description": "List of IP addresses for the hostname.", "items": { "type": "string", "description": "IP address for the hostname." }, "uniqueItems": false } ] } }, "additionalProperties": false }, { "type": "array", "description": "List of host:IP mappings in the format 'hostname:IP'.", "items": { "type": "string", "description": "Host:IP mapping in the format 'hostname:IP'." }, "uniqueItems": true } ], "description": "Additional hostnames to be defined in the container's /etc/hosts file." }, "blkio_limit": { "type": "object", "description": "Block IO limit for a specific device.", "properties": { "path": { "type": "string", "description": "Path to the device (e.g., '/dev/sda')." }, "rate": { "type": ["integer", "string"], "description": "Rate limit in bytes per second or IO operations per second." } }, "additionalProperties": false }, "blkio_weight": { "type": "object", "description": "Block IO weight for a specific device.", "properties": { "path": { "type": "string", "description": "Path to the device (e.g., '/dev/sda')." }, "weight": { "type": ["integer", "string"], "description": "Relative weight for the device, between 10 and 1000." } }, "additionalProperties": false }, "service_config_or_secret": { "type": "array", "description": "Configuration for service configs or secrets, defining how they are mounted in the container.", "items": { "oneOf": [ { "type": "string", "description": "Name of the config or secret to grant access to." }, { "type": "object", "description": "Detailed configuration for a config or secret.", "properties": { "source": { "type": "string", "description": "Name of the config or secret as defined in the top-level configs or secrets section." }, "target": { "type": "string", "description": "Path in the container where the config or secret will be mounted. Defaults to / for configs and /run/secrets/ for secrets." }, "uid": { "type": "string", "description": "UID of the file in the container. Default is 0 (root)." }, "gid": { "type": "string", "description": "GID of the file in the container. Default is 0 (root)." }, "mode": { "type": ["number", "string"], "description": "File permission mode inside the container, in octal. Default is 0444 for configs and 0400 for secrets." } }, "additionalProperties": false, "patternProperties": {"^x-": {}} } ] } }, "ulimits": { "type": "object", "description": "Container ulimit options, controlling resource limits for processes inside the container.", "patternProperties": { "^[a-z]+$": { "oneOf": [ { "type": ["integer", "string"], "description": "Single value for both soft and hard limits." }, { "type": "object", "description": "Separate soft and hard limits.", "properties": { "hard": { "type": ["integer", "string"], "description": "Hard limit for the ulimit type. This is the maximum allowed value." }, "soft": { "type": ["integer", "string"], "description": "Soft limit for the ulimit type. This is the value that's actually enforced." } }, "required": ["soft", "hard"], "additionalProperties": false, "patternProperties": {"^x-": {}} } ] } } } } } ================================================ FILE: frontend/runfile.toml ================================================ [dev-frontend] alias = "df" description = "starts the frontend in dev mode" cmd = "yarn dev" [build-frontend] alias = "bf" description = "generates fresh ts client and builds the frontend" cmd = "yarn build" after = "gen-client" ================================================ FILE: frontend/src/components/alert/details.tsx ================================================ import { ResourceLink } from "@components/resources/common"; import { useRead } from "@lib/hooks"; import { UsableResource } from "@types"; import { Button } from "@ui/button"; import { Dialog, DialogContent, DialogHeader, DialogTrigger, } from "@ui/dialog"; import { useState } from "react"; import { AlertLevel } from "."; import { fmt_date_with_minutes } from "@lib/formatting"; import { DialogDescription } from "@radix-ui/react-dialog"; import { alert_level_intention, text_color_class_by_intention, } from "@lib/color"; import { MonacoEditor } from "@components/monaco"; import { Types } from "komodo_client"; export const AlertDetailsDialog = ({ id }: { id: string }) => { const [open, set] = useState(false); const alert = useRead("GetAlert", { id }).data; return ( set(false)} /> ); }; export const AlertDetailsDialogContent = ({ alert, onClose, }: { alert: Types.Alert | undefined; onClose: () => void; }) => ( <> {alert && ( {alert && ( <> {alert && (
{fmt_date_with_minutes(new Date(alert.ts))}
)}
{/** Alert type */}
type:
{" "} {alert.data.type}
{/** Resolved */}
status:
{" "}
{alert.resolved ? "RESOLVED" : "OPEN"}
{/** Level */}
level:
{/** Alert data */}
)}
)} ); ================================================ FILE: frontend/src/components/alert/index.tsx ================================================ import { Section } from "@components/layouts"; import { alert_level_intention } from "@lib/color"; import { useRead, useLocalStorage } from "@lib/hooks"; import { Types } from "komodo_client"; import { Button } from "@ui/button"; import { AlertTriangle } from "lucide-react"; import { AlertsTable } from "./table"; import { StatusBadge } from "@components/util"; export const OpenAlerts = () => { const [open, setOpen] = useLocalStorage("open-alerts-v0", true); const alerts = useRead("ListAlerts", { query: { resolved: false } }).data ?.alerts; if (!alerts || alerts.length === 0) return null; return (
} actions={ } > {open && }
); }; export const AlertLevel = ({ level, }: { level: Types.SeverityLevel | undefined; }) => { if (!level) return null; return ; }; ================================================ FILE: frontend/src/components/alert/table.tsx ================================================ import { Types } from "komodo_client"; import { DataTable } from "@ui/data-table"; import { AlertLevel } from "."; import { AlertDetailsDialog } from "./details"; import { UsableResource } from "@types"; import { ResourceLink } from "@components/resources/common"; import { alert_level_intention, text_color_class_by_intention, } from "@lib/color"; export const AlertsTable = ({ alerts, showResolved, }: { alerts: Types.Alert[]; showResolved?: boolean; }) => { return ( row.original._id?.$oid && ( ), }, { header: "Resource", cell: ({ row }) => { const type = row.original.target.type as UsableResource; return ; }, }, showResolved && { header: "Status", cell: ({ row }) => { return (
{row.original.resolved ? "RESOLVED" : "OPEN"}
); }, }, { header: "Level", cell: ({ row }) => , }, { header: "Alert Type", accessorKey: "data.type", }, ]} /> ); }; ================================================ FILE: frontend/src/components/config/env_vars.tsx ================================================ import { SecretSelector } from "@components/config/util"; import { useRead } from "@lib/hooks"; import { Types } from "komodo_client"; import { useToast } from "@ui/use-toast"; export const SecretsSearch = ({ server, }: { /// eg server id server?: string; }) => { if (server) return ; return ; }; const SecretsNoServer = () => { const variables = useRead("ListVariables", {}).data ?? []; const secrets = useRead("ListSecrets", {}).data ?? []; return ; }; const SecretsWithServer = ({ server, }: { /// eg server id server: string; }) => { const variables = useRead("ListVariables", {}).data ?? []; const secrets = useRead("ListSecrets", { target: { type: "Server", id: server } }).data ?? []; return ; }; const SecretsView = ({ variables, secrets, }: { variables: Types.ListVariablesResponse; secrets: Types.ListSecretsResponse; }) => { const { toast } = useToast(); if (variables.length === 0 && secrets.length === 0) return; return (
{variables.length > 0 && ( v.name)} onSelect={(variable) => { if (!variable) return; navigator.clipboard.writeText("[[" + variable + "]]"); toast({ title: "Copied selection" }); }} disabled={false} side="right" align="start" /> )} {secrets.length > 0 && ( { if (!secret) return; navigator.clipboard.writeText("[[" + secret + "]]"); toast({ title: "Copied selection" }); }} disabled={false} side="right" align="start" /> )}
); }; ================================================ FILE: frontend/src/components/config/index.tsx ================================================ import { ConfigInput, ConfigSwitch, ConfirmUpdate, } from "@components/config/util"; import { Section } from "@components/layouts"; import { MonacoLanguage } from "@components/monaco"; import { Types } from "komodo_client"; import { cn } from "@lib/utils"; import { Button } from "@ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@ui/select"; import { AlertTriangle, History, Settings } from "lucide-react"; import { Fragment, ReactNode, SetStateAction } from "react"; const keys = >(obj: T) => Object.keys(obj) as Array; export const ConfigLayout = < T extends Types.Resource["config"], >({ original, update, children, disabled, onConfirm, onReset, selector, titleOther, file_contents_language, }: { original: T; update: Partial; children: ReactNode; disabled: boolean; onConfirm: () => void; onReset: () => void; selector?: ReactNode; titleOther?: ReactNode; file_contents_language?: MonacoLanguage; }) => { const titleProps = titleOther ? { titleOther } : { title: "Config", icon: }; const changesMade = Object.keys(update).length ? true : false; return (
{changesMade && (
Unsaved changes
)} {selector} {changesMade && ( <> onConfirm()} disabled={disabled} file_contents_language={file_contents_language} key_listener /> )}