diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..cb97c825 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### Added + +- Added `bin/deploy temporary` for disposable public Azure Container Apps environments from pull requests, branches, or immutable git hashes, including readiness checks, persisted cleanup state, and non-production resource guardrails. + +### Changed + +- Renamed temporary deployment environment variables to the `TEMPORARY_DEPLOYMENT_*` namespace to make the public configuration contract explicit. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index fac0beb0..2ac94b42 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,8 +38,15 @@ RUN npm clean-install COPY web ./ -# Switching to production mode for build environment. +ARG VITE_TEMPORARY_HOST_SUFFIX +ARG VITE_TEMPORARY_AUTH0_DOMAIN +ARG VITE_TEMPORARY_AUTH0_AUDIENCE +ARG VITE_TEMPORARY_AUTH0_CLIENT_ID ENV NODE_ENV=production +ENV VITE_TEMPORARY_HOST_SUFFIX=${VITE_TEMPORARY_HOST_SUFFIX} +ENV VITE_TEMPORARY_AUTH0_DOMAIN=${VITE_TEMPORARY_AUTH0_DOMAIN} +ENV VITE_TEMPORARY_AUTH0_AUDIENCE=${VITE_TEMPORARY_AUTH0_AUDIENCE} +ENV VITE_TEMPORARY_AUTH0_CLIENT_ID=${VITE_TEMPORARY_AUTH0_CLIENT_ID} RUN npm run build # Stage 3 - production setup diff --git a/Gemfile b/Gemfile index cb9afe04..dbb2cbe5 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,6 @@ source "https://rubygems.org" -gem "local-development-gateway", "~> 0.1" +gem "local-development-gateway", "~> 0.1.2" gem "json", ">= 2.7.2" gem "rdoc" diff --git a/Gemfile.lock b/Gemfile.lock index 8e088216..84dba03a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -4,7 +4,7 @@ GEM erb (6.0.6) json (2.21.1) language_server-protocol (3.17.0.6) - local-development-gateway (0.1.0) + local-development-gateway (0.1.2) logger (1.7.0) prettier_print (1.2.1) prism (1.9.0) @@ -31,7 +31,7 @@ PLATFORMS DEPENDENCIES json (>= 2.7.2) - local-development-gateway (~> 0.1) + local-development-gateway (~> 0.1.2) prettier_print (= 1.2.1) rdoc ruby-lsp (= 0.26.9) @@ -41,7 +41,7 @@ CHECKSUMS erb (6.0.6) sha256=a9b24986700f5bf127c4f297c5403c3ca41b83b0a316c0cd09a096b56e644ae5 json (2.21.1) sha256=13a43df75d95641443f5702dff350f237164a9d811ff0f2c2800d4d980220583 language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 - local-development-gateway (0.1.0) sha256=0f443837bbc4900ae0f3ea383c2055b27f741e4c81a57d03d0e5a8e9bdc6bff7 + local-development-gateway (0.1.2) sha256=51e94cb3c614d4c5a310e43d7bb27115efa0873090df8f31cf0e9b497f340082 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 prettier_print (1.2.1) sha256=a72838b5f23facff21f90a5423cdcdda19e4271092b41f4ea7f50b83929e6ff9 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 diff --git a/api/src/config.ts b/api/src/config.ts index 4b14e9bf..08ed8782 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -44,12 +44,11 @@ export const RUN_SCHEDULER = process.env.RUN_SCHEDULER || "false" // ==================== // Authentication & Authorization // ==================== - // Auth0 Configuration export const AUTH0_DOMAIN = stripTrailingSlash(process.env.VITE_AUTH0_DOMAIN || "") export const AUTH0_AUDIENCE = process.env.VITE_AUTH0_AUDIENCE export const AUTH0_REDIRECT = process.env.VITE_AUTH0_REDIRECT || process.env.FRONTEND_URL || "" - +export const QA_ENVIRONMENT = process.env.QA_ENVIRONMENT === "true" // ==================== // Database & Cache Configuration // ==================== diff --git a/bin/README.md b/bin/README.md index 2bbce1f6..a52236a2 100644 --- a/bin/README.md +++ b/bin/README.md @@ -13,3 +13,99 @@ dev sqlcmd -i ./data/funding_submission_lines.sql assuming the file is located at `/db/data/funding_submission_lines.sql` Note that the `dev` command uses the `db` service, and so only has access to folders under the top-level `db` directory. + +## Temporary deployments + +`bin/deploy temporary` builds the selected immutable commit and provisions a +disposable public Azure Container Apps environment. + +```bash +bin/deploy temporary --pr 51 --ttl-hours 4 +bin/deploy temporary --branch feature/example --ttl-hours 4 +bin/deploy temporary --git-hash 0123456789abcdef0123456789abcdef01234567 +bin/deploy temporary status --pr 51 +bin/deploy temporary logs --pr 51 --follow +bin/deploy temporary down --pr 51 +bin/deploy temporary down --all --expired --yes +``` + +`bin/deploy ephemeral` is an alias for `bin/deploy temporary`. The environment +URL is `https://tk-temporary-.`. Each +deployment has isolated SQL Server, Redis, MailDev, and blob storage. + +State is stored in `TEMPORARY_DEPLOYMENT_STATE_DIRECTORY` (default: +`~/.traditional-knowledge-temporary`) so failed cleanup can be retried. TTL is +recorded; there is no background scheduler. + +Before use, configure these local-only `TEMPORARY_DEPLOYMENT_*` variables: + +- `TEMPORARY_DEPLOYMENT_RESOURCE_GROUP`, `TEMPORARY_DEPLOYMENT_CONTAINER_APPS_ENVIRONMENT`, + `TEMPORARY_DEPLOYMENT_CONTAINER_REGISTRY_SERVER`, `TEMPORARY_DEPLOYMENT_AZURE_SUBSCRIPTION_ID` +- `TEMPORARY_DEPLOYMENT_DOMAIN_SUFFIX`, with an ACA custom-domain suffix and wildcard + certificate +- `TEMPORARY_DEPLOYMENT_STORAGE_ACCOUNT`, `TEMPORARY_DEPLOYMENT_BLOB_CONNECTION_STRING`, + `TEMPORARY_DEPLOYMENT_BLOB_CONTAINER` +- `TEMPORARY_DEPLOYMENT_AUTH0_MANAGEMENT_TOKEN`, + `TEMPORARY_DEPLOYMENT_AUTH0_ALLOWED_HOST_SUFFIX` + +The command defaults to the shared UAT Auth0 domain, audience, and client ID. +Set the `TEMPORARY_DEPLOYMENT_AUTH0_*` overrides only when using another compatible +Auth0 application. + +Use the `artzzpr-sub` subscription (or its ID), not `wrpzzpr-sub`. The command +resolves the configured subscription before Azure REST calls. A developer with +an eligible Azure role can use PIM self-activation; CI should use an +OIDC/service-principal identity with scoped write access. + +Configure the Auth0 application with these wildcard values, replacing +`` with `TEMPORARY_DEPLOYMENT_DOMAIN_SUFFIX`: + +```text +Allowed Callback URLs: https://*./callback +Allowed Logout URLs: https://*. +Allowed Web Origins: https://*. +``` + +The resource group, ACA environment, storage account, and ACR must all carry +`traditional-knowledge-temporary=true`. Do not use production credentials or +resources. + +### GitHub Actions + +The CLI is workflow-safe without a repository-specific action. Authenticate +Azure with OIDC and grant the workflow identity scoped access to the temporary +resource group; do not rely on interactive PIM in CI: + +```yaml +permissions: + contents: read + id-token: write + +env: + TEMPORARY_DEPLOYMENT_RESOURCE_GROUP: ${{ vars.TEMPORARY_DEPLOYMENT_RESOURCE_GROUP }} + TEMPORARY_DEPLOYMENT_CONTAINER_APPS_ENVIRONMENT: ${{ vars.TEMPORARY_DEPLOYMENT_CONTAINER_APPS_ENVIRONMENT }} + TEMPORARY_DEPLOYMENT_CONTAINER_REGISTRY_SERVER: ${{ vars.TEMPORARY_DEPLOYMENT_CONTAINER_REGISTRY_SERVER }} + TEMPORARY_DEPLOYMENT_AZURE_SUBSCRIPTION_ID: ${{ vars.TEMPORARY_DEPLOYMENT_AZURE_SUBSCRIPTION_ID }} + TEMPORARY_DEPLOYMENT_DOMAIN_SUFFIX: ${{ vars.TEMPORARY_DEPLOYMENT_DOMAIN_SUFFIX }} + TEMPORARY_DEPLOYMENT_STORAGE_ACCOUNT: ${{ vars.TEMPORARY_DEPLOYMENT_STORAGE_ACCOUNT }} + TEMPORARY_DEPLOYMENT_BLOB_CONTAINER: ${{ vars.TEMPORARY_DEPLOYMENT_BLOB_CONTAINER }} + TEMPORARY_DEPLOYMENT_AUTH0_ALLOWED_HOST_SUFFIX: ${{ vars.TEMPORARY_DEPLOYMENT_AUTH0_ALLOWED_HOST_SUFFIX }} + TEMPORARY_DEPLOYMENT_BLOB_CONNECTION_STRING: ${{ secrets.TEMPORARY_DEPLOYMENT_BLOB_CONNECTION_STRING }} + TEMPORARY_DEPLOYMENT_AUTH0_MANAGEMENT_TOKEN: ${{ secrets.TEMPORARY_DEPLOYMENT_AUTH0_MANAGEMENT_TOKEN }} + TEMPORARY_DEPLOYMENT_STATE_DIRECTORY: ${{ runner.temp }}/traditional-knowledge-temporary + GH_TOKEN: ${{ github.token }} + +steps: + - uses: actions/checkout@v4 + - uses: azure/login@v2 + with: + client-id: ${{ secrets.TEMPORARY_DEPLOYMENT_AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.TEMPORARY_DEPLOYMENT_AZURE_TENANT_ID }} + subscription-id: ${{ vars.TEMPORARY_DEPLOYMENT_AZURE_SUBSCRIPTION_ID }} + - run: bin/deploy temporary --pr "${{ github.event.pull_request.number }}" + - if: ${{ always() }} + run: bin/deploy temporary down --all --yes +``` + +Use `workflow_dispatch` or a trusted same-repository pull request workflow. +Never expose Azure or Auth0 secrets to untrusted fork code. diff --git a/bin/deploy b/bin/deploy new file mode 100755 index 00000000..aaed2605 --- /dev/null +++ b/bin/deploy @@ -0,0 +1,12 @@ +#!/usr/bin/env ruby + +require_relative "lib/deploy/all" + +if $PROGRAM_NAME == __FILE__ + begin + TraditionalKnowledgeTemporaryDeployment::Command.call(ARGV) + rescue ArgumentError => error + warn error.message + exit 1 + end +end diff --git a/bin/dev b/bin/dev index 28140641..3a8badf6 100755 --- a/bin/dev +++ b/bin/dev @@ -21,7 +21,7 @@ class DevHelper "edit-pr" => :edit_pr, "bash-completions" => :bash_completions, "plantuml-to-png" => :plantuml_to_png, - "sqlcmd-query" => :sqlcmd_query, + "sqlcmd-query" => :sqlcmd_query } METHOD_TO_COMMAND = COMMAND_TO_METHOD.invert @@ -66,16 +66,28 @@ class DevHelper end def up(*args, **kwargs) - LocalDevelopmentGateway.ensure_running - OpenInEditorBridge.with_running do - compose(*%w[up --remove-orphans --force-recreate], *args, execution_mode: WAIT_FOR_PROCESS, gateway: true, **kwargs) + LocalDevelopmentGateway.with_running do + OpenInEditorBridge.with_running do + compose( + *%w[up --remove-orphans --force-recreate], + *args, + execution_mode: WAIT_FOR_PROCESS, + gateway: true, + **kwargs + ) + end end end def down(*args, **kwargs) - OpenInEditorBridge.with_running(ensure_running: false) do - if compose(*%w[down --remove-orphans], *args, execution_mode: WAIT_FOR_PROCESS, **kwargs) - LocalDevelopmentGateway.stop_if_unused + LocalDevelopmentGateway.with_running(ensure_running: false) do + OpenInEditorBridge.with_running(ensure_running: false) do + compose( + *%w[down --remove-orphans], + *args, + execution_mode: WAIT_FOR_PROCESS, + **kwargs + ) end end end @@ -100,7 +112,6 @@ class DevHelper run(*%w[api sh], *args, **kwargs) end - def web(*args, **kwargs) run(*%w[web], *args, **kwargs) end @@ -108,6 +119,14 @@ class DevHelper def api(*args, **kwargs) run(*%w[api], *args, **kwargs) end + def deploy(*args, **kwargs) + unless kwargs.empty? + raise ArgumentError, + "Unexpected options for deploy: #{kwargs.keys.join(", ")}" + end + + exec("ruby", File.join(project_root, "bin/deploy"), *args) + end def archiver(*args, **kwargs) run(*%w[archiver], *args, **kwargs) @@ -122,8 +141,12 @@ class DevHelper end def check_types(*args, **kwargs) - run(*%w[api npm run check-types], *args, execution_mode: WAIT_FOR_PROCESS, **kwargs) && - run(*%w[web npm run check-types], *args, **kwargs) + run( + *%w[api npm run check-types], + *args, + execution_mode: WAIT_FOR_PROCESS, + **kwargs + ) && run(*%w[web npm run check-types], *args, **kwargs) end def test(*args, **kwargs) @@ -149,7 +172,12 @@ class DevHelper def knex(*args, **kwargs) if RUBY_PLATFORM =~ /linux/ - run(*%w[api npm run knex], *args, execution_mode: WAIT_FOR_PROCESS, **kwargs) + run( + *%w[api npm run knex], + *args, + execution_mode: WAIT_FOR_PROCESS, + **kwargs + ) file_or_directory = "api/src/db" exit(0) unless take_over_needed?(file_or_directory) @@ -179,20 +207,16 @@ class DevHelper # # For direct queries and queries with special characters, see sqlcmd_query method. def sqlcmd(*args, **kwargs) # rubocop:disable Metrics/MethodLength - environment_hoist = ENV.slice(*%w[ - DB_DATABASE - DB_USERNAME - DB_HOST - DB_PORT - ]).map { |key, value| - "export #{key}=\"#{value}\"" - }.join("\n ") + environment_hoist = + ENV + .slice(*%w[DB_DATABASE DB_USERNAME DB_HOST DB_PORT]) + .map { |key, value| "export #{key}=\"#{value}\"" } + .join("\n ") tty_argument = "" tty_argument = "-T" if kwargs.fetch(:enable_tty, false) - compose( - <<~BASH, + compose(<<~BASH, **kwargs) exec #{tty_argument} db sh -c ' #{environment_hoist} /opt/mssql-tools18/bin/sqlcmd \ @@ -204,8 +228,6 @@ class DevHelper -I #{args.join(" ")} ' BASH - **kwargs - ) end ## @@ -278,7 +300,9 @@ class DevHelper end def changes_from(branch_or_ref_or_commit_hash = "HEAD") - system("git --no-pager log origin/main..#{branch_or_ref_or_commit_hash} --patch") + system( + "git --no-pager log origin/main..#{branch_or_ref_or_commit_hash} --patch" + ) end ## @@ -286,13 +310,19 @@ class DevHelper # Example: # dev edit-pr https://github.com/icefoganalytics/travel-authorization/pull/218 def edit_pr(pull_request_url, *args, **kwargs) - PullRequestEditor.edit_pull_request_description(pull_request_url, *args, **kwargs) + PullRequestEditor.edit_pull_request_description( + pull_request_url, + *args, + **kwargs + ) exit(0) end def ownit(*args, **kwargs) file_or_directory = args[0] - raise ScriptError, "Must provide a file or directory path." if file_or_directory.nil? + if file_or_directory.nil? + raise ScriptError, "Must provide a file or directory path." + end if RUBY_PLATFORM =~ /linux/ puts "Take ownership of the file or directory? #{file_or_directory}" @@ -307,7 +337,8 @@ class DevHelper def bash_completions all_public_methods = public_methods(false).sort all_public_methods.delete(:call) - completions = all_public_methods.map { |word| METHOD_TO_COMMAND.fetch(word, word) } + completions = + all_public_methods.map { |word| METHOD_TO_COMMAND.fetch(word, word) } puts completions end @@ -375,9 +406,15 @@ class DevHelper if RUBY_PLATFORM =~ /linux/ linux_override = "docker-compose.#{environment}.linux.yml" linux_override_path = File.join(project_root, linux_override) - compose_file_arguments.concat(["-f", linux_override]) if File.exist?(linux_override_path) + if File.exist?(linux_override_path) + compose_file_arguments.concat(["-f", linux_override]) + end + end + if gateway && environment == "development" + compose_file_arguments.concat( + %w[-f docker-compose.development.gateway.yml] + ) end - compose_file_arguments.concat(["-f", "docker-compose.development.gateway.yml"]) if gateway && environment == "development" compose_arguments = ["docker", "compose", *compose_file_arguments, *args] docker_command = compose_arguments.join(" ") @@ -404,7 +441,7 @@ class DevHelper def dynamic_environment_variables variables = [] - if RUBY_PLATFORM =~ /linux/ && user_id == '1000' && group_id == '1000' + if RUBY_PLATFORM =~ /linux/ && user_id == "1000" && group_id == "1000" variables << "HOST_USER_ID=#{Shellwords.escape(user_id)}" variables << "HOST_GROUP_ID=#{Shellwords.escape(group_id)}" end @@ -415,15 +452,18 @@ class DevHelper def gateway_hostname checkout_name = File.basename(project_root).downcase.gsub(/[^a-z0-9]+/, "-") - return "traditional-knowledge.localhost" if checkout_name == "traditional-knowledge" + if checkout_name == "traditional-knowledge" + return "traditional-knowledge.localhost" + end "#{checkout_name}.traditional-knowledge.localhost" end - def take_over_needed?(file_or_directory) files_owned_by_others = - system("find #{file_or_directory} -not -user #{user_id} -print -quit | grep -q .") + system( + "find #{file_or_directory} -not -user #{user_id} -print -quit | grep -q ." + ) files_owned_by_others end @@ -453,7 +493,10 @@ class DevHelper next unless arg.start_with?(prefix) if arg.start_with?(src_path_prefix) - args[index] = arg.gsub(/^#{src_path_regex}/, "tests/").gsub(/\.ts$/, ".test.ts") + args[index] = arg.gsub(/^#{src_path_regex}/, "tests/").gsub( + /\.ts$/, + ".test.ts" + ) else args[index] = arg.gsub(/^#{test_path_regex}/, "") end @@ -461,7 +504,9 @@ class DevHelper reformatted = true end - puts "Reformatted path filter from project relative to service relative for vitest." if reformatted + if reformatted + puts "Reformatted path filter from project relative to service relative for vitest." + end end ## @@ -483,7 +528,11 @@ class DevHelper end def service_running?(container_name) - ps(*%w[-q --status=running], execution_mode: WAIT_FOR_PROCESS, slient: true) != "" + ps( + *%w[-q --status=running], + execution_mode: WAIT_FOR_PROCESS, + slient: true + ) != "" end end diff --git a/bin/lib/deploy/all.rb b/bin/lib/deploy/all.rb new file mode 100644 index 00000000..0c0d689e --- /dev/null +++ b/bin/lib/deploy/all.rb @@ -0,0 +1,15 @@ +require_relative "temporary/constants" +require_relative "temporary/support/errors" +require_relative "temporary/support/state_store" +require_relative "temporary/support/source" +require_relative "temporary/support/config" +require_relative "temporary/support/github" +require_relative "temporary/support/auth0" +require_relative "temporary/azure" +require_relative "temporary/azure/access" +require_relative "temporary/azure/resources" +require_relative "temporary/azure/deployment_body" +require_relative "temporary/support/worktree" +require_relative "temporary/support/service" +require_relative "temporary/application" +require_relative "temporary/command" diff --git a/bin/lib/deploy/temporary/application.rb b/bin/lib/deploy/temporary/application.rb new file mode 100644 index 00000000..6b997b35 --- /dev/null +++ b/bin/lib/deploy/temporary/application.rb @@ -0,0 +1,231 @@ +module TraditionalKnowledgeTemporaryDeployment + class Application + def initialize( + runner: Runner.new, + environment: ENV, + root: File.expand_path("../..", __dir__) + ) + @runner = runner + @config = Config.new(environment) + @root = root + @store = StateStore.new(@config.state_directory) + end + + def up(source, ttl_hours) + @config.validate! + github = GitHub.new(@runner, @config.repository) + sha = github.commit(source) + image_tag = "temporary-#{source.identifier}-#{sha[0, 12]}" + expires_at = (Time.now.utc + ttl_hours * 3600).iso8601 + environment_id = @config.environment_id(source) + existing_state = @store.find(environment_id) + preserve_existing_resources = preserve_existing_resources?(existing_state) + azure = Azure.new(@runner, @config) + ref = github.fetch_commit(source, sha, @root) + begin + azure.validate_environment! + Worktree + .new(@runner, @root) + .with(sha) do |worktree| + puts "Building #{image_tag} from #{sha}..." + azure.build_image( + worktree, + image_tag, + sha, + ".#{@config.dns_suffix}" + ) + end + @store.save( + "environment_id" => environment_id, + "app_name" => @config.app_name(source), + "source_kind" => source.kind.to_s, + "source_value" => source.value, + "sha" => sha, + "image_tag" => image_tag, + "previous_image_tag" => + ( + if existing_state && existing_state["image_tag"] != image_tag + existing_state["image_tag"] + else + nil + end + ), + "blob_container" => @config.blob_container(source), + "public_url" => @config.public_url(source), + "expires_at" => expires_at, + "resource_group" => @config.resource_group, + "aca_environment" => @config.aca_environment, + "subscription_id" => azure.subscription_id, + "phase" => "provisioning", + "preserve_existing_resources" => preserve_existing_resources, + "created_at" => Time.now.utc.iso8601 + ) + state = + azure.deploy( + source, + sha, + image_tag, + expires_at, + provision_blob_container: !preserve_existing_resources, + remove_app_on_failure: !preserve_existing_resources + ) + state["previous_image_tag"] = ( + if existing_state && existing_state["image_tag"] != image_tag + existing_state["image_tag"] + else + nil + end + ) + state["preserve_existing_resources"] = preserve_existing_resources + @store.save(state) + if existing_state && existing_state["image_tag"] != image_tag + begin + azure.delete_image(existing_state.fetch("image_tag")) + state.delete("previous_image_tag") + @store.save(state) + rescue Error => error + warn "Old image retained (cleanup can be retried manually): #{error.message}" + end + end + puts "temporary environment ready: #{state.fetch("public_url")}" + puts "Environment: #{state.fetch("environment_id")} App: #{state.fetch("app_name")}" + puts "Source: #{source.label} SHA: #{sha} Expires: #{expires_at}" + puts "Teardown: bin/deploy temporary down --#{source.kind.to_s.tr("_", "-")} #{source.value}" + ensure + github.remove_commit_ref(ref, @root) if ref + end + end + + def list + states = @store.all + if states.empty? + puts "No temporary environments." + return + end + states.each do |state| + puts "#{state.fetch("environment_id")} #{state.fetch("public_url")} #{state.fetch("sha")} expires #{state.fetch("expires_at")}" + end + end + + def status(source) + state = scoped_state_for(source) + puts "#{state.fetch("environment_id")}: #{Azure.new(@runner, @config).status(state)}" + puts "URL: #{state.fetch("public_url")}" + end + + def logs(source, follow) + state = scoped_state_for(source) + puts Azure.new(@runner, @config).logs(state, follow:) + end + + private + + def preserve_existing_resources?(state) + return false unless state + + state["phase"] == "ready" || state["preserve_existing_resources"] == true + end + + def scoped_state_for(source) + state = state_for(source) + @config.validate_cleanup! + azure = Azure.new(@runner, @config) + azure.validate_environment! + safety_check!(state, azure) + state + end + + def state_for(source, reconstruct: false) + state = @store.find(@config.environment_id(source)) + return state if state + return reconstruct_state(source) if reconstruct + + raise Error, "No temporary deployment state for #{source.label}." + end + + def reconstruct_state(source) + azure = Azure.new(@runner, @config) + { + "environment_id" => @config.environment_id(source), + "app_name" => @config.app_name(source), + "source_kind" => source.kind.to_s, + "source_value" => source.value, + "blob_container" => @config.blob_container(source), + "resource_group" => @config.resource_group, + "aca_environment" => @config.aca_environment, + "subscription_id" => azure.subscription_id, + "phase" => "ready" + } + end + + def safety_check!(state, azure) + expected_blob_container = + @config.blob_container( + Source.parse(state.fetch("source_kind"), state.fetch("source_value")) + ) + unless state.fetch("blob_container") == expected_blob_container + raise Error, "Refusing to delete an unexpected temporary blob container" + end + + unless state.fetch("app_name").start_with?(APP_PREFIX) && + @config.state_environment_matches?(state) && + state.fetch("subscription_id") == azure.subscription_id + raise Error, + "Refusing to delete an environment outside the configured temporary scope" + end + + azure.verify_app_scope!(state) + end + + public + + def down(source, all: false, expired: false, confirmed: false) + raise Error, "down --all requires --yes" if all && !confirmed + raise Error, "down --expired requires --all" if expired && !all + + @config.validate_cleanup! + Azure.new(@runner, @config).validate_environment! + states = + if all + all_states = @store.all + if expired + all_states.select do |state| + Time.iso8601(state.fetch("expires_at")) <= Time.now.utc + end + else + all_states + end + else + [state_for(source, reconstruct: true)] + end + if states.empty? + puts( + if expired + "No expired temporary environments." + else + "No temporary environments." + end + ) + return + end + + failures = [] + states.each do |state| + azure = Azure.new(@runner, @config) + begin + safety_check!(state, azure) + azure.delete(state) + @store.delete(state.fetch("environment_id")) + puts "Deleted #{state.fetch("environment_id")}." + rescue Error => error + failures << error + warn "Could not delete #{state.fetch("environment_id")} (state retained): #{error.message}" + end + end + unless failures.empty? + raise Error, + "#{failures.length} temporary environment deletion(s) failed" + end + end + end +end diff --git a/bin/lib/deploy/temporary/azure.rb b/bin/lib/deploy/temporary/azure.rb new file mode 100644 index 00000000..c9090750 --- /dev/null +++ b/bin/lib/deploy/temporary/azure.rb @@ -0,0 +1,430 @@ +module TraditionalKnowledgeTemporaryDeployment + class Azure + API_VERSION = "2024-03-01" + ACCESS_API_VERSION = "2020-10-01" + PERMISSIONS_API_VERSION = "2015-07-01" + ACCESS_WAIT_TIMEOUT_SECONDS = 420 + ACCESS_WAIT_INTERVAL_SECONDS = 3 + REQUIRED_ACCESS_ACTION = "Microsoft.App/containerApps/write" + PIM_AZURE_RESOURCE_ROLES_URL = + "https://entra.microsoft.com/?feature.msaljs=true" \ + "#view/Microsoft_Azure_PIMCommon/ActivationMenuBlade/~/azurerbac/provider/azurerbac" + + def initialize(runner, config) + @runner = runner + @config = config + end + + def subscription_id + @subscription_id ||= + begin + configured_id = @config.subscription_id + resolved_id = + @runner.run( + "az", + "account", + "show", + "--subscription", + configured_id, + "--query", + "id", + "-o", + "tsv" + ).strip + if resolved_id.empty? + raise Error, + "Azure returned no subscription ID for #{configured_id.inspect}" + end + + resolved_id + end + end + + def validate_environment! + ensure_access! + verify_scope_tag( + "az", + "group", + "show", + "--name", + @config.resource_group, + "--subscription", + subscription_id + ) + verify_scope_tag( + "az", + "containerapp", + "env", + "show", + "--name", + @config.aca_environment, + "--resource-group", + @config.resource_group, + "--subscription", + subscription_id + ) + verify_scope_tag( + "az", + "storage", + "account", + "show", + "--name", + @config.storage_account, + "--resource-group", + @config.resource_group, + "--subscription", + subscription_id + ) + verify_scope_tag( + "az", + "acr", + "show", + "--name", + @config.acr_name, + "--subscription", + subscription_id + ) + suffix = + @runner.run( + "az", + "containerapp", + "env", + "show", + "--name", + @config.aca_environment, + "--resource-group", + @config.resource_group, + "--subscription", + subscription_id, + "--query", + "properties.customDomainConfiguration.dnsSuffix", + "-o", + "tsv" + ).strip + unless suffix == @config.dns_suffix + raise Error, + "ACA environment DNS suffix is #{suffix.inspect}; expected #{@config.dns_suffix.inspect}" + end + end + def verify_scope_tag(*command) + value = + @runner.run( + *command, + "--query", + "tags['#{@config.scope_tag}']", + "-o", + "tsv" + ).strip + return if value == "true" + + raise Error, + "Azure resource is not tagged #{SCOPE_TAG}=true: #{command.join(" ")}" + end + def verify_app_scope!(state) + begin + verify_scope_tag( + "az", + "containerapp", + "show", + "--name", + state.fetch("app_name"), + "--resource-group", + state.fetch("resource_group"), + "--subscription", + state.fetch("subscription_id") + ) + rescue CommandError => error + if error.output.match?(/not found|could not be found|ResourceNotFound/i) + return + end + + raise + end + managed_environment_id = + @runner.run( + "az", + "containerapp", + "env", + "show", + "--name", + @config.aca_environment, + "--resource-group", + @config.resource_group, + "--subscription", + state.fetch("subscription_id"), + "--query", + "id", + "-o", + "tsv" + ).strip + actual_environment_id = + @runner.run( + "az", + "containerapp", + "show", + "--name", + state.fetch("app_name"), + "--resource-group", + state.fetch("resource_group"), + "--subscription", + state.fetch("subscription_id"), + "--query", + "properties.managedEnvironmentId", + "-o", + "tsv" + ).strip + return if actual_environment_id == managed_environment_id + + raise Error, + "Refusing to delete an app outside the configured temporary Container Apps environment" + end + + def build_image(worktree, image_tag, sha, host_suffix) + @runner.run( + "az", + "acr", + "build", + "--registry", + @config.acr_name, + "--subscription", + subscription_id, + "--image", + "traditional-knowledge:#{image_tag}", + "--build-arg", + "RELEASE_TAG=#{image_tag}", + "--build-arg", + "GIT_COMMIT_HASH=#{sha}", + "--build-arg", + "VITE_TEMPORARY_HOST_SUFFIX=#{host_suffix}", + "--build-arg", + "VITE_TEMPORARY_AUTH0_DOMAIN=#{@config.auth0_domain}", + "--build-arg", + "VITE_TEMPORARY_AUTH0_AUDIENCE=#{@config.auth0_audience}", + "--build-arg", + "VITE_TEMPORARY_AUTH0_CLIENT_ID=#{@config.auth0_client_id}", + worktree + ) + end + + def deploy( + source, + sha, + image_tag, + expires_at, + provision_blob_container:, + remove_app_on_failure: + ) + app_name = @config.app_name(source) + verify_app_scope!( + "app_name" => app_name, + "resource_group" => @config.resource_group, + "subscription_id" => subscription_id + ) + acr_credentials = + JSON.parse( + @runner.run( + "az", + "acr", + "credential", + "show", + "--name", + @config.acr_name, + "--subscription", + subscription_id + ) + ) + managed_environment_id = + @runner.run( + "az", + "containerapp", + "env", + "show", + "--name", + @config.aca_environment, + "--resource-group", + @config.resource_group, + "--subscription", + subscription_id, + "--query", + "id", + "-o", + "tsv" + ).strip + location = + @runner.run( + "az", + "containerapp", + "env", + "show", + "--name", + @config.aca_environment, + "--resource-group", + @config.resource_group, + "--subscription", + subscription_id, + "--query", + "location", + "-o", + "tsv" + ).strip + acr_password = acr_credentials.fetch("passwords").first.fetch("value") + db_password = secure_database_password + blob_container = @config.blob_container(source) + blob_created = false + if provision_blob_container + create_blob_container(blob_container) + blob_created = true + end + app_put = false + begin + blob_connection_string = + container_sas_connection_string(blob_container, expires_at) + body = + deployment_body( + source:, + temporary_environment_id: @config.environment_id(source), + managed_environment_id:, + location:, + image_tag:, + sha:, + expires_at:, + acr_user: acr_credentials.fetch("username"), + acr_password:, + db_password:, + blob_container:, + blob_connection_string: + ) + put_app(app_name, body) + app_put = true + wait_for_provisioning(app_name) + wait_for_http(@config.public_url(source), app_name, sha) + rescue StandardError + if app_put && remove_app_on_failure + begin + delete_remote_app(app_name, @config.resource_group, subscription_id) + rescue Error => cleanup_error + warn "Container App cleanup failed (state must be retained): #{cleanup_error.message}" + end + end + if blob_created + begin + delete_blob_container(blob_container) + rescue Error => cleanup_error + warn "Blob container cleanup failed (state must be retained): #{cleanup_error.message}" + end + end + raise + end + { + "environment_id" => @config.environment_id(source), + "app_name" => app_name, + "source_kind" => source.kind.to_s, + "source_value" => source.value, + "sha" => sha, + "image_tag" => image_tag, + "blob_container" => blob_container, + "public_url" => @config.public_url(source), + "expires_at" => expires_at, + "resource_group" => @config.resource_group, + "aca_environment" => @config.aca_environment, + "subscription_id" => subscription_id, + "phase" => "ready", + "created_at" => Time.now.utc.iso8601 + } + end + + def delete(state) + delete_remote_app( + state.fetch("app_name"), + state.fetch("resource_group"), + state.fetch("subscription_id") + ) + delete_blob_container(state.fetch("blob_container")) + delete_images_for_source( + Source.parse(state.fetch("source_kind"), state.fetch("source_value")) + ) + end + + def delete_images_for_source(source) + tags = + JSON.parse( + @runner.run( + "az", + "acr", + "repository", + "show-tags", + "--name", + @config.acr_name, + "--repository", + "traditional-knowledge", + "--subscription", + subscription_id, + "-o", + "json" + ) + ) + prefix = "temporary-#{source.identifier}-" + tags + .select { |tag| tag.start_with?(prefix) } + .each { |tag| delete_image(tag) } + rescue JSON::ParserError => error + raise Error, + "Azure returned invalid temporary image tags: #{error.message}" + end + + def delete_image(image_tag) + @runner.run( + "az", + "acr", + "repository", + "delete", + "--name", + @config.acr_name, + "--subscription", + subscription_id, + "--image", + "traditional-knowledge:#{image_tag}", + "--yes" + ) + rescue CommandError => error + raise unless error.output.match?(/not found|does not exist/i) + end + + def status(state) + @runner.run( + "az", + "containerapp", + "show", + "--name", + state.fetch("app_name"), + "--resource-group", + state.fetch("resource_group"), + "--subscription", + state.fetch("subscription_id"), + "--query", + "properties.provisioningState", + "-o", + "tsv" + ).strip + end + + def logs(state, follow: false) + command = [ + "az", + "containerapp", + "logs", + "show", + "--name", + state.fetch("app_name"), + "--resource-group", + state.fetch("resource_group"), + "--subscription", + state.fetch("subscription_id"), + "--container", + "web" + ] + command << "--follow" if follow + follow ? @runner.stream(*command) : @runner.run(*command) + end + end +end diff --git a/bin/lib/deploy/temporary/azure/access.rb b/bin/lib/deploy/temporary/azure/access.rb new file mode 100644 index 00000000..571f8ae3 --- /dev/null +++ b/bin/lib/deploy/temporary/azure/access.rb @@ -0,0 +1,181 @@ +module TraditionalKnowledgeTemporaryDeployment + class Azure + private + + def ensure_access! + return if access_ready? + + eligibility = activation_eligibility + raise Error, manual_access_message unless eligibility + + @runner.run( + "az", + "rest", + "--method", + "put", + "--uri", + activation_uri(eligibility, SecureRandom.uuid), + "--body", + activation_request_body(eligibility) + ) + wait_for_access + rescue CommandError, JSON::ParserError, KeyError => error + raise Error, "#{manual_access_message}\n#{error.message}" + end + + def access_ready? + payload = + JSON.parse( + @runner.run("az", "rest", "--method", "get", "--uri", permissions_uri) + ) + permissions = payload.fetch("value", []) + permissions.any? { |permission| write_access_allowed?(permission) } + rescue CommandError, JSON::ParserError, KeyError + false + end + + def write_access_allowed?(permission) + allowed = + permission + .fetch("actions", []) + .any? { |pattern| action_matches?(pattern) } + blocked = + permission + .fetch("notActions", []) + .any? { |pattern| action_matches?(pattern) } + allowed && !blocked + end + + def action_matches?(pattern) + File.fnmatch?(pattern.downcase, REQUIRED_ACCESS_ACTION.downcase) + end + + def activation_eligibility + payload = + JSON.parse( + @runner.run("az", "rest", "--method", "get", "--uri", eligibility_uri) + ) + eligibilities = + payload + .fetch("value", []) + .select do |eligibility| + [preferred_scope, subscription_scope].include?( + eligibility_scope(eligibility) + ) && eligible_role_allows_write?(eligibility) + end + preferred_matches = + eligibilities.select do |eligibility| + eligibility_scope(eligibility) == preferred_scope + end + return preferred_matches.first if preferred_matches.one? + + subscription_matches = + eligibilities.select do |eligibility| + eligibility_scope(eligibility) == subscription_scope + end + return subscription_matches.first if subscription_matches.one? + + eligibilities.first if eligibilities.one? + end + + def eligible_role_allows_write?(eligibility) + payload = + JSON.parse( + @runner.run( + "az", + "rest", + "--method", + "get", + "--uri", + role_definition_uri(eligibility) + ) + ) + permissions = payload.fetch("properties").fetch("permissions", []) + permissions.any? { |permission| write_access_allowed?(permission) } + end + + def role_definition_uri(eligibility) + role_definition_id = + eligibility.fetch("properties").fetch("roleDefinitionId") + resource_id = + role_definition_id.delete_prefix("https://management.azure.com") + resource_id = + "#{subscription_scope}/providers/Microsoft.Authorization/roleDefinitions/#{resource_id}" unless resource_id.start_with?( + "/" + ) + "https://management.azure.com#{resource_id}?api-version=#{ACCESS_API_VERSION}" + end + + def eligibility_scope(eligibility) + eligibility.fetch("properties").fetch("scope") + end + + def activation_uri(eligibility, request_name) + "https://management.azure.com#{eligibility_scope(eligibility)}/providers/Microsoft.Authorization/" \ + "roleAssignmentScheduleRequests/#{request_name}?api-version=#{ACCESS_API_VERSION}" + end + + def activation_request_body(eligibility) + JSON.dump( + properties: { + principalId: eligibility.fetch("properties").fetch("principalId"), + requestType: "SelfActivate", + roleDefinitionId: + eligibility.fetch("properties").fetch("roleDefinitionId"), + linkedRoleEligibilityScheduleId: + eligibility + .fetch("properties") + .fetch("roleEligibilityScheduleId") + .split("/") + .last, + justification: + "Activating Traditional Knowledge temporary access for #{@config.resource_group}.", + scheduleInfo: { + startDateTime: Time.now.utc.iso8601, + expiration: { + type: "AfterDuration", + duration: "PT8H" + } + } + } + ) + end + + def wait_for_access + deadline = Time.now + ACCESS_WAIT_TIMEOUT_SECONDS + loop do + return if access_ready? + raise Error, manual_access_message if Time.now >= deadline + + sleep ACCESS_WAIT_INTERVAL_SECONDS + end + end + + def permissions_uri + "https://management.azure.com/subscriptions/#{subscription_id}" \ + "/resourceGroups/#{@config.resource_group}/providers/Microsoft.Authorization/permissions" \ + "?api-version=#{PERMISSIONS_API_VERSION}" + end + + def eligibility_uri + "https://management.azure.com#{subscription_scope}/providers/Microsoft.Authorization/" \ + "roleEligibilityScheduleInstances?$filter=asTarget()&api-version=#{ACCESS_API_VERSION}" + end + + def subscription_scope + "/subscriptions/#{subscription_id}" + end + + def preferred_scope + "#{subscription_scope}/resourceGroups/#{@config.resource_group}" + end + + def manual_access_message + <<~MESSAGE.chomp + Azure temporary access is not active for subscription #{subscription_id} and resource group #{@config.resource_group}. + Activate the eligible role in Azure PIM, then rerun the command: + #{PIM_AZURE_RESOURCE_ROLES_URL} + MESSAGE + end + end +end diff --git a/bin/lib/deploy/temporary/azure/deployment_body.rb b/bin/lib/deploy/temporary/azure/deployment_body.rb new file mode 100644 index 00000000..6d2566b4 --- /dev/null +++ b/bin/lib/deploy/temporary/azure/deployment_body.rb @@ -0,0 +1,142 @@ +module TraditionalKnowledgeTemporaryDeployment + class Azure + private + + def deployment_body( + source:, + temporary_environment_id:, + managed_environment_id:, + location:, + image_tag:, + sha:, + expires_at:, + acr_user:, + acr_password:, + db_password:, + blob_container:, + blob_connection_string: + ) + host = @config.public_url(source) + { + "location" => location, + "tags" => { + "traditional-knowledge-temporary" => "true", + "temporary-environment" => temporary_environment_id, + "temporary-source-sha" => sha, + "temporary-expires" => expires_at + }, + "properties" => { + "managedEnvironmentId" => managed_environment_id, + "configuration" => { + "activeRevisionsMode" => "Single", + "ingress" => { + "external" => true, + "targetPort" => 3000, + "transport" => "auto" + }, + "secrets" => [ + { "name" => "acr-password", "value" => acr_password }, + { "name" => "db-password", "value" => db_password }, + { "name" => "blob-connection", "value" => blob_connection_string } + ], + "registries" => [ + { + "server" => @config.acr_server, + "username" => acr_user, + "passwordSecretRef" => "acr-password" + } + ] + }, + "template" => { + "scale" => { + "minReplicas" => 1, + "maxReplicas" => 1 + }, + "containers" => [ + { + "name" => "db", + "image" => + "mcr.microsoft.com/mssql/server:2022-CU14-ubuntu-22.04", + "resources" => { + "cpu" => 1.0, + "memory" => "2.0Gi" + }, + "env" => [ + { "name" => "ACCEPT_EULA", "value" => "Y" }, + { + "name" => "MSSQL_SA_PASSWORD", + "secretRef" => "db-password" + } + ] + }, + { + "name" => "cache", + "image" => "bitnamilegacy/redis:8.0.2", + "resources" => { + "cpu" => 0.25, + "memory" => "0.5Gi" + }, + "env" => [ + { "name" => "ALLOW_EMPTY_PASSWORD", "value" => "yes" } + ] + }, + { + "name" => "mail", + "image" => "maildev/maildev:2.2.1", + "resources" => { + "cpu" => 0.25, + "memory" => "0.5Gi" + } + }, + { + "name" => "web", + "image" => + "#{@config.acr_server}/traditional-knowledge:#{image_tag}", + "resources" => { + "cpu" => 0.5, + "memory" => "1.0Gi" + }, + "env" => [ + { "name" => "NODE_ENV", "value" => "production" }, + { "name" => "FRONTEND_URL", "value" => host }, + { "name" => "DB_HOST", "value" => "localhost" }, + { "name" => "DB_PORT", "value" => "1433" }, + { "name" => "DB_USERNAME", "value" => "sa" }, + { "name" => "DB_PASSWORD", "secretRef" => "db-password" }, + { + "name" => "DB_DATABASE", + "value" => "traditional_knowledge_temporary" + }, + { + "name" => "DB_TRUST_SERVER_CERTIFICATE", + "value" => "true" + }, + { + "name" => "REDIS_CONNECTION_URL", + "value" => "redis://localhost:6379" + }, + { "name" => "MAIL_HOST", "value" => "localhost" }, + { "name" => "MAIL_PORT", "value" => "1025" }, + { "name" => "MAIL_SERVICE", "value" => "MailDev" }, + { + "name" => "BLOB_CONNECTION_STRING", + "secretRef" => "blob-connection" + }, + { "name" => "BLOB_CONTAINER", "value" => blob_container }, + { + "name" => "VITE_AUTH0_DOMAIN", + "value" => @config.auth0_domain + }, + { + "name" => "VITE_AUTH0_AUDIENCE", + "value" => @config.auth0_audience + } + ] + } + ] + } + } + } + end + end +end diff --git a/bin/lib/deploy/temporary/azure/resources.rb b/bin/lib/deploy/temporary/azure/resources.rb new file mode 100644 index 00000000..501a731b --- /dev/null +++ b/bin/lib/deploy/temporary/azure/resources.rb @@ -0,0 +1,198 @@ +module TraditionalKnowledgeTemporaryDeployment + class Azure + private + + def put_app(app_name, body) + Tempfile.create(%w[tk-temporary- .json]) do |file| + file.write(JSON.generate(body)) + file.flush + url = + "https://management.azure.com/subscriptions/#{subscription_id}/resourceGroups/#{@config.resource_group}/providers/Microsoft.App/containerApps/#{app_name}?api-version=#{API_VERSION}" + @runner.run( + "az", + "rest", + "--method", + "put", + "--url", + url, + "--body", + "@#{file.path}", + "--headers", + "Content-Type=application/json" + ) + end + end + + def create_blob_container(name) + @runner.run_with_environment( + { "AZURE_STORAGE_CONNECTION_STRING" => @config.blob_connection_string }, + "az", + "storage", + "container", + "create", + "--name", + name, + "--subscription", + subscription_id, + "--public-access", + "off" + ) + end + def container_sas_connection_string(name, expires_at) + sas = + @runner.run_with_environment( + { + "AZURE_STORAGE_CONNECTION_STRING" => @config.blob_connection_string + }, + "az", + "storage", + "container", + "generate-sas", + "--name", + name, + "--subscription", + subscription_id, + "--permissions", + "racwdl", + "--expiry", + expires_at, + "--https-only", + "-o", + "tsv" + ).strip + if sas.empty? + raise Error, "Azure did not return a SAS for blob container #{name}" + end + + endpoint = @config.blob_endpoint + if endpoint.to_s.empty? + raise Error, + "TEMPORARY_DEPLOYMENT_BLOB_CONNECTION_STRING has no BlobEndpoint" + end + + "BlobEndpoint=#{endpoint};SharedAccessSignature=#{sas.delete_prefix("?")}" + end + + def delete_blob_container(name) + @runner.run_with_environment( + { "AZURE_STORAGE_CONNECTION_STRING" => @config.blob_connection_string }, + "az", + "storage", + "container", + "delete", + "--name", + name, + "--subscription", + subscription_id, + "--fail-not-exist", + "false" + ) + rescue CommandError => error + raise unless error.output.match?(/not exist|not found/i) + end + + def delete_remote_app(app_name, resource_group, subscription_id) + @runner.run( + "az", + "containerapp", + "delete", + "--name", + app_name, + "--resource-group", + resource_group, + "--subscription", + subscription_id, + "--yes" + ) + rescue CommandError => error + unless error.output.match?( + /not found|could not be found|ResourceNotFound/i + ) + raise + end + end + + def secure_database_password + [ + SecureRandom.random_number(26) + 65, + SecureRandom.random_number(26) + 97, + SecureRandom.random_number(10) + 48, + [33, 35, 36, 37, 38, 42, 64].sample, + SecureRandom.alphanumeric(28) + ].map { |value| value.is_a?(Integer) ? value.chr : value } + .join + .chars + .shuffle + .join + end + + def wait_for_provisioning(app_name) + deadline = Time.now + @config.timeout_seconds + loop do + state = + @runner.run( + "az", + "containerapp", + "show", + "--name", + app_name, + "--resource-group", + @config.resource_group, + "--subscription", + subscription_id, + "--query", + "properties.provisioningState", + "-o", + "tsv" + ).strip + return if state == "Succeeded" + if state == "Failed" + raise Error, + "Azure provisioning failed for #{app_name}; inspect with `az containerapp logs show --name #{app_name} --resource-group #{@config.resource_group} --container web`" + end + if Time.now >= deadline + raise Error, + "Timed out waiting for Azure provisioning for #{app_name}; inspect with `az containerapp logs show --name #{app_name} --resource-group #{@config.resource_group} --container web`" + end + + sleep 5 + end + end + + def wait_for_http(url, app_name, expected_sha) + deadline = Time.now + @config.timeout_seconds + uri = URI("#{url}/_status") + loop do + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == "https" + http.open_timeout = @config.http_timeout_seconds + http.read_timeout = @config.http_timeout_seconds + response = http.get(uri.request_uri) + payload = + begin + JSON.parse(response.body) + rescue StandardError + {} + end + if response.is_a?(Net::HTTPSuccess) && + payload["GIT_COMMIT_HASH"] == expected_sha + return + end + if Time.now >= deadline + raise Error, + "Timed out waiting for #{url}/_status; inspect with `az containerapp logs show --name #{app_name} --resource-group #{@config.resource_group} --container web`" + end + + sleep 5 + rescue StandardError => error + raise error if error.is_a?(Error) + if Time.now >= deadline + raise Error, + "Timed out waiting for #{url}/_status; inspect with `az containerapp logs show --name #{app_name} --resource-group #{@config.resource_group} --container web`" + end + + sleep 5 + end + end + end +end diff --git a/bin/lib/deploy/temporary/command.rb b/bin/lib/deploy/temporary/command.rb new file mode 100644 index 00000000..0f144dd7 --- /dev/null +++ b/bin/lib/deploy/temporary/command.rb @@ -0,0 +1,133 @@ +module TraditionalKnowledgeTemporaryDeployment + ACTIONS = %w[up list status logs down].freeze + + class Command < Service + def initialize(argv) + @argv = argv + end + + def call + command = @argv.shift + return puts(help) if command.nil? || %w[help --help -h].include?(command) + unless %w[temporary ephemeral].include?(command) + raise Error, "Unknown deploy target: #{command}" + end + + call_temporary(@argv) + rescue OptionParser::ParseError, ArgumentError, Error => error + warn error.message + exit 1 + end + + private + + def call_temporary(argv) + action = ACTIONS.include?(argv.first) ? argv.shift : "up" + options = { + ttl_hours: 4, + follow: false, + all: false, + expired: false, + yes: false, + sources: [] + } + parser = + OptionParser.new do |option_parser| + option_parser.banner = + "Usage: bin/deploy temporary [up|list|status|logs|down] [source] [options]" + option_parser.on("--pr NUMBER", "Pull request number") do |value| + options[:sources] << [:pr, value] + end + option_parser.on("--branch NAME", "Git branch name") do |value| + options[:sources] << [:branch, value] + end + option_parser.on("--git-hash SHA", "Full git commit SHA") do |value| + options[:sources] << [:git_hash, value] + end + option_parser.on( + "--ttl-hours HOURS", + Integer, + "Environment lifetime (default: 4)" + ) { |value| options[:ttl_hours] = value } + option_parser.on("--follow", "Follow logs") { options[:follow] = true } + option_parser.on("--all", "Operate on all tracked environments") do + options[:all] = true + end + option_parser.on( + "--expired", + "With --all, operate only on expired environments" + ) { options[:expired] = true } + option_parser.on("--yes", "Confirm a destructive --all operation") do + options[:yes] = true + end + option_parser.on("--help", "Show help") do + puts option_parser + exit + end + end + parser.parse!(argv) + raise Error, "Unexpected argument(s): #{argv.join(" ")}" unless argv.empty? + unless options[:ttl_hours].positive? + raise Error, "--ttl-hours must be positive" + end + + application = Application.new + if options[:sources].length > 1 + raise Error, "Choose exactly one source selector" + end + source = options[:sources].first && Source.parse(*options[:sources].first) + if options[:all] && source + raise Error, "--all cannot be combined with a source selector" + end + case action + when "up" + unless source + raise Error, "temporary requires --pr, --branch, or --git-hash" + end + application.up(source, options[:ttl_hours]) + when "list" + application.list + when "status" + raise Error, "status requires --pr, --branch, or --git-hash" unless source + application.status(source) + when "logs" + raise Error, "logs requires --pr, --branch, or --git-hash" unless source + application.logs(source, options[:follow]) + when "down" + unless source || options[:all] + raise Error, "down requires a source or --all" + end + application.down( + source, + all: options[:all], + expired: options[:expired], + confirmed: options[:yes] + ) + end + end + + def help + <<~HELP + Deploy disposable public environments for Traditional Knowledge. + + Usage: + bin/deploy temporary --pr NUMBER [--ttl-hours HOURS] + bin/deploy temporary --branch NAME [--ttl-hours HOURS] + bin/deploy temporary --git-hash SHA [--ttl-hours HOURS] + bin/deploy temporary status --pr NUMBER + bin/deploy temporary status --branch NAME + bin/deploy temporary status --git-hash SHA + bin/deploy temporary logs --pr NUMBER [--follow] + bin/deploy temporary logs --branch NAME [--follow] + bin/deploy temporary logs --git-hash SHA [--follow] + bin/deploy temporary down --pr NUMBER + bin/deploy temporary down --branch NAME + bin/deploy temporary down --git-hash SHA + bin/deploy temporary down --all --expired --yes + + `ephemeral` is an alias for `temporary`. Configure TEMPORARY_DEPLOYMENT_* values + for a non-production Azure Container Apps environment. + HELP + end + end +end diff --git a/bin/lib/deploy/temporary/constants.rb b/bin/lib/deploy/temporary/constants.rb new file mode 100644 index 00000000..fa87f383 --- /dev/null +++ b/bin/lib/deploy/temporary/constants.rb @@ -0,0 +1,23 @@ +#!/usr/bin/env ruby + +require "fileutils" +require "digest" +require "json" +require "net/http" +require "open3" +require "optparse" +require "securerandom" +require "tempfile" +require "tmpdir" +require "time" +require "uri" + +module TraditionalKnowledgeTemporaryDeployment + REPOSITORY = "icefoganalytics/traditional-knowledge" + APP_PREFIX = "tk-temporary-" + SCOPE_TAG = "traditional-knowledge-temporary" + DEFAULT_STATE_DIR = File.expand_path("~/.traditional-knowledge-temporary") + UAT_AUTH0_DOMAIN = "https://yukon-staging.eu.auth0.com" + UAT_AUTH0_AUDIENCE = "generic-uat" + UAT_AUTH0_CLIENT_ID = "11878vWk1pmhwyVQwsr2m2zM3w3e912U" +end diff --git a/bin/lib/deploy/temporary/support/auth0.rb b/bin/lib/deploy/temporary/support/auth0.rb new file mode 100644 index 00000000..b1415abf --- /dev/null +++ b/bin/lib/deploy/temporary/support/auth0.rb @@ -0,0 +1,57 @@ +module TraditionalKnowledgeTemporaryDeployment + class Auth0 + def initialize(config, http_client: nil) + @config = config + @http_client = http_client + end + + def validate! + uri = + URI( + "#{@config.auth0_domain.sub(%r{/\z}, "")}/api/v2/clients/#{URI.encode_www_form_component(@config.auth0_client_id)}" + ) + response = + ( + if @http_client + @http_client.get(uri, @config.auth0_management_token) + else + request(uri) + end + ) + unless response.is_a?(Net::HTTPSuccess) + raise Error, + "Auth0 UAT client settings could not be read (HTTP #{response.code})" + end + + settings = JSON.parse(response.body) + expected_host = "https://*.#{@config.dns_suffix}" + missing = + { + "callbacks" => "#{expected_host}/callback", + "allowed_logout_urls" => expected_host, + "web_origins" => expected_host + }.filter_map do |key, expected| + "#{key}=#{expected}" unless settings.fetch(key, []).include?(expected) + end + return if missing.empty? + + raise Error, "Auth0 UAT client is missing: #{missing.join(", ")}" + rescue JSON::ParserError => error + raise Error, "Auth0 client settings were not valid JSON: #{error.message}" + rescue Timeout::Error, SocketError, Errno::ECONNREFUSED => error + raise Error, "Auth0 client settings could not be read: #{error.message}" + end + + private + + def request(uri) + http_request = Net::HTTP::Get.new(uri) + http_request["Authorization"] = "Bearer #{@config.auth0_management_token}" + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == "https" + http.open_timeout = @config.http_timeout_seconds + http.read_timeout = @config.http_timeout_seconds + http.request(http_request) + end + end +end diff --git a/bin/lib/deploy/temporary/support/config.rb b/bin/lib/deploy/temporary/support/config.rb new file mode 100644 index 00000000..d1636311 --- /dev/null +++ b/bin/lib/deploy/temporary/support/config.rb @@ -0,0 +1,175 @@ +module TraditionalKnowledgeTemporaryDeployment + class Config + REQUIRED = %w[ + TEMPORARY_DEPLOYMENT_RESOURCE_GROUP + TEMPORARY_DEPLOYMENT_CONTAINER_APPS_ENVIRONMENT + TEMPORARY_DEPLOYMENT_CONTAINER_REGISTRY_SERVER + TEMPORARY_DEPLOYMENT_AZURE_SUBSCRIPTION_ID + TEMPORARY_DEPLOYMENT_DOMAIN_SUFFIX + TEMPORARY_DEPLOYMENT_STORAGE_ACCOUNT + TEMPORARY_DEPLOYMENT_AUTH0_ALLOWED_HOST_SUFFIX + TEMPORARY_DEPLOYMENT_AUTH0_MANAGEMENT_TOKEN + TEMPORARY_DEPLOYMENT_BLOB_CONNECTION_STRING + TEMPORARY_DEPLOYMENT_BLOB_CONTAINER + ].freeze + + attr_reader :environment + + def initialize(environment = ENV) + @environment = environment + end + + def validate! + missing = REQUIRED.reject { |key| environment[key].to_s.strip != "" } + unless missing.empty? + raise Error, + "Missing temporary deployment configuration: #{missing.join(", ")}" + end + validate_auth0_domain! + blob_container(Source.parse(:pr, "1")) + + validate_scope! + unless blob_connection_account == storage_account + raise Error, + "TEMPORARY_DEPLOYMENT_BLOB_CONNECTION_STRING must belong to TEMPORARY_DEPLOYMENT_STORAGE_ACCOUNT" + end + expected_suffix = ".#{dns_suffix}" + unless auth0_allowed_host_suffix == expected_suffix + raise Error, + "TEMPORARY_DEPLOYMENT_AUTH0_ALLOWED_HOST_SUFFIX must be #{expected_suffix.inspect}" + end + self + end + + def validate_cleanup! + required = %w[ + TEMPORARY_DEPLOYMENT_RESOURCE_GROUP + TEMPORARY_DEPLOYMENT_CONTAINER_APPS_ENVIRONMENT + TEMPORARY_DEPLOYMENT_CONTAINER_REGISTRY_SERVER + TEMPORARY_DEPLOYMENT_AZURE_SUBSCRIPTION_ID + TEMPORARY_DEPLOYMENT_DOMAIN_SUFFIX + TEMPORARY_DEPLOYMENT_STORAGE_ACCOUNT + TEMPORARY_DEPLOYMENT_BLOB_CONNECTION_STRING + TEMPORARY_DEPLOYMENT_BLOB_CONTAINER + ] + missing = required.reject { |key| environment[key].to_s.strip != "" } + unless missing.empty? + raise Error, + "Missing temporary deployment cleanup configuration: #{missing.join(", ")}" + end + + validate_scope! + unless blob_connection_account == storage_account + raise Error, + "TEMPORARY_DEPLOYMENT_BLOB_CONNECTION_STRING must belong to TEMPORARY_DEPLOYMENT_STORAGE_ACCOUNT" + end + end + + def validate_scope! + unsafe_values = { + "resource group" => resource_group, + "ACA environment" => aca_environment, + "DNS suffix" => dns_suffix, + "storage account" => storage_account, + "blob container" => blob_container_prefix + } + unsafe = + unsafe_values.select do |_label, value| + value.match?(/production|prod(?:uction)?[-_\.]?/i) + end + return if unsafe.empty? + + labels = unsafe.keys.join(", ") + raise Error, + "Refusing production-looking temporary configuration in #{labels}" + end + + def resource_group = fetch("TEMPORARY_DEPLOYMENT_RESOURCE_GROUP") + def aca_environment = + fetch("TEMPORARY_DEPLOYMENT_CONTAINER_APPS_ENVIRONMENT") + def acr_server = fetch("TEMPORARY_DEPLOYMENT_CONTAINER_REGISTRY_SERVER") + def acr_name = acr_server.split(".").first + def dns_suffix = + fetch("TEMPORARY_DEPLOYMENT_DOMAIN_SUFFIX").sub(%r{\Ahttps?://}, "").sub( + %r{/.*\z}, + "" + ) + def scope_tag = SCOPE_TAG + def storage_account = fetch("TEMPORARY_DEPLOYMENT_STORAGE_ACCOUNT") + def auth0_allowed_host_suffix = + fetch("TEMPORARY_DEPLOYMENT_AUTH0_ALLOWED_HOST_SUFFIX") + def auth0_domain = + environment.fetch("TEMPORARY_DEPLOYMENT_AUTH0_DOMAIN", UAT_AUTH0_DOMAIN) + def auth0_audience = + environment.fetch( + "TEMPORARY_DEPLOYMENT_AUTH0_AUDIENCE", + UAT_AUTH0_AUDIENCE + ) + def auth0_client_id = + environment.fetch( + "TEMPORARY_DEPLOYMENT_AUTH0_CLIENT_ID", + UAT_AUTH0_CLIENT_ID + ) + def auth0_management_token = + fetch("TEMPORARY_DEPLOYMENT_AUTH0_MANAGEMENT_TOKEN") + def blob_connection_string = + fetch("TEMPORARY_DEPLOYMENT_BLOB_CONNECTION_STRING") + def blob_connection_account = + blob_connection_string[/AccountName=([^;]+)/i, 1] + def blob_endpoint = blob_connection_string[/BlobEndpoint=([^;]+)/i, 1] + def blob_container_prefix = fetch("TEMPORARY_DEPLOYMENT_BLOB_CONTAINER") + def blob_container(source) + name = "#{blob_container_prefix}-#{source.identifier}" + unless name.length.between?(3, 63) && + name.match?(/\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/) && + !name.include?("--") + raise Error, + "Temporary blob containers must be 3–63 lowercase letters, numbers, and single hyphens" + end + + name + end + def repository = + environment.fetch("TEMPORARY_DEPLOYMENT_SOURCE_REPOSITORY", REPOSITORY) + def state_directory = + environment.fetch( + "TEMPORARY_DEPLOYMENT_STATE_DIRECTORY", + DEFAULT_STATE_DIR + ) + def subscription_id = fetch("TEMPORARY_DEPLOYMENT_AZURE_SUBSCRIPTION_ID") + def timeout_seconds = + Integer( + environment.fetch("TEMPORARY_DEPLOYMENT_TIMEOUT_SECONDS", "300"), + 10 + ) + def http_timeout_seconds = + Integer( + environment.fetch("TEMPORARY_DEPLOYMENT_REQUEST_TIMEOUT_SECONDS", "10"), + 10 + ) + + def environment_id(source) = source.identifier + def app_name(source) = "#{APP_PREFIX}#{source.app_identifier}" + def public_url(source) = "https://#{app_name(source)}.#{dns_suffix}" + + def state_environment_matches?(state) + state.fetch("resource_group") == resource_group && + state.fetch("aca_environment") == aca_environment + end + + private + + def validate_auth0_domain! + unless auth0_domain.start_with?("https://") + raise Error, "TEMPORARY_DEPLOYMENT_AUTH0_DOMAIN must use https://" + end + end + + def fetch(key) + value = environment[key].to_s.strip + raise Error, "Missing temporary configuration: #{key}" if value.empty? + + value + end + end +end diff --git a/bin/lib/deploy/temporary/support/errors.rb b/bin/lib/deploy/temporary/support/errors.rb new file mode 100644 index 00000000..da389e39 --- /dev/null +++ b/bin/lib/deploy/temporary/support/errors.rb @@ -0,0 +1,42 @@ +module TraditionalKnowledgeTemporaryDeployment + class Error < StandardError + end + + class CommandError < Error + attr_reader :command, :output + + def initialize(command, output) + @command = command + @output = output + super("Command failed (#{command.join(" ")}):\n#{output}") + end + end + + class Runner + def run(*command) + run_with_environment({}, *command) + end + + def run_with_environment(environment, *command) + stdout, stderr, status = + Open3.capture3( + { "TEMPORARY_DEPLOYMENT_AUTH0_MANAGEMENT_TOKEN" => nil }.merge( + environment + ), + *command + ) + output = [stdout, stderr].reject(&:empty?).join + raise CommandError.new(command, output) unless status.success? + + stdout + end + def stream(*command) + unless system( + { "TEMPORARY_DEPLOYMENT_AUTH0_MANAGEMENT_TOKEN" => nil }, + *command + ) + raise Error, "Command failed (#{command.join(" ")})" + end + end + end +end diff --git a/bin/lib/deploy/temporary/support/github.rb b/bin/lib/deploy/temporary/support/github.rb new file mode 100644 index 00000000..8b2940e1 --- /dev/null +++ b/bin/lib/deploy/temporary/support/github.rb @@ -0,0 +1,73 @@ +module TraditionalKnowledgeTemporaryDeployment + class GitHub + def initialize(runner, repository) + @runner = runner + @repository = repository + end + + def commit(source) + case source.kind + when :pr + pull_request(source.value).fetch("headSha") + when :branch + @runner.run( + "gh", + "api", + "repos/#{@repository}/commits/#{source.value}", + "--jq", + ".sha" + ).strip + when :git_hash + source.value + end + end + + def pull_request(number) + result = + @runner.run( + "gh", + "pr", + "view", + Integer(number).to_s, + "--repo", + @repository, + "--json", + "headRefName,headSha" + ) + JSON.parse(result).transform_keys(&:to_s) + rescue JSON::ParserError => error + raise Error, "Unable to read PR metadata: #{error.message}" + end + + def fetch_commit(source, sha, root) + ref = "refs/tk-temporary/#{source.identifier}" + remote_ref = + source.kind == :pr ? + "refs/pull/#{source.value}/head" : + source.kind == :branch ? "refs/heads/#{source.value}" : sha + @runner.run( + "git", + "-C", + root, + "fetch", + "--force", + "origin", + "#{remote_ref}:#{ref}" + ) + fetched_sha = @runner.run("git", "-C", root, "rev-parse", ref).strip + return ref if fetched_sha == sha + + begin + @runner.run("git", "-C", root, "update-ref", "-d", ref) + rescue StandardError + nil + end + raise Error, + "Fetched #{source.label} commit #{fetched_sha} does not match GitHub SHA #{sha}" + end + + def remove_commit_ref(ref, root) + @runner.run("git", "-C", root, "update-ref", "-d", ref) + end + end +end diff --git a/bin/lib/deploy/temporary/support/service.rb b/bin/lib/deploy/temporary/support/service.rb new file mode 100644 index 00000000..00390e9e --- /dev/null +++ b/bin/lib/deploy/temporary/support/service.rb @@ -0,0 +1,7 @@ +module TraditionalKnowledgeTemporaryDeployment + class Service + def self.call(*arguments, **keyword_arguments) + new(*arguments, **keyword_arguments).call + end + end +end diff --git a/bin/lib/deploy/temporary/support/source.rb b/bin/lib/deploy/temporary/support/source.rb new file mode 100644 index 00000000..148c0559 --- /dev/null +++ b/bin/lib/deploy/temporary/support/source.rb @@ -0,0 +1,75 @@ +module TraditionalKnowledgeTemporaryDeployment + class Source + KINDS = %i[pr branch git_hash].freeze + + attr_reader :kind, :value + + def self.parse(kind, value) + normalized_kind = kind.to_s.tr("-", "_").to_sym + unless KINDS.include?(normalized_kind) + raise Error, "Source must be a PR, branch, or git hash" + end + + new(normalized_kind, value) + end + def initialize(kind, value) + @kind = kind + @value = value.to_s.strip + @value = @value.downcase if kind == :git_hash + validate! + end + + def identifier + case kind + when :pr + "pr-#{Integer(value, 10)}" + when :branch + "branch-#{slug}-#{Digest::SHA256.hexdigest(value)[0, 8]}" + when :git_hash + "sha-#{value[0, 12]}" + end + end + + def label + "#{kind}:#{value}" + end + + def app_identifier + case kind + when :pr + "pr-#{Integer(value, 10)}" + when :branch + "branch-#{Digest::SHA256.hexdigest(value)[0, 10]}" + when :git_hash + "sha-#{value[0, 12]}" + end + end + + def slug + result = + value.downcase.gsub(/[^a-z0-9]+/, "-").sub(/\A-+/, "").sub(/-+\z/, "")[ + 0, + 24 + ] + result.empty? ? "branch" : result + end + + def validate! + case kind + when :pr + unless value.match?(/\A[1-9]\d{0,15}\z/) + raise Error, "PR number must be positive and at most 16 digits" + end + when :branch + raise Error, "Branch must not be empty" if value.empty? + unless value.match?(%r{\A[\w./-]+\z}) + raise Error, "Branch contains unsupported characters" + end + when :git_hash + unless value.match?(/\A[0-9a-f]{40}\z/i) + raise Error, "Git hash must be a full 40-character SHA" + end + end + end + end +end diff --git a/bin/lib/deploy/temporary/support/state_store.rb b/bin/lib/deploy/temporary/support/state_store.rb new file mode 100644 index 00000000..f3cb2731 --- /dev/null +++ b/bin/lib/deploy/temporary/support/state_store.rb @@ -0,0 +1,66 @@ +module TraditionalKnowledgeTemporaryDeployment + class StateStore + attr_reader :directory + + def initialize( + directory = ENV.fetch( + "TEMPORARY_DEPLOYMENT_STATE_DIRECTORY", + DEFAULT_STATE_DIR + ) + ) + @directory = File.expand_path(directory) + end + + def save(state) + FileUtils.mkdir_p(directory, mode: 0o700) + path = path_for(state.fetch("environment_id")) + Tempfile.create(%w[temporary- .json], directory, mode: 0o600) do |file| + file.write(JSON.pretty_generate(state)) + file.flush + File.rename(file.path, path) + end + end + + def find(environment_id) + path = path_for(environment_id) + return unless File.file?(path) + + JSON.parse(File.read(path)) + rescue JSON::ParserError => error + raise Error, + "Invalid temporary deployment state at #{path}: #{error.message}" + end + + def all + return [] unless Dir.exist?(directory) + + Dir + .glob(File.join(directory, "*.json")) + .sort + .filter_map do |path| + JSON.parse(File.read(path)) + rescue JSON::ParserError => error + raise Error, + "Invalid temporary deployment state at #{path}: #{error.message}" + end + end + + def delete(environment_id) + File.delete(path_for(environment_id)) + rescue Errno::ENOENT + nil + end + + private + + def path_for(environment_id) + unless environment_id.match?( + /\A(?:pr-\d+|branch-[a-z0-9-]+|sha-[0-9a-f]{12})\z/ + ) + raise Error, "Invalid temporary deployment identifier" + end + + File.join(directory, "#{environment_id}.json") + end + end +end diff --git a/bin/lib/deploy/temporary/support/worktree.rb b/bin/lib/deploy/temporary/support/worktree.rb new file mode 100644 index 00000000..069f3ed3 --- /dev/null +++ b/bin/lib/deploy/temporary/support/worktree.rb @@ -0,0 +1,28 @@ +module TraditionalKnowledgeTemporaryDeployment + class Worktree + def initialize(runner, root) + @runner = runner + @root = root + end + + def with(sha) + Dir.mktmpdir("tk-temporary-build-") do |path| + @runner.run( + "git", + "-C", + @root, + "worktree", + "add", + "--detach", + path, + sha + ) + begin + yield path + ensure + @runner.run("git", "-C", @root, "worktree", "remove", "--force", path) + end + end + end + end +end diff --git a/web/src/config.ts b/web/src/config.ts index 28f59385..a16621f9 100644 --- a/web/src/config.ts +++ b/web/src/config.ts @@ -34,6 +34,14 @@ const localProductionConfig = { apiBaseUrl: "http://localhost:8080", applicationName: "Traditional Knowledge", } +const temporaryConfig = { + hostSuffix: import.meta.env.VITE_TEMPORARY_HOST_SUFFIX || "", + domain: import.meta.env.VITE_TEMPORARY_AUTH0_DOMAIN || uatConfig.domain, + clientId: import.meta.env.VITE_TEMPORARY_AUTH0_CLIENT_ID || uatConfig.clientId, + audience: import.meta.env.VITE_TEMPORARY_AUTH0_AUDIENCE || uatConfig.audience, + apiBaseUrl: "", + applicationName: "Traditional Knowledge - Temporary", +} let config = prodConfig @@ -49,6 +57,8 @@ if (ENVIRONMENT === "production" && window.location.host === "localhost:8080") { config = devConfig } else if (window.location.host === "yg-wrap-uat.azurewebsites.net") { config = uatConfig +} else if (temporaryConfig.hostSuffix && window.location.hostname.endsWith(temporaryConfig.hostSuffix)) { + config = temporaryConfig } export const APPLICATION_NAME = config.applicationName