From 6d424d6ab489306b6bfed106169f13ebb3591ae8 Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Thu, 23 Jul 2026 14:14:46 -0700 Subject: [PATCH 01/10] :hammer: Provision isolated QA environments for pull requests. Add a local Ruby workflow that builds immutable PR images, provisions disposable Azure sidecars, verifies readiness, and retains retryable cleanup state. See https://github.com/icefoganalytics/traditional-knowledge/issues/50. --- CHANGELOG.md | 9 + Dockerfile | 9 +- api/src/app.ts | 7 +- api/src/config.ts | 3 +- bin/README.md | 46 +++ bin/dev | 8 + bin/lib/qa.rb | 940 ++++++++++++++++++++++++++++++++++++++++++++++ bin/qa | 5 + bin/qa_test.rb | 136 +++++++ web/src/config.ts | 10 + 10 files changed, 1169 insertions(+), 4 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 bin/lib/qa.rb create mode 100755 bin/qa create mode 100755 bin/qa_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..62770764 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] + +### Added + +- Added a local `bin/qa` workflow for isolated per-PR Azure Container Apps QA environments, including readiness checks, persisted cleanup state, and non-production resource guardrails. diff --git a/Dockerfile b/Dockerfile index fac0beb0..d448ef62 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,8 +38,15 @@ RUN npm clean-install COPY web ./ -# Switching to production mode for build environment. +ARG VITE_QA_HOST_SUFFIX +ARG VITE_QA_AUTH0_DOMAIN +ARG VITE_QA_AUTH0_AUDIENCE +ARG VITE_QA_AUTH0_CLIENT_ID ENV NODE_ENV=production +ENV VITE_QA_HOST_SUFFIX=${VITE_QA_HOST_SUFFIX} +ENV VITE_QA_AUTH0_DOMAIN=${VITE_QA_AUTH0_DOMAIN} +ENV VITE_QA_AUTH0_AUDIENCE=${VITE_QA_AUTH0_AUDIENCE} +ENV VITE_QA_AUTH0_CLIENT_ID=${VITE_QA_AUTH0_CLIENT_ID} RUN npm run build # Stage 3 - production setup diff --git a/api/src/app.ts b/api/src/app.ts index fffd14f2..52d20cd1 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -4,7 +4,7 @@ import path from "path" import helmet from "helmet" import formData from "express-form-data" -import { AUTH0_DOMAIN, FRONTEND_URL } from "@/config" +import { AUTH0_DOMAIN, FRONTEND_URL, GIT_COMMIT_HASH, QA_ENVIRONMENT, RELEASE_TAG } from "@/config" import { betterFormDataBodyParserMiddleware, requestLoggerMiddleware } from "@/middlewares" import router from "@/router" import enhancedQsDecoder from "@/utils/enhanced-qs-decoder" @@ -46,6 +46,11 @@ app.use( app.use(requestLoggerMiddleware) +if (QA_ENVIRONMENT) { + app.get("/qa-status", (_request: Request, response: Response) => { + response.json({ status: "ok", releaseTag: RELEASE_TAG, gitCommitHash: GIT_COMMIT_HASH }) + }) +} app.use(router) // serves the static files generated by the front-end 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..cc2175a2 100644 --- a/bin/README.md +++ b/bin/README.md @@ -13,3 +13,49 @@ 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. + +## `qa` Command + +Run the offline lifecycle checks through the normal wrapper: + +```bash +bin/dev test qa +``` +`bin/qa` creates a disposable Azure Container Apps environment from a pull request: + +```bash +bin/qa up --pr 50 --ttl-hours 4 +bin/qa status --pr 50 +bin/qa logs --pr 50 --follow +bin/qa down --pr 50 +``` + +The command builds an immutable PR SHA in a detached worktree, publishes a uniquely tagged image, creates an isolated SQL Server/Redis/MailDev application, and stores retryable state in `~/.traditional-knowledge-qa`. `bin/qa down --all --yes` is intentionally explicit. TTL is recorded for cleanup; it is not a scheduler. + +Before use, configure these local-only `TK_QA_*` variables: + +- `TK_QA_RESOURCE_GROUP`, `TK_QA_ACA_ENVIRONMENT`, `TK_QA_ACR_SERVER`, `TK_QA_SUBSCRIPTION_ID` +- `TK_QA_DNS_SUFFIX`, with an ACA custom-domain suffix and wildcard certificate +- `TK_QA_STORAGE_ACCOUNT`, `TK_QA_BLOB_CONNECTION_STRING`, `TK_QA_BLOB_CONTAINER` +- `TK_QA_AUTH0_DOMAIN`, `TK_QA_AUTH0_AUDIENCE`, `TK_QA_AUTH0_CLIENT_ID`, `TK_QA_AUTH0_MANAGEMENT_TOKEN` +- `TK_QA_AUTH0_ALLOWED_HOST_SUFFIX`, exactly `.` +Configure the QA Auth0 application with these exact wildcard values, replacing `` with `TK_QA_DNS_SUFFIX`: + +```text +Allowed Callback URLs: https://*./callback +Allowed Logout URLs: https://*. +Allowed Web Origins: https://*. +``` + +The command refuses to provision unless `TK_QA_AUTH0_ALLOWED_HOST_SUFFIX` is `.`. Existing production and UAT Auth0 entries must remain unchanged. +`TK_QA_AUTH0_MANAGEMENT_TOKEN` must be a short-lived, local-only token with only `read:clients` scope. The command uses it only for the Auth0 preflight, never writes it to state, passes it to Azure, or includes it in error output. + +The resource group, ACA environment, storage account, and ACR must all carry the exact tag `traditional-knowledge-qa=true`. The blob connection string must belong to `TK_QA_STORAGE_ACCOUNT`; each PR receives a separate container and short-lived container SAS. Do not use production credentials or resources. `TK_QA_STATE_DIR`, `TK_QA_TIMEOUT_SECONDS`, and `TK_QA_HTTP_TIMEOUT_SECONDS` are optional overrides. + +Naming and lifecycle contracts: + +- The branch is fetched through `refs/pull//head` and verified against GitHub’s `headSha`; the detached worktree is removed after the ACR build. +- The image is `traditional-knowledge:qa-pr--`, the ACA app is `tk-qa-`, the environment ID is `pr-`, and the blob container is `-pr-`. +- Re-running `bin/qa up --pr ` updates that PR’s app to the current SHA, renews its container SAS, and removes the previous SHA image after the new revision is ready. +- Each ACA app runs one replica with SQL Server at `1 CPU/2 GiB`, the web container at `0.5 CPU/1 GiB`, and Redis/MailDev sidecars at `0.25 CPU/0.5 GiB` each. These limits are intentionally conservative defaults and should be tuned only with observed capacity needs. +- The operator who starts an environment is responsible for running `bin/qa down` before the recorded TTL. There is no automatic expiry worker yet. Failed app, blob, or image cleanup retains local state and returns non-zero so the operator can retry. diff --git a/bin/dev b/bin/dev index 28140641..7488f224 100755 --- a/bin/dev +++ b/bin/dev @@ -132,11 +132,19 @@ class DevHelper test_api(*args.drop(1), **kwargs) elsif service == "web" test_web(*args.drop(1), **kwargs) + elsif service == "qa" + test_qa(*args.drop(1), **kwargs) else test_api(*args, **kwargs) end end + def test_qa(*args, **kwargs) + raise ArgumentError, "Unexpected argument(s) for qa tests: #{args.join(" ")}" unless args.empty? + + exit 1 unless system("ruby", File.join(project_root, "bin/qa_test.rb")) + end + def test_api(*args, **kwargs) reformat_project_relative_path_filter_for_vitest!(args, "api/") run(*%w[test_api npm run test], *args, **kwargs) diff --git a/bin/lib/qa.rb b/bin/lib/qa.rb new file mode 100644 index 00000000..6437558f --- /dev/null +++ b/bin/lib/qa.rb @@ -0,0 +1,940 @@ +#!/usr/bin/env ruby + +require "fileutils" +require "json" +require "net/http" +require "open3" +require "optparse" +require "securerandom" +require "tempfile" +require "tmpdir" +require "time" +require "uri" + +module TraditionalKnowledgeQa + REPOSITORY = "icefoganalytics/traditional-knowledge" + APP_PREFIX = "tk-qa-" + SCOPE_TAG = "traditional-knowledge-qa" + DEFAULT_STATE_DIR = File.expand_path("~/.traditional-knowledge-qa") + + 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({ "TK_QA_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) + raise Error, "Command failed (#{command.join(" ")})" unless system({ "TK_QA_AUTH0_MANAGEMENT_TOKEN" => nil }, *command) + end + end + + class StateStore + attr_reader :directory + + def initialize(directory = ENV.fetch("TK_QA_STATE_DIR", 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(["qa-", ".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 QA 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 QA 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) + raise Error, "Invalid environment identifier" unless environment_id.match?(/\Apr-\d+\z/) + + File.join(directory, "#{environment_id}.json") + end + end + + class Config + REQUIRED = %w[ + TK_QA_RESOURCE_GROUP + TK_QA_ACA_ENVIRONMENT + TK_QA_ACR_SERVER + TK_QA_DNS_SUFFIX + TK_QA_STORAGE_ACCOUNT + TK_QA_AUTH0_ALLOWED_HOST_SUFFIX + TK_QA_AUTH0_DOMAIN + TK_QA_AUTH0_AUDIENCE + TK_QA_AUTH0_CLIENT_ID + TK_QA_AUTH0_MANAGEMENT_TOKEN + TK_QA_BLOB_CONNECTION_STRING + TK_QA_BLOB_CONTAINER + ].freeze + + attr_reader :environment + + def initialize(environment = ENV) + @environment = environment + end + + def validate! + missing = REQUIRED.reject { |key| environment[key].to_s.strip != "" } + raise Error, "Missing QA configuration: #{missing.join(", ")}" unless missing.empty? + blob_container(1) + + validate_scope! + unless blob_connection_account == storage_account + raise Error, "TK_QA_BLOB_CONNECTION_STRING must belong to TK_QA_STORAGE_ACCOUNT" + end + expected_suffix = ".#{dns_suffix}" + unless auth0_allowed_host_suffix == expected_suffix + raise Error, "TK_QA_AUTH0_ALLOWED_HOST_SUFFIX must be #{expected_suffix.inspect}" + end + raise Error, "TK_QA_AUTH0_DOMAIN must use https://" unless auth0_domain.start_with?("https://") + + self + end + + def validate_cleanup! + required = %w[ + TK_QA_RESOURCE_GROUP + TK_QA_ACA_ENVIRONMENT + TK_QA_ACR_SERVER + TK_QA_DNS_SUFFIX + TK_QA_STORAGE_ACCOUNT + TK_QA_BLOB_CONNECTION_STRING + TK_QA_BLOB_CONTAINER + ] + missing = required.reject { |key| environment[key].to_s.strip != "" } + raise Error, "Missing QA cleanup configuration: #{missing.join(", ")}" unless missing.empty? + + validate_scope! + raise Error, "TK_QA_BLOB_CONNECTION_STRING must belong to TK_QA_STORAGE_ACCOUNT" unless blob_connection_account == storage_account + 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 { |_label, value| value.match?(/production|prod(?:uction)?[-_\.]?/i) } + return if unsafe.empty? + + labels = unsafe.keys.join(", ") + raise Error, "Refusing production-looking QA configuration in #{labels}" + end + + def resource_group = fetch("TK_QA_RESOURCE_GROUP") + def aca_environment = fetch("TK_QA_ACA_ENVIRONMENT") + def acr_server = fetch("TK_QA_ACR_SERVER") + def acr_name = acr_server.split(".").first + def dns_suffix = fetch("TK_QA_DNS_SUFFIX").sub(%r{\Ahttps?://}, "").sub(%r{/.*\z}, "") + def scope_tag = SCOPE_TAG + def storage_account = fetch("TK_QA_STORAGE_ACCOUNT") + def auth0_allowed_host_suffix = fetch("TK_QA_AUTH0_ALLOWED_HOST_SUFFIX") + def auth0_domain = fetch("TK_QA_AUTH0_DOMAIN") + def auth0_audience = fetch("TK_QA_AUTH0_AUDIENCE") + def auth0_client_id = fetch("TK_QA_AUTH0_CLIENT_ID") + def auth0_management_token = fetch("TK_QA_AUTH0_MANAGEMENT_TOKEN") + def blob_connection_string = fetch("TK_QA_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("TK_QA_BLOB_CONTAINER") + def blob_container(pr_number) + name = "#{blob_container_prefix}-pr-#{positive_pr_number(pr_number)}" + unless name.length.between?(3, 63) && name.match?(/\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/) && !name.include?("--") + raise Error, "QA blob container must be 3–63 lowercase letters, numbers, and single hyphens" + end + + name + end + def repository = environment.fetch("TK_QA_REPOSITORY", REPOSITORY) + def state_directory = environment.fetch("TK_QA_STATE_DIR", DEFAULT_STATE_DIR) + def subscription_id = environment["TK_QA_SUBSCRIPTION_ID"] + def timeout_seconds = Integer(environment.fetch("TK_QA_TIMEOUT_SECONDS", "300"), 10) + def http_timeout_seconds = Integer(environment.fetch("TK_QA_HTTP_TIMEOUT_SECONDS", "10"), 10) + + def environment_id(pr_number) = "pr-#{positive_pr_number(pr_number)}" + def app_name(pr_number) = "#{APP_PREFIX}#{positive_pr_number(pr_number)}" + def public_url(pr_number) = "https://#{app_name(pr_number)}.#{dns_suffix}" + + def state_environment_matches?(state) + state.fetch("resource_group") == resource_group && + state.fetch("aca_environment") == aca_environment && + (!subscription_id || state.fetch("subscription_id") == subscription_id) + end + + def positive_pr_number(pr_number) + value = Integer(pr_number.to_s, 10) + raise Error, "PR number must be positive" unless value.positive? + + value + end + + private + + def fetch(key) + value = environment[key].to_s.strip + raise Error, "Missing QA configuration: #{key}" if value.empty? + + value + end + end + + class GitHub + def initialize(runner, repository) + @runner = runner + @repository = repository + 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(number, sha, root) + ref = "refs/tk-qa/pr-#{Integer(number)}" + @runner.run("git", "-C", root, "fetch", "--force", "origin", "refs/pull/#{Integer(number)}/head:#{ref}") + fetched_sha = @runner.run("git", "-C", root, "rev-parse", ref).strip + if fetched_sha != sha + @runner.run("git", "-C", root, "update-ref", "-d", ref) rescue nil + raise Error, "Fetched PR commit #{fetched_sha} does not match GitHub SHA #{sha}" + end + + ref + end + + def remove_commit_ref(ref, root) + @runner.run("git", "-C", root, "update-ref", "-d", ref) + end + end + 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 = @http_client ? @http_client.get(uri, @config.auth0_management_token) : request(uri) + raise Error, "Auth0 client settings could not be read (HTTP #{response.code})" unless response.is_a?(Net::HTTPSuccess) + + 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 QA 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 + + + class Azure + API_VERSION = "2024-03-01" + + def initialize(runner, config) + @runner = runner + @config = config + end + + def subscription_id + @subscription_id ||= @config.subscription_id || @runner.run("az", "account", "show", "--query", "id", "-o", "tsv").strip + end + + def validate_environment! + 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 + return if error.output.match?(/not found|could not be found|ResourceNotFound/i) + + 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 QA 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_QA_HOST_SUFFIX=#{host_suffix}", + "--build-arg", "VITE_QA_AUTH0_DOMAIN=#{@config.auth0_domain}", + "--build-arg", "VITE_QA_AUTH0_AUDIENCE=#{@config.auth0_audience}", + "--build-arg", "VITE_QA_AUTH0_CLIENT_ID=#{@config.auth0_client_id}", + worktree + ) + end + + def deploy(pr_number, sha, image_tag, expires_at, provision_blob_container:, remove_app_on_failure:) + app_name = @config.app_name(pr_number) + 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(pr_number) + 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( + pr_number:, qa_environment_id: @config.environment_id(pr_number), + 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(pr_number), 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(pr_number), + "app_name" => app_name, + "pr_number" => Integer(pr_number), + "sha" => sha, + "image_tag" => image_tag, + "blob_container" => blob_container, + "public_url" => @config.public_url(pr_number), + "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_image(state.fetch("image_tag")) + previous_image_tag = state["previous_image_tag"] + delete_image(previous_image_tag) if previous_image_tag && previous_image_tag != state.fetch("image_tag") + 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 + + private + + def put_app(app_name, body) + Tempfile.create(["tk-qa-", ".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 + raise Error, "Azure did not return a SAS for blob container #{name}" if sas.empty? + + endpoint = @config.blob_endpoint + raise Error, "TK_QA_BLOB_CONNECTION_STRING has no BlobEndpoint" if endpoint.to_s.empty? + + "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 + raise unless error.output.match?(/not found|could not be found|ResourceNotFound/i) + 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" + raise Error, "Azure provisioning failed for #{app_name}; inspect with `az containerapp logs show --name #{app_name} --resource-group #{@config.resource_group} --container web`" if state == "Failed" + 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`" if Time.now >= deadline + + sleep 5 + end + end + + def wait_for_http(url, app_name, expected_sha) + deadline = Time.now + @config.timeout_seconds + uri = URI("#{url}/qa-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 = JSON.parse(response.body) rescue {} + return if response.is_a?(Net::HTTPSuccess) && payload["status"] == "ok" && payload["gitCommitHash"] == expected_sha + raise Error, "Timed out waiting for #{url}/qa-status; inspect with `az containerapp logs show --name #{app_name} --resource-group #{@config.resource_group} --container web`" if Time.now >= deadline + + sleep 5 + rescue StandardError => error + raise error if error.is_a?(Error) + raise Error, "Timed out waiting for #{url}/qa-status; inspect with `az containerapp logs show --name #{app_name} --resource-group #{@config.resource_group} --container web`" if Time.now >= deadline + + sleep 5 + end + end + + def deployment_body(pr_number:, qa_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(pr_number) + { + "location" => location, + "tags" => { + "traditional-knowledge-qa" => "true", + "qa-environment" => qa_environment_id, + "qa-pr-sha" => sha, + "qa-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" => "QA_ENVIRONMENT", "value" => "true" }, + { "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_qa" }, + { "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 }, + { "name" => "VITE_AUTH0_CLIENT_ID", "value" => @config.auth0_client_id }, + ], + }, + ], + }, + }, + } + end + end + + class Worktree + def initialize(runner, root) + @runner = runner + @root = root + end + + def with(sha) + Dir.mktmpdir("tk-qa-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 + + 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(pr_number, ttl_hours) + @config.validate! + Auth0.new(@config).validate! + github = GitHub.new(@runner, @config.repository) + pr = github.pull_request(pr_number) + sha = pr.fetch("headSha") + image_tag = "qa-pr-#{Integer(pr_number)}-#{sha[0, 12]}" + expires_at = (Time.now.utc + ttl_hours * 3600).iso8601 + environment_id = @config.environment_id(pr_number) + existing_state = @store.find(environment_id) + preserve_existing_resources = preserve_existing_resources?(existing_state) + azure = Azure.new(@runner, @config) + ref = github.fetch_commit(pr_number, 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(pr_number), + "pr_number" => Integer(pr_number), + "sha" => sha, + "image_tag" => image_tag, + "previous_image_tag" => existing_state && existing_state["image_tag"] != image_tag ? existing_state["image_tag"] : nil, + "blob_container" => @config.blob_container(pr_number), + "public_url" => @config.public_url(pr_number), + "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( + pr_number, sha, image_tag, expires_at, + provision_blob_container: !preserve_existing_resources, + remove_app_on_failure: !preserve_existing_resources + ) + state["previous_image_tag"] = existing_state && existing_state["image_tag"] != image_tag ? existing_state["image_tag"] : nil + 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 "QA environment ready: #{state.fetch("public_url")}" + puts "Environment: #{state.fetch("environment_id")} App: #{state.fetch("app_name")}" + puts "PR: #{state.fetch("pr_number")} SHA: #{sha} Expires: #{expires_at}" + puts "Teardown: bin/qa down --pr #{pr_number}" + ensure + github.remove_commit_ref(ref, @root) if ref + end + end + + def list + states = @store.all + if states.empty? + puts "No QA environments." + return + end + states.each { |state| puts "#{state.fetch("environment_id")} #{state.fetch("public_url")} #{state.fetch("sha")} expires #{state.fetch("expires_at")}" } + end + + def status(pr_number) + state = scoped_state_for(pr_number) + puts "#{state.fetch("environment_id")}: #{Azure.new(@runner, @config).status(state)}" + puts "URL: #{state.fetch("public_url")}" + end + + def logs(pr_number, follow) + state = scoped_state_for(pr_number) + 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(pr_number) + state = state_for(pr_number) + @config.validate_cleanup! + azure = Azure.new(@runner, @config) + azure.validate_environment! + safety_check!(state, azure) + state + end + + def state_for(pr_number) + state = @store.find(@config.environment_id(pr_number)) + raise Error, "No QA state for PR #{pr_number}." unless state + + state + end + + def safety_check!(state, azure) + expected_blob_container = @config.blob_container(state.fetch("pr_number")) + unless state.fetch("blob_container") == expected_blob_container + raise Error, "Refusing to delete an unexpected QA blob container" + end + + unless state.fetch("app_name").start_with?(APP_PREFIX) && @config.state_environment_matches?(state) + raise Error, "Refusing to delete an environment outside the configured QA scope" + end + + azure.verify_app_scope!(state) + end + + public + def down(pr_number, all: false, confirmed: false) + raise Error, "down --all requires --yes" if all && !confirmed + + @config.validate_cleanup! + Azure.new(@runner, @config).validate_environment! + states = all ? @store.all : [state_for(pr_number)] + raise Error, "No QA environments." if states.empty? + + 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 + raise Error, "#{failures.length} QA environment deletion(s) failed" unless failures.empty? + end + + end + + def self.run(argv) + command = argv.shift + return puts(help) if command.nil? || %w[help --help -h].include?(command) + + options = { ttl_hours: 4, follow: false, all: false, yes: false } + parser = OptionParser.new do |option_parser| + option_parser.banner = "Usage: bin/qa [options]" + option_parser.on("--pr NUMBER", Integer, "PR number") { |value| options[:pr] = value } + 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") { options[:all] = true } + option_parser.on("--yes", "Confirm a destructive --all operation") { options[:yes] = true } + option_parser.on("--help", "Show help") { puts option_parser; exit } + end + parser.parse!(argv) + raise Error, "Unexpected argument(s): #{argv.join(" ")}" unless argv.empty? + + application = Application.new + case command + when "up" + raise Error, "up requires --pr NUMBER" unless options[:pr] + raise Error, "--ttl-hours must be positive" unless options[:ttl_hours].positive? + application.up(options[:pr], options[:ttl_hours]) + when "list" + application.list + when "status" + raise Error, "status requires --pr NUMBER" unless options[:pr] + application.status(options[:pr]) + when "logs" + raise Error, "logs requires --pr NUMBER" unless options[:pr] + application.logs(options[:pr], options[:follow]) + when "down" + raise Error, "down requires --pr NUMBER or --all" unless options[:pr] || options[:all] + application.down(options[:pr], all: options[:all], confirmed: options[:yes]) + else + raise Error, "Unknown command: #{command}" + end + rescue OptionParser::ParseError, ArgumentError, Error => error + warn error.message + exit 1 + end + + def self.help + <<~HELP + Disposable per-PR QA environments for Traditional Knowledge. + + Usage: + bin/qa up --pr NUMBER [--ttl-hours HOURS] + bin/qa list + bin/qa status --pr NUMBER + bin/qa logs --pr NUMBER [--follow] + bin/qa down --pr NUMBER + bin/qa down --all --yes + + `up` requires TK_QA_* configuration for a non-production Azure Container Apps + environment. See README.md for the required variables and Auth0 wildcard setup. + HELP + end +end + +TraditionalKnowledgeQa.run(ARGV) if $PROGRAM_NAME == __FILE__ diff --git a/bin/qa b/bin/qa new file mode 100755 index 00000000..0c724ecd --- /dev/null +++ b/bin/qa @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby + +require_relative "lib/qa" + +TraditionalKnowledgeQa.run(ARGV) if $PROGRAM_NAME == __FILE__ diff --git a/bin/qa_test.rb b/bin/qa_test.rb new file mode 100755 index 00000000..2387c27c --- /dev/null +++ b/bin/qa_test.rb @@ -0,0 +1,136 @@ +#!/usr/bin/env ruby + +require "tmpdir" +load File.expand_path("qa", __dir__) + +module QaTest + module_function + + def assert(condition, message) + raise message unless condition + end + + def assert_raises(error_class, message) + yield + raise "Expected #{error_class}" + rescue error_class + nil + end + + def environment(state_directory, **overrides) + { + "TK_QA_RESOURCE_GROUP" => "CapAero_QA", + "TK_QA_ACA_ENVIRONMENT" => "tk-qa-env", + "TK_QA_ACR_SERVER" => "qa.azurecr.io", + "TK_QA_DNS_SUFFIX" => "qa.example.com", + "TK_QA_STORAGE_ACCOUNT" => "tkqaassets", + "TK_QA_AUTH0_ALLOWED_HOST_SUFFIX" => ".qa.example.com", + "TK_QA_AUTH0_DOMAIN" => "https://qa.example.auth0.com", + "TK_QA_AUTH0_AUDIENCE" => "traditional-knowledge-qa", + "TK_QA_AUTH0_CLIENT_ID" => "qa-client", + "TK_QA_AUTH0_MANAGEMENT_TOKEN" => "qa-management-token", + "TK_QA_BLOB_CONNECTION_STRING" => "DefaultEndpointsProtocol=https;AccountName=tkqaassets;AccountKey=not-used;BlobEndpoint=https://tkqaassets.blob.core.windows.net/", + "TK_QA_BLOB_CONTAINER" => "tk-qa", + "TK_QA_STATE_DIR" => state_directory, + "TK_QA_SUBSCRIPTION_ID" => "qa-subscription", + }.merge(overrides) + end + class Auth0HttpClient + attr_reader :token + + def get(_uri, token) + @token = token + response = Net::HTTPOK.new("1.1", "200", "OK") + response.instance_variable_set( + :@body, + JSON.generate( + "callbacks" => ["https://*.qa.example.com/callback"], + "allowed_logout_urls" => ["https://*.qa.example.com"], + "web_origins" => ["https://*.qa.example.com"], + ) + ) + response.instance_variable_set(:@read, true) + response + end + end + + class FailingDeleteRunner + attr_reader :commands + + def initialize + @commands = [] + end + + def run(*command) + @commands << command + return "true\n" if command.include?("tags['traditional-knowledge-qa']") + return "qa.example.com\n" if command.include?("properties.customDomainConfiguration.dnsSuffix") + return "/subscriptions/qa/resourceGroups/CapAero_QA/providers/Microsoft.App/managedEnvironments/tk-qa-env\n" if command.include?("--query") && command.include?("id") + return "/subscriptions/qa/resourceGroups/CapAero_QA/providers/Microsoft.App/managedEnvironments/tk-qa-env\n" if command.include?("properties.managedEnvironmentId") + raise TraditionalKnowledgeQa::CommandError.new(command, "simulated delete failure") if command.include?("containerapp") && command.include?("delete") + + "" + end + + def run_with_environment(environment, *command) + run(*command) + end + end + + Dir.mktmpdir("tk-qa-test-") do |state_directory| + config = TraditionalKnowledgeQa::Config.new(environment(state_directory)) + config.validate! + auth0_client = Auth0HttpClient.new + TraditionalKnowledgeQa::Auth0.new(config, http_client: auth0_client).validate! + assert(auth0_client.token == "qa-management-token", "Auth0 preflight must use the management token") + cleanup_keys = %w[TK_QA_RESOURCE_GROUP TK_QA_ACA_ENVIRONMENT TK_QA_ACR_SERVER TK_QA_DNS_SUFFIX TK_QA_STORAGE_ACCOUNT TK_QA_BLOB_CONNECTION_STRING TK_QA_BLOB_CONTAINER TK_QA_STATE_DIR TK_QA_SUBSCRIPTION_ID] + TraditionalKnowledgeQa::Config.new(environment(state_directory).slice(*cleanup_keys)).validate_cleanup! + assert(config.environment_id(12) == "pr-12", "PR environment IDs must be stable") + assert(config.app_name(12) == "tk-qa-12", "Azure app names must be stable") + assert(config.blob_container(12) == "tk-qa-pr-12", "blob containers must be per PR") + assert_raises(TraditionalKnowledgeQa::Error, "production-looking resources must be rejected") do + TraditionalKnowledgeQa::Config.new(environment(state_directory, "TK_QA_RESOURCE_GROUP" => "production")).validate! + end + assert_raises(TraditionalKnowledgeQa::Error, "insecure Auth0 domains must be rejected") do + TraditionalKnowledgeQa::Config.new(environment(state_directory, "TK_QA_AUTH0_DOMAIN" => "http://qa.example.auth0.com")).validate! + end + assert_raises(TraditionalKnowledgeQa::Error, "invalid blob names must be rejected") do + TraditionalKnowledgeQa::Config.new(environment(state_directory, "TK_QA_BLOB_CONTAINER" => "TK-INVALID")).validate! + end + application_without_initialization = TraditionalKnowledgeQa::Application.allocate + assert(application_without_initialization.send(:preserve_existing_resources?, { "phase" => "ready" }), "ready state must preserve resources") + assert(application_without_initialization.send(:preserve_existing_resources?, { "preserve_existing_resources" => true }), "interrupted update must preserve resources") + assert(!application_without_initialization.send(:preserve_existing_resources?, { "phase" => "provisioning" }), "initial provisioning must not preserve resources") + + store = TraditionalKnowledgeQa::StateStore.new(state_directory) + store.save( + "environment_id" => "pr-12", + "app_name" => "tk-qa-12", + "pr_number" => 12, + "sha" => "a" * 40, + "image_tag" => "qa-pr-12-aaaaaaaaaaaa", + "blob_container" => "tk-qa-pr-12", + "public_url" => "https://tk-qa-12.qa.example.com", + "expires_at" => "2026-07-23T22:00:00Z", + "resource_group" => "CapAero_QA", + "aca_environment" => "tk-qa-env", + "subscription_id" => "qa-subscription", + "phase" => "ready", + ) + assert(store.find("pr-12").fetch("sha") == "a" * 40, "state must persist PR metadata") + + runner = FailingDeleteRunner.new + application = TraditionalKnowledgeQa::Application.new(runner:, environment: environment(state_directory)) + assert_raises(TraditionalKnowledgeQa::Error, "cleanup failure must fail the command") { application.down(12) } + assert(store.find("pr-12"), "cleanup failure must retain state for retry") + tampered_state = store.find("pr-12") + tampered_state["blob_container"] = "tk-qa-pr-999" + store.save(tampered_state) + command_count = runner.commands.length + assert_raises(TraditionalKnowledgeQa::Error, "unexpected blob containers must be rejected") { application.down(12) } + new_commands = runner.commands.drop(command_count) + assert(new_commands.none? { |command| command.include?("delete") }, "unexpected blob containers must not be deleted") + end + + puts "QA command checks passed" +end diff --git a/web/src/config.ts b/web/src/config.ts index 28f59385..01340fa5 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 qaConfig = { + hostSuffix: import.meta.env.VITE_QA_HOST_SUFFIX || "", + domain: import.meta.env.VITE_QA_AUTH0_DOMAIN || "", + clientId: import.meta.env.VITE_QA_AUTH0_CLIENT_ID || "", + audience: import.meta.env.VITE_QA_AUTH0_AUDIENCE || "", + apiBaseUrl: "", + applicationName: "Traditional Knowledge - QA", +} 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 (qaConfig.hostSuffix && window.location.hostname.endsWith(qaConfig.hostSuffix)) { + config = qaConfig } export const APPLICATION_NAME = config.applicationName From fe372949a5035504f82510bcf4ebca9f0d2d44fd Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Fri, 31 Jul 2026 12:25:27 -0700 Subject: [PATCH 02/10] :hammer: Make QA Auth0 and PIM setup reliable. --- bin/README.md | 114 ++++- bin/dev | 123 +++-- bin/lib/qa.rb | 1240 +++++++++++++++++++++++++++++++++++---------- bin/qa_test.rb | 315 ++++++++++-- web/src/config.ts | 6 +- 5 files changed, 1437 insertions(+), 361 deletions(-) diff --git a/bin/README.md b/bin/README.md index cc2175a2..17f34d05 100644 --- a/bin/README.md +++ b/bin/README.md @@ -14,48 +14,106 @@ 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. -## `qa` Command +## Temporary QA environments -Run the offline lifecycle checks through the normal wrapper: +`dev qa` creates and removes a disposable Azure Container Apps environment for +one pull request. It builds the exact PR commit in a detached worktree and +never changes the caller's checkout. ```bash -bin/dev test qa +bin/dev qa up --pr 50 --ttl-hours 4 +bin/dev qa status --pr 50 +bin/dev qa logs --pr 50 --follow +bin/dev qa down --pr 50 +bin/dev qa down --all --expired --yes ``` -`bin/qa` creates a disposable Azure Container Apps environment from a pull request: -```bash -bin/qa up --pr 50 --ttl-hours 4 -bin/qa status --pr 50 -bin/qa logs --pr 50 --follow -bin/qa down --pr 50 -``` - -The command builds an immutable PR SHA in a detached worktree, publishes a uniquely tagged image, creates an isolated SQL Server/Redis/MailDev application, and stores retryable state in `~/.traditional-knowledge-qa`. `bin/qa down --all --yes` is intentionally explicit. TTL is recorded for cleanup; it is not a scheduler. +The environment URL is +`https://tk-qa-.`. The frontend uses that same origin for +API requests, matching the newer production-style application configuration. +Each environment has isolated SQL Server, Redis, MailDev, and blob storage. +State is stored in `TK_QA_STATE_DIR` (default: +`~/.traditional-knowledge-qa`) so failed cleanup can be retried. TTL is +recorded; `down --all --expired --yes` performs explicit expiry cleanup. There +is no background scheduler. Before use, configure these local-only `TK_QA_*` variables: -- `TK_QA_RESOURCE_GROUP`, `TK_QA_ACA_ENVIRONMENT`, `TK_QA_ACR_SERVER`, `TK_QA_SUBSCRIPTION_ID` +- `TK_QA_RESOURCE_GROUP`, `TK_QA_ACA_ENVIRONMENT`, `TK_QA_ACR_SERVER`, + `TK_QA_SUBSCRIPTION_ID` - `TK_QA_DNS_SUFFIX`, with an ACA custom-domain suffix and wildcard certificate - `TK_QA_STORAGE_ACCOUNT`, `TK_QA_BLOB_CONNECTION_STRING`, `TK_QA_BLOB_CONTAINER` -- `TK_QA_AUTH0_DOMAIN`, `TK_QA_AUTH0_AUDIENCE`, `TK_QA_AUTH0_CLIENT_ID`, `TK_QA_AUTH0_MANAGEMENT_TOKEN` +- `TK_QA_AUTH0_MANAGEMENT_TOKEN` - `TK_QA_AUTH0_ALLOWED_HOST_SUFFIX`, exactly `.` -Configure the QA Auth0 application with these exact wildcard values, replacing `` with `TK_QA_DNS_SUFFIX`: + +The QA command defaults to the shared UAT Auth0 domain, audience, and client +ID. Set `TK_QA_AUTH0_DOMAIN`, `TK_QA_AUTH0_AUDIENCE`, or +`TK_QA_AUTH0_CLIENT_ID` only when using another compatible Auth0 application. + +Use the QA subscription (`artzzpr-sub` or its subscription ID), not the WRAP +subscription (`wrpzzpr-sub`). The command resolves a configured subscription +name to its ID before Azure REST calls. A developer with an eligible Azure +role can use the WRAP-style PIM self-activation; CI should instead use an +OIDC/service-principal identity with scoped write access to the QA resources. + +Configure the QA Auth0 application with these exact wildcard values, replacing +`` with `TK_QA_DNS_SUFFIX`: ```text Allowed Callback URLs: https://*./callback Allowed Logout URLs: https://*. -Allowed Web Origins: https://*. +Allowed Web Origins: https://*. ``` -The command refuses to provision unless `TK_QA_AUTH0_ALLOWED_HOST_SUFFIX` is `.`. Existing production and UAT Auth0 entries must remain unchanged. -`TK_QA_AUTH0_MANAGEMENT_TOKEN` must be a short-lived, local-only token with only `read:clients` scope. The command uses it only for the Auth0 preflight, never writes it to state, passes it to Azure, or includes it in error output. - -The resource group, ACA environment, storage account, and ACR must all carry the exact tag `traditional-knowledge-qa=true`. The blob connection string must belong to `TK_QA_STORAGE_ACCOUNT`; each PR receives a separate container and short-lived container SAS. Do not use production credentials or resources. `TK_QA_STATE_DIR`, `TK_QA_TIMEOUT_SECONDS`, and `TK_QA_HTTP_TIMEOUT_SECONDS` are optional overrides. - -Naming and lifecycle contracts: - -- The branch is fetched through `refs/pull//head` and verified against GitHub’s `headSha`; the detached worktree is removed after the ACR build. -- The image is `traditional-knowledge:qa-pr--`, the ACA app is `tk-qa-`, the environment ID is `pr-`, and the blob container is `-pr-`. -- Re-running `bin/qa up --pr ` updates that PR’s app to the current SHA, renews its container SAS, and removes the previous SHA image after the new revision is ready. -- Each ACA app runs one replica with SQL Server at `1 CPU/2 GiB`, the web container at `0.5 CPU/1 GiB`, and Redis/MailDev sidecars at `0.25 CPU/0.5 GiB` each. These limits are intentionally conservative defaults and should be tuned only with observed capacity needs. -- The operator who starts an environment is responsible for running `bin/qa down` before the recorded TTL. There is no automatic expiry worker yet. Failed app, blob, or image cleanup retains local state and returns non-zero so the operator can retry. +The command refuses to provision unless `TK_QA_AUTH0_ALLOWED_HOST_SUFFIX` is +`.`. Existing production and UAT Auth0 entries remain unchanged. +`TK_QA_AUTH0_MANAGEMENT_TOKEN` is a short-lived local-only token with only +`read:clients`; it is used only for preflight validation. + +The resource group, ACA environment, storage account, and ACR must all carry +`traditional-knowledge-qa=true`. The blob connection string must belong to +`TK_QA_STORAGE_ACCOUNT`; each PR receives a separate container and short-lived +container SAS. 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 QA +resource group; do not rely on interactive PIM in CI: + +```yaml +permissions: + contents: read + id-token: write + +env: + TK_QA_RESOURCE_GROUP: ${{ vars.TK_QA_RESOURCE_GROUP }} + TK_QA_ACA_ENVIRONMENT: ${{ vars.TK_QA_ACA_ENVIRONMENT }} + TK_QA_ACR_SERVER: ${{ vars.TK_QA_ACR_SERVER }} + TK_QA_SUBSCRIPTION_ID: ${{ vars.TK_QA_SUBSCRIPTION_ID }} + TK_QA_DNS_SUFFIX: ${{ vars.TK_QA_DNS_SUFFIX }} + TK_QA_STORAGE_ACCOUNT: ${{ vars.TK_QA_STORAGE_ACCOUNT }} + TK_QA_BLOB_CONTAINER: ${{ vars.TK_QA_BLOB_CONTAINER }} + TK_QA_AUTH0_DOMAIN: ${{ vars.TK_QA_AUTH0_DOMAIN }} + TK_QA_AUTH0_AUDIENCE: ${{ vars.TK_QA_AUTH0_AUDIENCE }} + TK_QA_AUTH0_CLIENT_ID: ${{ vars.TK_QA_AUTH0_CLIENT_ID }} + TK_QA_AUTH0_ALLOWED_HOST_SUFFIX: ${{ vars.TK_QA_AUTH0_ALLOWED_HOST_SUFFIX }} + TK_QA_BLOB_CONNECTION_STRING: ${{ secrets.TK_QA_BLOB_CONNECTION_STRING }} + TK_QA_AUTH0_MANAGEMENT_TOKEN: ${{ secrets.TK_QA_AUTH0_MANAGEMENT_TOKEN }} + TK_QA_STATE_DIR: ${{ runner.temp }}/traditional-knowledge-qa + GH_TOKEN: ${{ github.token }} + +steps: + - uses: actions/checkout@v4 + - uses: azure/login@v2 + with: + client-id: ${{ secrets.TK_QA_AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.TK_QA_AZURE_TENANT_ID }} + subscription-id: ${{ vars.TK_QA_SUBSCRIPTION_ID }} + - run: bin/dev qa up --pr "${{ github.event.pull_request.number }}" --ttl-hours 4 + - if: ${{ always() }} + run: bin/dev qa down --all --yes + +The workflow may use `workflow_dispatch` or `pull_request_target` according to +the repository's trust policy. Never expose Azure or Auth0 secrets to +untrusted fork code. diff --git a/bin/dev b/bin/dev index 7488f224..4387bbfe 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 @@ -68,13 +68,24 @@ class DevHelper 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) + compose( + *%w[up --remove-orphans --force-recreate], + *args, + execution_mode: WAIT_FOR_PROCESS, + gateway: true, + **kwargs + ) 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) + if compose( + *%w[down --remove-orphans], + *args, + execution_mode: WAIT_FOR_PROCESS, + **kwargs + ) LocalDevelopmentGateway.stop_if_unused end end @@ -100,7 +111,6 @@ class DevHelper run(*%w[api sh], *args, **kwargs) end - def web(*args, **kwargs) run(*%w[web], *args, **kwargs) end @@ -108,6 +118,14 @@ class DevHelper def api(*args, **kwargs) run(*%w[api], *args, **kwargs) end + def qa(*args, **kwargs) + unless kwargs.empty? + raise ArgumentError, + "Unexpected options for qa: #{kwargs.keys.join(", ")}" + end + + exec("ruby", File.join(project_root, "bin/qa"), *args) + end def archiver(*args, **kwargs) run(*%w[archiver], *args, **kwargs) @@ -122,8 +140,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) @@ -140,9 +162,16 @@ class DevHelper end def test_qa(*args, **kwargs) - raise ArgumentError, "Unexpected argument(s) for qa tests: #{args.join(" ")}" unless args.empty? + unless args.empty? + raise ArgumentError, + "Unexpected argument(s) for qa tests: #{args.join(" ")}" + end + unless kwargs.empty? + raise ArgumentError, + "Unexpected options for qa tests: #{kwargs.keys.join(", ")}" + end - exit 1 unless system("ruby", File.join(project_root, "bin/qa_test.rb")) + exec("ruby", File.join(project_root, "bin/qa_test.rb")) end def test_api(*args, **kwargs) @@ -157,7 +186,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) @@ -187,20 +221,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 \ @@ -212,8 +242,6 @@ class DevHelper -I #{args.join(" ")} ' BASH - **kwargs - ) end ## @@ -286,7 +314,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 ## @@ -294,13 +324,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}" @@ -315,7 +351,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 @@ -383,9 +420,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(" ") @@ -412,7 +455,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 @@ -423,15 +466,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 @@ -461,7 +507,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 @@ -469,7 +518,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 ## @@ -491,7 +542,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/qa.rb b/bin/lib/qa.rb index 6437558f..e74b37b7 100644 --- a/bin/lib/qa.rb +++ b/bin/lib/qa.rb @@ -16,8 +16,12 @@ module TraditionalKnowledgeQa APP_PREFIX = "tk-qa-" SCOPE_TAG = "traditional-knowledge-qa" DEFAULT_STATE_DIR = File.expand_path("~/.traditional-knowledge-qa") + UAT_AUTH0_DOMAIN = "https://yukon-staging.eu.auth0.com" + UAT_AUTH0_AUDIENCE = "generic-uat" + UAT_AUTH0_CLIENT_ID = "11878vWk1pmhwyVQwsr2m2zM3w3e912U" - class Error < StandardError; end + class Error < StandardError + end class CommandError < Error attr_reader :command, :output @@ -35,14 +39,20 @@ def run(*command) end def run_with_environment(environment, *command) - stdout, stderr, status = Open3.capture3({ "TK_QA_AUTH0_MANAGEMENT_TOKEN" => nil }.merge(environment), *command) + stdout, stderr, status = + Open3.capture3( + { "TK_QA_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) - raise Error, "Command failed (#{command.join(" ")})" unless system({ "TK_QA_AUTH0_MANAGEMENT_TOKEN" => nil }, *command) + unless system({ "TK_QA_AUTH0_MANAGEMENT_TOKEN" => nil }, *command) + raise Error, "Command failed (#{command.join(" ")})" + end end end @@ -56,7 +66,7 @@ def initialize(directory = ENV.fetch("TK_QA_STATE_DIR", DEFAULT_STATE_DIR)) def save(state) FileUtils.mkdir_p(directory, mode: 0o700) path = path_for(state.fetch("environment_id")) - Tempfile.create(["qa-", ".json"], directory, mode: 0o600) do |file| + Tempfile.create(%w[qa- .json], directory, mode: 0o600) do |file| file.write(JSON.pretty_generate(state)) file.flush File.rename(file.path, path) @@ -75,11 +85,14 @@ def find(environment_id) 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 QA state at #{path}: #{error.message}" - end + Dir + .glob(File.join(directory, "*.json")) + .sort + .filter_map do |path| + JSON.parse(File.read(path)) + rescue JSON::ParserError => error + raise Error, "Invalid QA state at #{path}: #{error.message}" + end end def delete(environment_id) @@ -91,7 +104,9 @@ def delete(environment_id) private def path_for(environment_id) - raise Error, "Invalid environment identifier" unless environment_id.match?(/\Apr-\d+\z/) + unless environment_id.match?(/\Apr-\d+\z/) + raise Error, "Invalid environment identifier" + end File.join(directory, "#{environment_id}.json") end @@ -102,12 +117,10 @@ class Config TK_QA_RESOURCE_GROUP TK_QA_ACA_ENVIRONMENT TK_QA_ACR_SERVER + TK_QA_SUBSCRIPTION_ID TK_QA_DNS_SUFFIX TK_QA_STORAGE_ACCOUNT TK_QA_AUTH0_ALLOWED_HOST_SUFFIX - TK_QA_AUTH0_DOMAIN - TK_QA_AUTH0_AUDIENCE - TK_QA_AUTH0_CLIENT_ID TK_QA_AUTH0_MANAGEMENT_TOKEN TK_QA_BLOB_CONNECTION_STRING TK_QA_BLOB_CONTAINER @@ -121,19 +134,22 @@ def initialize(environment = ENV) def validate! missing = REQUIRED.reject { |key| environment[key].to_s.strip != "" } - raise Error, "Missing QA configuration: #{missing.join(", ")}" unless missing.empty? + unless missing.empty? + raise Error, "Missing QA configuration: #{missing.join(", ")}" + end + validate_auth0_domain! blob_container(1) validate_scope! unless blob_connection_account == storage_account - raise Error, "TK_QA_BLOB_CONNECTION_STRING must belong to TK_QA_STORAGE_ACCOUNT" + raise Error, + "TK_QA_BLOB_CONNECTION_STRING must belong to TK_QA_STORAGE_ACCOUNT" end expected_suffix = ".#{dns_suffix}" unless auth0_allowed_host_suffix == expected_suffix - raise Error, "TK_QA_AUTH0_ALLOWED_HOST_SUFFIX must be #{expected_suffix.inspect}" + raise Error, + "TK_QA_AUTH0_ALLOWED_HOST_SUFFIX must be #{expected_suffix.inspect}" end - raise Error, "TK_QA_AUTH0_DOMAIN must use https://" unless auth0_domain.start_with?("https://") - self end @@ -142,16 +158,22 @@ def validate_cleanup! TK_QA_RESOURCE_GROUP TK_QA_ACA_ENVIRONMENT TK_QA_ACR_SERVER + TK_QA_SUBSCRIPTION_ID TK_QA_DNS_SUFFIX TK_QA_STORAGE_ACCOUNT TK_QA_BLOB_CONNECTION_STRING TK_QA_BLOB_CONTAINER ] missing = required.reject { |key| environment[key].to_s.strip != "" } - raise Error, "Missing QA cleanup configuration: #{missing.join(", ")}" unless missing.empty? + unless missing.empty? + raise Error, "Missing QA cleanup configuration: #{missing.join(", ")}" + end validate_scope! - raise Error, "TK_QA_BLOB_CONNECTION_STRING must belong to TK_QA_STORAGE_ACCOUNT" unless blob_connection_account == storage_account + unless blob_connection_account == storage_account + raise Error, + "TK_QA_BLOB_CONNECTION_STRING must belong to TK_QA_STORAGE_ACCOUNT" + end end def validate_scope! @@ -160,9 +182,12 @@ def validate_scope! "ACA environment" => aca_environment, "DNS suffix" => dns_suffix, "storage account" => storage_account, - "blob container" => blob_container_prefix, + "blob container" => blob_container_prefix } - unsafe = unsafe_values.select { |_label, value| value.match?(/production|prod(?:uction)?[-_\.]?/i) } + unsafe = + unsafe_values.select do |_label, value| + value.match?(/production|prod(?:uction)?[-_\.]?/i) + end return if unsafe.empty? labels = unsafe.keys.join(", ") @@ -173,31 +198,41 @@ def resource_group = fetch("TK_QA_RESOURCE_GROUP") def aca_environment = fetch("TK_QA_ACA_ENVIRONMENT") def acr_server = fetch("TK_QA_ACR_SERVER") def acr_name = acr_server.split(".").first - def dns_suffix = fetch("TK_QA_DNS_SUFFIX").sub(%r{\Ahttps?://}, "").sub(%r{/.*\z}, "") + def dns_suffix = + fetch("TK_QA_DNS_SUFFIX").sub(%r{\Ahttps?://}, "").sub(%r{/.*\z}, "") def scope_tag = SCOPE_TAG def storage_account = fetch("TK_QA_STORAGE_ACCOUNT") def auth0_allowed_host_suffix = fetch("TK_QA_AUTH0_ALLOWED_HOST_SUFFIX") - def auth0_domain = fetch("TK_QA_AUTH0_DOMAIN") - def auth0_audience = fetch("TK_QA_AUTH0_AUDIENCE") - def auth0_client_id = fetch("TK_QA_AUTH0_CLIENT_ID") + def auth0_domain = environment.fetch("TK_QA_AUTH0_DOMAIN", UAT_AUTH0_DOMAIN) + def auth0_audience = + environment.fetch("TK_QA_AUTH0_AUDIENCE", UAT_AUTH0_AUDIENCE) + def auth0_client_id = + environment.fetch("TK_QA_AUTH0_CLIENT_ID", UAT_AUTH0_CLIENT_ID) def auth0_management_token = fetch("TK_QA_AUTH0_MANAGEMENT_TOKEN") def blob_connection_string = fetch("TK_QA_BLOB_CONNECTION_STRING") - def blob_connection_account = blob_connection_string[/AccountName=([^;]+)/i, 1] + def blob_connection_account = + blob_connection_string[/AccountName=([^;]+)/i, 1] def blob_endpoint = blob_connection_string[/BlobEndpoint=([^;]+)/i, 1] def blob_container_prefix = fetch("TK_QA_BLOB_CONTAINER") def blob_container(pr_number) name = "#{blob_container_prefix}-pr-#{positive_pr_number(pr_number)}" - unless name.length.between?(3, 63) && name.match?(/\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/) && !name.include?("--") - raise Error, "QA blob container must be 3–63 lowercase letters, numbers, and single hyphens" + unless name.length.between?(3, 63) && + name.match?(/\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/) && + !name.include?("--") + raise Error, + "QA blob container must be 3–63 lowercase letters, numbers, and single hyphens" end name end def repository = environment.fetch("TK_QA_REPOSITORY", REPOSITORY) - def state_directory = environment.fetch("TK_QA_STATE_DIR", DEFAULT_STATE_DIR) - def subscription_id = environment["TK_QA_SUBSCRIPTION_ID"] - def timeout_seconds = Integer(environment.fetch("TK_QA_TIMEOUT_SECONDS", "300"), 10) - def http_timeout_seconds = Integer(environment.fetch("TK_QA_HTTP_TIMEOUT_SECONDS", "10"), 10) + def state_directory = + environment.fetch("TK_QA_STATE_DIR", DEFAULT_STATE_DIR) + def subscription_id = fetch("TK_QA_SUBSCRIPTION_ID") + def timeout_seconds = + Integer(environment.fetch("TK_QA_TIMEOUT_SECONDS", "300"), 10) + def http_timeout_seconds = + Integer(environment.fetch("TK_QA_HTTP_TIMEOUT_SECONDS", "10"), 10) def environment_id(pr_number) = "pr-#{positive_pr_number(pr_number)}" def app_name(pr_number) = "#{APP_PREFIX}#{positive_pr_number(pr_number)}" @@ -205,8 +240,7 @@ def public_url(pr_number) = "https://#{app_name(pr_number)}.#{dns_suffix}" def state_environment_matches?(state) state.fetch("resource_group") == resource_group && - state.fetch("aca_environment") == aca_environment && - (!subscription_id || state.fetch("subscription_id") == subscription_id) + state.fetch("aca_environment") == aca_environment end def positive_pr_number(pr_number) @@ -218,6 +252,12 @@ def positive_pr_number(pr_number) private + def validate_auth0_domain! + unless auth0_domain.start_with?("https://") + raise Error, "TK_QA_AUTH0_DOMAIN must use https://" + end + end + def fetch(key) value = environment[key].to_s.strip raise Error, "Missing QA configuration: #{key}" if value.empty? @@ -231,33 +271,51 @@ def initialize(runner, repository) @runner = runner @repository = repository end - def pull_request(number) - result = @runner.run( - "gh", "pr", "view", Integer(number).to_s, - "--repo", @repository, - "--json", "headRefName,headSha" - ) + 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(number, sha, root) ref = "refs/tk-qa/pr-#{Integer(number)}" - @runner.run("git", "-C", root, "fetch", "--force", "origin", "refs/pull/#{Integer(number)}/head:#{ref}") + @runner.run( + "git", + "-C", + root, + "fetch", + "--force", + "origin", + "refs/pull/#{Integer(number)}/head:#{ref}" + ) fetched_sha = @runner.run("git", "-C", root, "rev-parse", ref).strip if fetched_sha != sha - @runner.run("git", "-C", root, "update-ref", "-d", ref) rescue nil - raise Error, "Fetched PR commit #{fetched_sha} does not match GitHub SHA #{sha}" + begin + @runner.run("git", "-C", root, "update-ref", "-d", ref) + rescue StandardError + nil + end + raise Error, + "Fetched PR commit #{fetched_sha} does not match GitHub SHA #{sha}" end ref end - def remove_commit_ref(ref, root) @runner.run("git", "-C", root, "update-ref", "-d", ref) end end + class Auth0 def initialize(config, http_client: nil) @config = config @@ -265,27 +323,42 @@ def initialize(config, http_client: nil) end def validate! - uri = URI("#{@config.auth0_domain.sub(%r{/\z}, "")}/api/v2/clients/#{URI.encode_www_form_component(@config.auth0_client_id)}") - response = @http_client ? @http_client.get(uri, @config.auth0_management_token) : request(uri) - raise Error, "Auth0 client settings could not be read (HTTP #{response.code})" unless response.is_a?(Net::HTTPSuccess) + 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 + 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 QA client is missing: #{missing.join(", ")}" + 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) @@ -299,9 +372,16 @@ def request(uri) end end - 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 @@ -309,120 +389,257 @@ def initialize(runner, config) end def subscription_id - @subscription_id ||= @config.subscription_id || @runner.run("az", "account", "show", "--query", "id", "-o", "tsv").strip + @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 + "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 + "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 + "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 + "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 + 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}" + 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 + 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(" ")}" + 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") + "az", + "containerapp", + "show", + "--name", + state.fetch("app_name"), + "--resource-group", + state.fetch("resource_group"), + "--subscription", + state.fetch("subscription_id") ) rescue CommandError => error - return if error.output.match?(/not found|could not be found|ResourceNotFound/i) + 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 + 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 QA Container Apps environment" + raise Error, + "Refusing to delete an app outside the configured QA 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_QA_HOST_SUFFIX=#{host_suffix}", - "--build-arg", "VITE_QA_AUTH0_DOMAIN=#{@config.auth0_domain}", - "--build-arg", "VITE_QA_AUTH0_AUDIENCE=#{@config.auth0_audience}", - "--build-arg", "VITE_QA_AUTH0_CLIENT_ID=#{@config.auth0_client_id}", + "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_QA_HOST_SUFFIX=#{host_suffix}", + "--build-arg", + "VITE_QA_AUTH0_DOMAIN=#{@config.auth0_domain}", + "--build-arg", + "VITE_QA_AUTH0_AUDIENCE=#{@config.auth0_audience}", + "--build-arg", + "VITE_QA_AUTH0_CLIENT_ID=#{@config.auth0_client_id}", worktree ) end - def deploy(pr_number, sha, image_tag, expires_at, provision_blob_container:, remove_app_on_failure:) + def deploy( + pr_number, + sha, + image_tag, + expires_at, + provision_blob_container:, + remove_app_on_failure: + ) app_name = @config.app_name(pr_number) verify_app_scope!( "app_name" => app_name, "resource_group" => @config.resource_group, - "subscription_id" => subscription_id, + "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_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(pr_number) @@ -433,12 +650,23 @@ def deploy(pr_number, sha, image_tag, expires_at, provision_blob_container:, rem end app_put = false begin - blob_connection_string = container_sas_connection_string(blob_container, expires_at) - body = deployment_body( - pr_number:, qa_environment_id: @config.environment_id(pr_number), - managed_environment_id:, location:, image_tag:, sha:, expires_at:, - acr_user: acr_credentials.fetch("username"), acr_password:, db_password:, blob_container:, blob_connection_string: - ) + blob_connection_string = + container_sas_connection_string(blob_container, expires_at) + body = + deployment_body( + pr_number:, + qa_environment_id: @config.environment_id(pr_number), + 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) @@ -473,24 +701,58 @@ def deploy(pr_number, sha, image_tag, expires_at, provision_blob_container:, rem "aca_environment" => @config.aca_environment, "subscription_id" => subscription_id, "phase" => "ready", - "created_at" => Time.now.utc.iso8601, + "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_remote_app( + state.fetch("app_name"), + state.fetch("resource_group"), + state.fetch("subscription_id") + ) delete_blob_container(state.fetch("blob_container")) - delete_image(state.fetch("image_tag")) - previous_image_tag = state["previous_image_tag"] - delete_image(previous_image_tag) if previous_image_tag && previous_image_tag != state.fetch("image_tag") + delete_images_for_pr(state.fetch("pr_number")) + end + + def delete_images_for_pr(pr_number) + tags = + JSON.parse( + @runner.run( + "az", + "acr", + "repository", + "show-tags", + "--name", + @config.acr_name, + "--repository", + "traditional-knowledge", + "--subscription", + subscription_id, + "-o", + "json" + ) + ) + prefix = "qa-pr-#{Integer(pr_number)}-" + tags + .select { |tag| tag.start_with?(prefix) } + .each { |tag| delete_image(tag) } + rescue JSON::ParserError => error + raise Error, "Azure returned invalid QA 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}", + "az", + "acr", + "repository", + "delete", + "--name", + @config.acr_name, + "--subscription", + subscription_id, + "--image", + "traditional-knowledge:#{image_tag}", "--yes" ) rescue CommandError => error @@ -499,21 +761,36 @@ def delete_image(image_tag) 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" + "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", + "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) @@ -521,39 +798,248 @@ def logs(state, follow: false) 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 QA 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 QA 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 + def put_app(app_name, body) - Tempfile.create(["tk-qa-", ".json"]) do |file| + Tempfile.create(%w[tk-qa- .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") + 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" + "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 - raise Error, "Azure did not return a SAS for blob container #{name}" if sas.empty? + 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 - raise Error, "TK_QA_BLOB_CONNECTION_STRING has no BlobEndpoint" if endpoint.to_s.empty? + if endpoint.to_s.empty? + raise Error, "TK_QA_BLOB_CONNECTION_STRING has no BlobEndpoint" + end "BlobEndpoint=#{endpoint};SharedAccessSignature=#{sas.delete_prefix("?")}" end @@ -561,10 +1047,16 @@ def container_sas_connection_string(name, expires_at) 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" + "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) @@ -572,14 +1064,23 @@ def delete_blob_container(name) 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, + "az", + "containerapp", + "delete", + "--name", + app_name, + "--resource-group", + resource_group, + "--subscription", + subscription_id, "--yes" ) rescue CommandError => error - raise unless error.output.match?(/not found|could not be found|ResourceNotFound/i) + unless error.output.match?( + /not found|could not be found|ResourceNotFound/i + ) + raise + end end def secure_database_password @@ -588,23 +1089,42 @@ def secure_database_password 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 + 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 + 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" - raise Error, "Azure provisioning failed for #{app_name}; inspect with `az containerapp logs show --name #{app_name} --resource-group #{@config.resource_group} --container web`" if state == "Failed" - 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`" if Time.now >= deadline + 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 @@ -619,20 +1139,47 @@ def wait_for_http(url, app_name, expected_sha) http.open_timeout = @config.http_timeout_seconds http.read_timeout = @config.http_timeout_seconds response = http.get(uri.request_uri) - payload = JSON.parse(response.body) rescue {} - return if response.is_a?(Net::HTTPSuccess) && payload["status"] == "ok" && payload["gitCommitHash"] == expected_sha - raise Error, "Timed out waiting for #{url}/qa-status; inspect with `az containerapp logs show --name #{app_name} --resource-group #{@config.resource_group} --container web`" if Time.now >= deadline + payload = + begin + JSON.parse(response.body) + rescue StandardError + {} + end + if response.is_a?(Net::HTTPSuccess) && payload["status"] == "ok" && + payload["gitCommitHash"] == expected_sha + return + end + if Time.now >= deadline + raise Error, + "Timed out waiting for #{url}/qa-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) - raise Error, "Timed out waiting for #{url}/qa-status; inspect with `az containerapp logs show --name #{app_name} --resource-group #{@config.resource_group} --container web`" if Time.now >= deadline + if Time.now >= deadline + raise Error, + "Timed out waiting for #{url}/qa-status; inspect with `az containerapp logs show --name #{app_name} --resource-group #{@config.resource_group} --container web`" + end sleep 5 end end - def deployment_body(pr_number:, qa_environment_id:, managed_environment_id:, location:, image_tag:, sha:, expires_at:, acr_user:, acr_password:, db_password:, blob_container:, blob_connection_string:) + def deployment_body( + pr_number:, + qa_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(pr_number) { "location" => location, @@ -640,43 +1187,79 @@ def deployment_body(pr_number:, qa_environment_id:, managed_environment_id:, loc "traditional-knowledge-qa" => "true", "qa-environment" => qa_environment_id, "qa-pr-sha" => sha, - "qa-expires" => expires_at, + "qa-expires" => expires_at }, "properties" => { "managedEnvironmentId" => managed_environment_id, "configuration" => { "activeRevisionsMode" => "Single", - "ingress" => { "external" => true, "targetPort" => 3000, "transport" => "auto" }, + "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 }, + { "name" => "blob-connection", "value" => blob_connection_string } ], - "registries" => [{ "server" => @config.acr_server, "username" => acr_user, "passwordSecretRef" => "acr-password" }], + "registries" => [ + { + "server" => @config.acr_server, + "username" => acr_user, + "passwordSecretRef" => "acr-password" + } + ] }, "template" => { - "scale" => { "minReplicas" => 1, "maxReplicas" => 1 }, + "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" }, + "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" => "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" => "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" => "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" }, + "name" => "web", + "image" => + "#{@config.acr_server}/traditional-knowledge:#{image_tag}", + "resources" => { + "cpu" => 0.5, + "memory" => "1.0Gi" + }, "env" => [ { "name" => "NODE_ENV", "value" => "production" }, { "name" => "QA_ENVIRONMENT", "value" => "true" }, @@ -685,22 +1268,39 @@ def deployment_body(pr_number:, qa_environment_id:, managed_environment_id:, loc { "name" => "DB_PORT", "value" => "1433" }, { "name" => "DB_USERNAME", "value" => "sa" }, { "name" => "DB_PASSWORD", "secretRef" => "db-password" }, - { "name" => "DB_DATABASE", "value" => "traditional_knowledge_qa" }, - { "name" => "DB_TRUST_SERVER_CERTIFICATE", "value" => "true" }, - { "name" => "REDIS_CONNECTION_URL", "value" => "redis://localhost:6379" }, + { + "name" => "DB_DATABASE", + "value" => "traditional_knowledge_qa" + }, + { + "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_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 }, - { "name" => "VITE_AUTH0_CLIENT_ID", "value" => @config.auth0_client_id }, - ], - }, - ], - }, - }, + { + "name" => "VITE_AUTH0_DOMAIN", + "value" => @config.auth0_domain + }, + { + "name" => "VITE_AUTH0_AUDIENCE", + "value" => @config.auth0_audience + } + ] + } + ] + } + } } end end @@ -713,7 +1313,16 @@ def initialize(runner, root) def with(sha) Dir.mktmpdir("tk-qa-build-") do |path| - @runner.run("git", "-C", @root, "worktree", "add", "--detach", path, sha) + @runner.run( + "git", + "-C", + @root, + "worktree", + "add", + "--detach", + path, + sha + ) begin yield path ensure @@ -724,7 +1333,11 @@ def with(sha) end class Application - def initialize(runner: Runner.new, environment: ENV, root: File.expand_path("../..", __dir__)) + def initialize( + runner: Runner.new, + environment: ENV, + root: File.expand_path("../..", __dir__) + ) @runner = runner @config = Config.new(environment) @root = root @@ -733,7 +1346,6 @@ def initialize(runner: Runner.new, environment: ENV, root: File.expand_path("../ def up(pr_number, ttl_hours) @config.validate! - Auth0.new(@config).validate! github = GitHub.new(@runner, @config.repository) pr = github.pull_request(pr_number) sha = pr.fetch("headSha") @@ -746,17 +1358,31 @@ def up(pr_number, ttl_hours) ref = github.fetch_commit(pr_number, 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 + 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(pr_number), "pr_number" => Integer(pr_number), "sha" => sha, "image_tag" => image_tag, - "previous_image_tag" => existing_state && existing_state["image_tag"] != image_tag ? existing_state["image_tag"] : nil, + "previous_image_tag" => + ( + if existing_state && existing_state["image_tag"] != image_tag + existing_state["image_tag"] + else + nil + end + ), "blob_container" => @config.blob_container(pr_number), "public_url" => @config.public_url(pr_number), "expires_at" => expires_at, @@ -765,14 +1391,24 @@ def up(pr_number, ttl_hours) "subscription_id" => azure.subscription_id, "phase" => "provisioning", "preserve_existing_resources" => preserve_existing_resources, - "created_at" => Time.now.utc.iso8601, + "created_at" => Time.now.utc.iso8601 ) - state = azure.deploy( - pr_number, sha, image_tag, expires_at, - provision_blob_container: !preserve_existing_resources, - remove_app_on_failure: !preserve_existing_resources + state = + azure.deploy( + pr_number, + 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["previous_image_tag"] = existing_state && existing_state["image_tag"] != image_tag ? existing_state["image_tag"] : nil state["preserve_existing_resources"] = preserve_existing_resources @store.save(state) if existing_state && existing_state["image_tag"] != image_tag @@ -799,7 +1435,9 @@ def list puts "No QA environments." return end - states.each { |state| puts "#{state.fetch("environment_id")} #{state.fetch("public_url")} #{state.fetch("sha")} expires #{state.fetch("expires_at")}" } + 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(pr_number) @@ -830,11 +1468,26 @@ def scoped_state_for(pr_number) state end - def state_for(pr_number) + def state_for(pr_number, reconstruct: false) state = @store.find(@config.environment_id(pr_number)) - raise Error, "No QA state for PR #{pr_number}." unless state + return state if state + return reconstruct_state(pr_number) if reconstruct - state + raise Error, "No QA state for PR #{pr_number}." + end + + def reconstruct_state(pr_number) + azure = Azure.new(@runner, @config) + { + "environment_id" => @config.environment_id(pr_number), + "app_name" => @config.app_name(pr_number), + "pr_number" => Integer(pr_number), + "blob_container" => @config.blob_container(pr_number), + "resource_group" => @config.resource_group, + "aca_environment" => @config.aca_environment, + "subscription_id" => azure.subscription_id, + "phase" => "ready" + } end def safety_check!(state, azure) @@ -843,21 +1496,41 @@ def safety_check!(state, azure) raise Error, "Refusing to delete an unexpected QA blob container" end - unless state.fetch("app_name").start_with?(APP_PREFIX) && @config.state_environment_matches?(state) - raise Error, "Refusing to delete an environment outside the configured QA scope" + 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 QA scope" end azure.verify_app_scope!(state) end public - def down(pr_number, all: false, confirmed: false) + + def down(pr_number, 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 = all ? @store.all : [state_for(pr_number)] - raise Error, "No QA environments." if states.empty? + 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(pr_number, reconstruct: true)] + end + if states.empty? + puts expired ? "No expired QA environments." : "No QA environments." + return + end failures = [] states.each do |state| @@ -872,25 +1545,51 @@ def down(pr_number, all: false, confirmed: false) warn "Could not delete #{state.fetch("environment_id")} (state retained): #{error.message}" end end - raise Error, "#{failures.length} QA environment deletion(s) failed" unless failures.empty? + unless failures.empty? + raise Error, "#{failures.length} QA environment deletion(s) failed" + end end - end def self.run(argv) command = argv.shift return puts(help) if command.nil? || %w[help --help -h].include?(command) - options = { ttl_hours: 4, follow: false, all: false, yes: false } - parser = OptionParser.new do |option_parser| - option_parser.banner = "Usage: bin/qa [options]" - option_parser.on("--pr NUMBER", Integer, "PR number") { |value| options[:pr] = value } - 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") { options[:all] = true } - option_parser.on("--yes", "Confirm a destructive --all operation") { options[:yes] = true } - option_parser.on("--help", "Show help") { puts option_parser; exit } - end + options = { + ttl_hours: 4, + follow: false, + all: false, + expired: false, + yes: false + } + parser = + OptionParser.new do |option_parser| + option_parser.banner = + "Usage: bin/qa [options]" + option_parser.on("--pr NUMBER", Integer, "PR number") do |value| + options[:pr] = 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? @@ -898,7 +1597,9 @@ def self.run(argv) case command when "up" raise Error, "up requires --pr NUMBER" unless options[:pr] - raise Error, "--ttl-hours must be positive" unless options[:ttl_hours].positive? + unless options[:ttl_hours].positive? + raise Error, "--ttl-hours must be positive" + end application.up(options[:pr], options[:ttl_hours]) when "list" application.list @@ -909,8 +1610,15 @@ def self.run(argv) raise Error, "logs requires --pr NUMBER" unless options[:pr] application.logs(options[:pr], options[:follow]) when "down" - raise Error, "down requires --pr NUMBER or --all" unless options[:pr] || options[:all] - application.down(options[:pr], all: options[:all], confirmed: options[:yes]) + unless options[:pr] || options[:all] + raise Error, "down requires --pr NUMBER or --all" + end + application.down( + options[:pr], + all: options[:all], + expired: options[:expired], + confirmed: options[:yes] + ) else raise Error, "Unknown command: #{command}" end @@ -929,7 +1637,7 @@ def self.help bin/qa status --pr NUMBER bin/qa logs --pr NUMBER [--follow] bin/qa down --pr NUMBER - bin/qa down --all --yes + bin/qa down --all --expired --yes `up` requires TK_QA_* configuration for a non-production Azure Container Apps environment. See README.md for the required variables and Auth0 wildcard setup. diff --git a/bin/qa_test.rb b/bin/qa_test.rb index 2387c27c..612e759b 100755 --- a/bin/qa_test.rb +++ b/bin/qa_test.rb @@ -29,12 +29,14 @@ def environment(state_directory, **overrides) "TK_QA_AUTH0_AUDIENCE" => "traditional-knowledge-qa", "TK_QA_AUTH0_CLIENT_ID" => "qa-client", "TK_QA_AUTH0_MANAGEMENT_TOKEN" => "qa-management-token", - "TK_QA_BLOB_CONNECTION_STRING" => "DefaultEndpointsProtocol=https;AccountName=tkqaassets;AccountKey=not-used;BlobEndpoint=https://tkqaassets.blob.core.windows.net/", + "TK_QA_BLOB_CONNECTION_STRING" => + "DefaultEndpointsProtocol=https;AccountName=tkqaassets;AccountKey=not-used;BlobEndpoint=https://tkqaassets.blob.core.windows.net/", "TK_QA_BLOB_CONTAINER" => "tk-qa", "TK_QA_STATE_DIR" => state_directory, - "TK_QA_SUBSCRIPTION_ID" => "qa-subscription", + "TK_QA_SUBSCRIPTION_ID" => "qa-subscription" }.merge(overrides) end + class Auth0HttpClient attr_reader :token @@ -46,7 +48,7 @@ def get(_uri, token) JSON.generate( "callbacks" => ["https://*.qa.example.com/callback"], "allowed_logout_urls" => ["https://*.qa.example.com"], - "web_origins" => ["https://*.qa.example.com"], + "web_origins" => ["https://*.qa.example.com"] ) ) response.instance_variable_set(:@read, true) @@ -64,10 +66,25 @@ def initialize def run(*command) @commands << command return "true\n" if command.include?("tags['traditional-knowledge-qa']") - return "qa.example.com\n" if command.include?("properties.customDomainConfiguration.dnsSuffix") - return "/subscriptions/qa/resourceGroups/CapAero_QA/providers/Microsoft.App/managedEnvironments/tk-qa-env\n" if command.include?("--query") && command.include?("id") - return "/subscriptions/qa/resourceGroups/CapAero_QA/providers/Microsoft.App/managedEnvironments/tk-qa-env\n" if command.include?("properties.managedEnvironmentId") - raise TraditionalKnowledgeQa::CommandError.new(command, "simulated delete failure") if command.include?("containerapp") && command.include?("delete") + if command.include?("properties.customDomainConfiguration.dnsSuffix") + return "qa.example.com\n" + end + if command.include?("--query") && command.include?("id") + return( + "/subscriptions/qa/resourceGroups/CapAero_QA/providers/Microsoft.App/managedEnvironments/tk-qa-env\n" + ) + end + if command.include?("properties.managedEnvironmentId") + return( + "/subscriptions/qa/resourceGroups/CapAero_QA/providers/Microsoft.App/managedEnvironments/tk-qa-env\n" + ) + end + if command.include?("containerapp") && command.include?("delete") + raise TraditionalKnowledgeQa::CommandError.new( + command, + "simulated delete failure" + ) + end "" end @@ -77,30 +94,253 @@ def run_with_environment(environment, *command) end end + class PimRunner + def initialize(eligibilities, roles) + @eligibilities = eligibilities + @roles = roles + end + + def run(*command) + joined = command.join(" ") + if command.include?("--query") && command.include?("id") + return "qa-subscription\n" + end + if joined.include?("roleEligibilityScheduleInstances") + return JSON.generate("value" => @eligibilities) + end + + role_id = @roles.keys.find { |id| joined.include?(id) } + if role_id + return( + JSON.generate( + "properties" => { + "permissions" => @roles.fetch(role_id) + } + ) + ) + end + + raise "Unexpected PIM command: #{joined}" + end + end + class BuildRunner + attr_reader :commands + + def initialize + @commands = [] + end + + def run(*command) + @commands << command + if command.include?("--query") && command.include?("id") + return "qa-subscription\n" + end + + "" + end + end + + def eligibility(scope, role_id) + { + "properties" => { + "scope" => scope, + "roleDefinitionId" => + "/subscriptions/qa/providers/Microsoft.Authorization/roleDefinitions/#{role_id}", + "principalId" => "principal", + "roleEligibilityScheduleId" => "/eligibility/1" + } + } + end + Dir.mktmpdir("tk-qa-test-") do |state_directory| config = TraditionalKnowledgeQa::Config.new(environment(state_directory)) config.validate! auth0_client = Auth0HttpClient.new - TraditionalKnowledgeQa::Auth0.new(config, http_client: auth0_client).validate! - assert(auth0_client.token == "qa-management-token", "Auth0 preflight must use the management token") - cleanup_keys = %w[TK_QA_RESOURCE_GROUP TK_QA_ACA_ENVIRONMENT TK_QA_ACR_SERVER TK_QA_DNS_SUFFIX TK_QA_STORAGE_ACCOUNT TK_QA_BLOB_CONNECTION_STRING TK_QA_BLOB_CONTAINER TK_QA_STATE_DIR TK_QA_SUBSCRIPTION_ID] - TraditionalKnowledgeQa::Config.new(environment(state_directory).slice(*cleanup_keys)).validate_cleanup! - assert(config.environment_id(12) == "pr-12", "PR environment IDs must be stable") + TraditionalKnowledgeQa::Auth0.new( + config, + http_client: auth0_client + ).validate! + assert( + auth0_client.token == "qa-management-token", + "Auth0 preflight must use the management token" + ) + cleanup_keys = %w[ + TK_QA_RESOURCE_GROUP + TK_QA_ACA_ENVIRONMENT + TK_QA_ACR_SERVER + TK_QA_DNS_SUFFIX + TK_QA_STORAGE_ACCOUNT + TK_QA_BLOB_CONNECTION_STRING + TK_QA_BLOB_CONTAINER + TK_QA_STATE_DIR + TK_QA_SUBSCRIPTION_ID + ] + TraditionalKnowledgeQa::Config.new( + environment(state_directory).slice(*cleanup_keys) + ).validate_cleanup! + assert( + config.environment_id(12) == "pr-12", + "PR environment IDs must be stable" + ) assert(config.app_name(12) == "tk-qa-12", "Azure app names must be stable") - assert(config.blob_container(12) == "tk-qa-pr-12", "blob containers must be per PR") - assert_raises(TraditionalKnowledgeQa::Error, "production-looking resources must be rejected") do - TraditionalKnowledgeQa::Config.new(environment(state_directory, "TK_QA_RESOURCE_GROUP" => "production")).validate! + assert( + config.blob_container(12) == "tk-qa-pr-12", + "blob containers must be per PR" + ) + assert_raises( + TraditionalKnowledgeQa::Error, + "production-looking resources must be rejected" + ) do + TraditionalKnowledgeQa::Config.new( + environment(state_directory, "TK_QA_RESOURCE_GROUP" => "production") + ).validate! end - assert_raises(TraditionalKnowledgeQa::Error, "insecure Auth0 domains must be rejected") do - TraditionalKnowledgeQa::Config.new(environment(state_directory, "TK_QA_AUTH0_DOMAIN" => "http://qa.example.auth0.com")).validate! + assert_raises( + TraditionalKnowledgeQa::Error, + "insecure Auth0 domains must be rejected" + ) do + TraditionalKnowledgeQa::Config.new( + environment( + state_directory, + "TK_QA_AUTH0_DOMAIN" => "http://qa.example.auth0.com" + ) + ).validate! end - assert_raises(TraditionalKnowledgeQa::Error, "invalid blob names must be rejected") do - TraditionalKnowledgeQa::Config.new(environment(state_directory, "TK_QA_BLOB_CONTAINER" => "TK-INVALID")).validate! + assert_raises( + TraditionalKnowledgeQa::Error, + "invalid blob names must be rejected" + ) do + TraditionalKnowledgeQa::Config.new( + environment(state_directory, "TK_QA_BLOB_CONTAINER" => "TK-INVALID") + ).validate! end - application_without_initialization = TraditionalKnowledgeQa::Application.allocate - assert(application_without_initialization.send(:preserve_existing_resources?, { "phase" => "ready" }), "ready state must preserve resources") - assert(application_without_initialization.send(:preserve_existing_resources?, { "preserve_existing_resources" => true }), "interrupted update must preserve resources") - assert(!application_without_initialization.send(:preserve_existing_resources?, { "phase" => "provisioning" }), "initial provisioning must not preserve resources") + + subscription_scope = "/subscriptions/qa-subscription" + resource_group_scope = + "/subscriptions/qa-subscription/resourceGroups/CapAero_QA" + insufficient = eligibility(resource_group_scope, "insufficient") + sufficient = eligibility(subscription_scope, "sufficient") + runner = + PimRunner.new( + [insufficient, sufficient], + { + "insufficient" => [ + { "actions" => ["Microsoft.Storage/*"], "notActions" => [] } + ], + "sufficient" => [ + { + "actions" => ["Microsoft.App/containerApps/write"], + "notActions" => [] + } + ] + } + ) + azure = TraditionalKnowledgeQa::Azure.new(runner, config) + selected = azure.send(:activation_eligibility) + assert( + selected == sufficient, + "PIM must select an eligible role that grants Container Apps write access" + ) + + insufficient_runner = + PimRunner.new( + [insufficient], + { + "insufficient" => [ + { "actions" => ["Microsoft.Storage/*"], "notActions" => [] } + ] + } + ) + insufficient_azure = + TraditionalKnowledgeQa::Azure.new(insufficient_runner, config) + assert( + insufficient_azure.send(:activation_eligibility).nil?, + "PIM must reject eligible roles without Container Apps write access" + ) + override_config = + TraditionalKnowledgeQa::Config.new( + environment( + state_directory, + "TK_QA_AUTH0_DOMAIN" => "https://custom.example.auth0.com", + "TK_QA_AUTH0_AUDIENCE" => "custom-audience", + "TK_QA_AUTH0_CLIENT_ID" => "custom-client" + ) + ) + build_runner = BuildRunner.new + TraditionalKnowledgeQa::Azure.new( + build_runner, + override_config + ).build_image("/tmp/worktree", "qa-pr-12-tag", "a" * 40, ".qa.example.com") + build_command = build_runner.commands.last + assert( + build_command.include?( + "VITE_QA_AUTH0_DOMAIN=https://custom.example.auth0.com" + ) && build_command.include?("VITE_QA_AUTH0_AUDIENCE=custom-audience") && + build_command.include?("VITE_QA_AUTH0_CLIENT_ID=custom-client"), + "Auth0 overrides must be passed into the QA web image build" + ) + deployment = + TraditionalKnowledgeQa::Azure.new(build_runner, override_config).send( + :deployment_body, + pr_number: 12, + qa_environment_id: "pr-12", + managed_environment_id: "/managed-environments/qa", + location: "canadacentral", + image_tag: "qa-pr-12-tag", + sha: "a" * 40, + expires_at: "2026-07-24T00:00:00Z", + acr_user: "acr-user", + acr_password: "acr-password", + db_password: "db-password", + blob_container: "tk-qa-pr-12", + blob_connection_string: + "BlobEndpoint=https://blob.example;SharedAccessSignature=sas" + ) + web_container = + deployment + .fetch("properties") + .fetch("template") + .fetch("containers") + .find { |container| container.fetch("name") == "web" } + web_environment = + web_container + .fetch("env") + .select do |entry| + %w[VITE_AUTH0_DOMAIN VITE_AUTH0_AUDIENCE].include?( + entry.fetch("name") + ) + end + .to_h { |entry| [entry.fetch("name"), entry.fetch("value")] } + assert( + web_environment["VITE_AUTH0_DOMAIN"] == + "https://custom.example.auth0.com" && + web_environment["VITE_AUTH0_AUDIENCE"] == "custom-audience", + "Auth0 overrides must be passed into the QA API runtime" + ) + + application_without_initialization = + TraditionalKnowledgeQa::Application.allocate + assert( + application_without_initialization.send( + :preserve_existing_resources?, + { "phase" => "ready" } + ), + "ready state must preserve resources" + ) + assert( + application_without_initialization.send( + :preserve_existing_resources?, + { "preserve_existing_resources" => true } + ), + "interrupted update must preserve resources" + ) + assert( + !application_without_initialization.send( + :preserve_existing_resources?, + { "phase" => "provisioning" } + ), + "initial provisioning must not preserve resources" + ) store = TraditionalKnowledgeQa::StateStore.new(state_directory) store.save( @@ -115,21 +355,36 @@ def run_with_environment(environment, *command) "resource_group" => "CapAero_QA", "aca_environment" => "tk-qa-env", "subscription_id" => "qa-subscription", - "phase" => "ready", + "phase" => "ready" + ) + assert( + store.find("pr-12").fetch("sha") == "a" * 40, + "state must persist PR metadata" ) - assert(store.find("pr-12").fetch("sha") == "a" * 40, "state must persist PR metadata") - runner = FailingDeleteRunner.new - application = TraditionalKnowledgeQa::Application.new(runner:, environment: environment(state_directory)) - assert_raises(TraditionalKnowledgeQa::Error, "cleanup failure must fail the command") { application.down(12) } + application = + TraditionalKnowledgeQa::Application.new( + runner:, + environment: environment(state_directory) + ) + assert_raises( + TraditionalKnowledgeQa::Error, + "cleanup failure must fail the command" + ) { application.down(12) } assert(store.find("pr-12"), "cleanup failure must retain state for retry") tampered_state = store.find("pr-12") tampered_state["blob_container"] = "tk-qa-pr-999" store.save(tampered_state) command_count = runner.commands.length - assert_raises(TraditionalKnowledgeQa::Error, "unexpected blob containers must be rejected") { application.down(12) } + assert_raises( + TraditionalKnowledgeQa::Error, + "unexpected blob containers must be rejected" + ) { application.down(12) } new_commands = runner.commands.drop(command_count) - assert(new_commands.none? { |command| command.include?("delete") }, "unexpected blob containers must not be deleted") + assert( + new_commands.none? { |command| command.include?("delete") }, + "unexpected blob containers must not be deleted" + ) end puts "QA command checks passed" diff --git a/web/src/config.ts b/web/src/config.ts index 01340fa5..bc152f5e 100644 --- a/web/src/config.ts +++ b/web/src/config.ts @@ -36,9 +36,9 @@ const localProductionConfig = { } const qaConfig = { hostSuffix: import.meta.env.VITE_QA_HOST_SUFFIX || "", - domain: import.meta.env.VITE_QA_AUTH0_DOMAIN || "", - clientId: import.meta.env.VITE_QA_AUTH0_CLIENT_ID || "", - audience: import.meta.env.VITE_QA_AUTH0_AUDIENCE || "", + domain: import.meta.env.VITE_QA_AUTH0_DOMAIN || uatConfig.domain, + clientId: import.meta.env.VITE_QA_AUTH0_CLIENT_ID || uatConfig.clientId, + audience: import.meta.env.VITE_QA_AUTH0_AUDIENCE || uatConfig.audience, apiBaseUrl: "", applicationName: "Traditional Knowledge - QA", } From 3d10fc818ea445e30a06b9207d3a44e1d7142889 Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Fri, 31 Jul 2026 14:29:37 -0700 Subject: [PATCH 03/10] :hammer: Replace QA workflow with temporary deployment command. --- CHANGELOG.md | 2 +- Dockerfile | 16 +- Gemfile | 2 +- Gemfile.lock | 6 +- api/src/app.ts | 7 +- api/src/config.ts | 1 - bin/README.md | 135 +- bin/deploy | 12 + bin/dev | 56 +- bin/lib/deploy/all.rb | 14 + bin/lib/deploy/temporary/application.rb | 231 +++ bin/lib/deploy/temporary/azure.rb | 430 +++++ bin/lib/deploy/temporary/azure/access.rb | 181 ++ .../deploy/temporary/azure/deployment_body.rb | 142 ++ bin/lib/deploy/temporary/azure/resources.rb | 197 ++ bin/lib/deploy/temporary/command.rb | 122 ++ bin/lib/deploy/temporary/constants.rb | 23 + bin/lib/deploy/temporary/support/auth0.rb | 57 + bin/lib/deploy/temporary/support/config.rb | 156 ++ bin/lib/deploy/temporary/support/errors.rb | 37 + bin/lib/deploy/temporary/support/github.rb | 73 + bin/lib/deploy/temporary/support/source.rb | 75 + .../deploy/temporary/support/state_store.rb | 63 + bin/lib/deploy/temporary/support/worktree.rb | 28 + bin/lib/qa.rb | 1648 ----------------- bin/qa | 5 - bin/qa_test.rb | 391 ---- web/src/config.ts | 16 +- 28 files changed, 1949 insertions(+), 2177 deletions(-) create mode 100755 bin/deploy create mode 100644 bin/lib/deploy/all.rb create mode 100644 bin/lib/deploy/temporary/application.rb create mode 100644 bin/lib/deploy/temporary/azure.rb create mode 100644 bin/lib/deploy/temporary/azure/access.rb create mode 100644 bin/lib/deploy/temporary/azure/deployment_body.rb create mode 100644 bin/lib/deploy/temporary/azure/resources.rb create mode 100644 bin/lib/deploy/temporary/command.rb create mode 100644 bin/lib/deploy/temporary/constants.rb create mode 100644 bin/lib/deploy/temporary/support/auth0.rb create mode 100644 bin/lib/deploy/temporary/support/config.rb create mode 100644 bin/lib/deploy/temporary/support/errors.rb create mode 100644 bin/lib/deploy/temporary/support/github.rb create mode 100644 bin/lib/deploy/temporary/support/source.rb create mode 100644 bin/lib/deploy/temporary/support/state_store.rb create mode 100644 bin/lib/deploy/temporary/support/worktree.rb delete mode 100644 bin/lib/qa.rb delete mode 100755 bin/qa delete mode 100755 bin/qa_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 62770764..793714a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,4 +6,4 @@ All notable changes to this project will be documented in this file. ### Added -- Added a local `bin/qa` workflow for isolated per-PR Azure Container Apps QA environments, including readiness checks, persisted cleanup state, and non-production resource guardrails. +- 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. diff --git a/Dockerfile b/Dockerfile index d448ef62..2ac94b42 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,15 +38,15 @@ RUN npm clean-install COPY web ./ -ARG VITE_QA_HOST_SUFFIX -ARG VITE_QA_AUTH0_DOMAIN -ARG VITE_QA_AUTH0_AUDIENCE -ARG VITE_QA_AUTH0_CLIENT_ID +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_QA_HOST_SUFFIX=${VITE_QA_HOST_SUFFIX} -ENV VITE_QA_AUTH0_DOMAIN=${VITE_QA_AUTH0_DOMAIN} -ENV VITE_QA_AUTH0_AUDIENCE=${VITE_QA_AUTH0_AUDIENCE} -ENV VITE_QA_AUTH0_CLIENT_ID=${VITE_QA_AUTH0_CLIENT_ID} +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/app.ts b/api/src/app.ts index 52d20cd1..fffd14f2 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -4,7 +4,7 @@ import path from "path" import helmet from "helmet" import formData from "express-form-data" -import { AUTH0_DOMAIN, FRONTEND_URL, GIT_COMMIT_HASH, QA_ENVIRONMENT, RELEASE_TAG } from "@/config" +import { AUTH0_DOMAIN, FRONTEND_URL } from "@/config" import { betterFormDataBodyParserMiddleware, requestLoggerMiddleware } from "@/middlewares" import router from "@/router" import enhancedQsDecoder from "@/utils/enhanced-qs-decoder" @@ -46,11 +46,6 @@ app.use( app.use(requestLoggerMiddleware) -if (QA_ENVIRONMENT) { - app.get("/qa-status", (_request: Request, response: Response) => { - response.json({ status: "ok", releaseTag: RELEASE_TAG, gitCommitHash: GIT_COMMIT_HASH }) - }) -} app.use(router) // serves the static files generated by the front-end diff --git a/api/src/config.ts b/api/src/config.ts index 08ed8782..7183a4c3 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -48,7 +48,6 @@ export const RUN_SCHEDULER = process.env.RUN_SCHEDULER || "false" 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 17f34d05..70a12d97 100644 --- a/bin/README.md +++ b/bin/README.md @@ -14,50 +14,51 @@ 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 QA environments +## Temporary deployments -`dev qa` creates and removes a disposable Azure Container Apps environment for -one pull request. It builds the exact PR commit in a detached worktree and -never changes the caller's checkout. +`bin/deploy temporary` builds the selected immutable commit and provisions a +disposable public Azure Container Apps environment. ```bash -bin/dev qa up --pr 50 --ttl-hours 4 -bin/dev qa status --pr 50 -bin/dev qa logs --pr 50 --follow -bin/dev qa down --pr 50 -bin/dev qa down --all --expired --yes +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 ``` -The environment URL is -`https://tk-qa-.`. The frontend uses that same origin for -API requests, matching the newer production-style application configuration. -Each environment has isolated SQL Server, Redis, MailDev, and blob storage. -State is stored in `TK_QA_STATE_DIR` (default: -`~/.traditional-knowledge-qa`) so failed cleanup can be retried. TTL is -recorded; `down --all --expired --yes` performs explicit expiry cleanup. There -is no background scheduler. - -Before use, configure these local-only `TK_QA_*` variables: - -- `TK_QA_RESOURCE_GROUP`, `TK_QA_ACA_ENVIRONMENT`, `TK_QA_ACR_SERVER`, - `TK_QA_SUBSCRIPTION_ID` -- `TK_QA_DNS_SUFFIX`, with an ACA custom-domain suffix and wildcard certificate -- `TK_QA_STORAGE_ACCOUNT`, `TK_QA_BLOB_CONNECTION_STRING`, `TK_QA_BLOB_CONTAINER` -- `TK_QA_AUTH0_MANAGEMENT_TOKEN` -- `TK_QA_AUTH0_ALLOWED_HOST_SUFFIX`, exactly `.` - -The QA command defaults to the shared UAT Auth0 domain, audience, and client -ID. Set `TK_QA_AUTH0_DOMAIN`, `TK_QA_AUTH0_AUDIENCE`, or -`TK_QA_AUTH0_CLIENT_ID` only when using another compatible Auth0 application. - -Use the QA subscription (`artzzpr-sub` or its subscription ID), not the WRAP -subscription (`wrpzzpr-sub`). The command resolves a configured subscription -name to its ID before Azure REST calls. A developer with an eligible Azure -role can use the WRAP-style PIM self-activation; CI should instead use an -OIDC/service-principal identity with scoped write access to the QA resources. - -Configure the QA Auth0 application with these exact wildcard values, replacing -`` with `TK_QA_DNS_SUFFIX`: +`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 `TK_TEMPORARY_STATE_DIR` (default: +`~/.traditional-knowledge-temporary`) so failed cleanup can be retried. TTL is +recorded; there is no background scheduler. + +Before use, configure these local-only `TK_TEMPORARY_*` variables: + +- `TK_TEMPORARY_RESOURCE_GROUP`, `TK_TEMPORARY_ACA_ENVIRONMENT`, + `TK_TEMPORARY_ACR_SERVER`, `TK_TEMPORARY_SUBSCRIPTION_ID` +- `TK_TEMPORARY_DNS_SUFFIX`, with an ACA custom-domain suffix and wildcard + certificate +- `TK_TEMPORARY_STORAGE_ACCOUNT`, `TK_TEMPORARY_BLOB_CONNECTION_STRING`, + `TK_TEMPORARY_BLOB_CONTAINER` +- `TK_TEMPORARY_AUTH0_MANAGEMENT_TOKEN`, + `TK_TEMPORARY_AUTH0_ALLOWED_HOST_SUFFIX` + +The command defaults to the shared UAT Auth0 domain, audience, and client ID. +Set the `TK_TEMPORARY_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 `TK_TEMPORARY_DNS_SUFFIX`: ```text Allowed Callback URLs: https://*./callback @@ -65,20 +66,14 @@ Allowed Logout URLs: https://*. Allowed Web Origins: https://*. ``` -The command refuses to provision unless `TK_QA_AUTH0_ALLOWED_HOST_SUFFIX` is -`.`. Existing production and UAT Auth0 entries remain unchanged. -`TK_QA_AUTH0_MANAGEMENT_TOKEN` is a short-lived local-only token with only -`read:clients`; it is used only for preflight validation. - The resource group, ACA environment, storage account, and ACR must all carry -`traditional-knowledge-qa=true`. The blob connection string must belong to -`TK_QA_STORAGE_ACCOUNT`; each PR receives a separate container and short-lived -container SAS. Do not use production credentials or resources. +`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 QA +Azure with OIDC and grant the workflow identity scoped access to the temporary resource group; do not rely on interactive PIM in CI: ```yaml @@ -87,33 +82,33 @@ permissions: id-token: write env: - TK_QA_RESOURCE_GROUP: ${{ vars.TK_QA_RESOURCE_GROUP }} - TK_QA_ACA_ENVIRONMENT: ${{ vars.TK_QA_ACA_ENVIRONMENT }} - TK_QA_ACR_SERVER: ${{ vars.TK_QA_ACR_SERVER }} - TK_QA_SUBSCRIPTION_ID: ${{ vars.TK_QA_SUBSCRIPTION_ID }} - TK_QA_DNS_SUFFIX: ${{ vars.TK_QA_DNS_SUFFIX }} - TK_QA_STORAGE_ACCOUNT: ${{ vars.TK_QA_STORAGE_ACCOUNT }} - TK_QA_BLOB_CONTAINER: ${{ vars.TK_QA_BLOB_CONTAINER }} - TK_QA_AUTH0_DOMAIN: ${{ vars.TK_QA_AUTH0_DOMAIN }} - TK_QA_AUTH0_AUDIENCE: ${{ vars.TK_QA_AUTH0_AUDIENCE }} - TK_QA_AUTH0_CLIENT_ID: ${{ vars.TK_QA_AUTH0_CLIENT_ID }} - TK_QA_AUTH0_ALLOWED_HOST_SUFFIX: ${{ vars.TK_QA_AUTH0_ALLOWED_HOST_SUFFIX }} - TK_QA_BLOB_CONNECTION_STRING: ${{ secrets.TK_QA_BLOB_CONNECTION_STRING }} - TK_QA_AUTH0_MANAGEMENT_TOKEN: ${{ secrets.TK_QA_AUTH0_MANAGEMENT_TOKEN }} - TK_QA_STATE_DIR: ${{ runner.temp }}/traditional-knowledge-qa + TK_TEMPORARY_RESOURCE_GROUP: ${{ vars.TK_TEMPORARY_RESOURCE_GROUP }} + TK_TEMPORARY_ACA_ENVIRONMENT: ${{ vars.TK_TEMPORARY_ACA_ENVIRONMENT }} + TK_TEMPORARY_ACR_SERVER: ${{ vars.TK_TEMPORARY_ACR_SERVER }} + TK_TEMPORARY_SUBSCRIPTION_ID: ${{ vars.TK_TEMPORARY_SUBSCRIPTION_ID }} + TK_TEMPORARY_DNS_SUFFIX: ${{ vars.TK_TEMPORARY_DNS_SUFFIX }} + TK_TEMPORARY_STORAGE_ACCOUNT: ${{ vars.TK_TEMPORARY_STORAGE_ACCOUNT }} + TK_TEMPORARY_BLOB_CONTAINER: ${{ vars.TK_TEMPORARY_BLOB_CONTAINER }} + TK_TEMPORARY_AUTH0_DOMAIN: ${{ vars.TK_TEMPORARY_AUTH0_DOMAIN }} + TK_TEMPORARY_AUTH0_AUDIENCE: ${{ vars.TK_TEMPORARY_AUTH0_AUDIENCE }} + TK_TEMPORARY_AUTH0_CLIENT_ID: ${{ vars.TK_TEMPORARY_AUTH0_CLIENT_ID }} + TK_TEMPORARY_AUTH0_ALLOWED_HOST_SUFFIX: ${{ vars.TK_TEMPORARY_AUTH0_ALLOWED_HOST_SUFFIX }} + TK_TEMPORARY_BLOB_CONNECTION_STRING: ${{ secrets.TK_TEMPORARY_BLOB_CONNECTION_STRING }} + TK_TEMPORARY_AUTH0_MANAGEMENT_TOKEN: ${{ secrets.TK_TEMPORARY_AUTH0_MANAGEMENT_TOKEN }} + TK_TEMPORARY_STATE_DIR: ${{ runner.temp }}/traditional-knowledge-temporary GH_TOKEN: ${{ github.token }} steps: - uses: actions/checkout@v4 - uses: azure/login@v2 with: - client-id: ${{ secrets.TK_QA_AZURE_CLIENT_ID }} - tenant-id: ${{ secrets.TK_QA_AZURE_TENANT_ID }} - subscription-id: ${{ vars.TK_QA_SUBSCRIPTION_ID }} - - run: bin/dev qa up --pr "${{ github.event.pull_request.number }}" --ttl-hours 4 + client-id: ${{ secrets.TK_TEMPORARY_AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.TK_TEMPORARY_AZURE_TENANT_ID }} + subscription-id: ${{ vars.TK_TEMPORARY_SUBSCRIPTION_ID }} + - run: bin/deploy temporary --pr "${{ github.event.pull_request.number }}" - if: ${{ always() }} - run: bin/dev qa down --all --yes + run: bin/deploy temporary down --all --yes +``` -The workflow may use `workflow_dispatch` or `pull_request_target` according to -the repository's trust policy. Never expose Azure or Auth0 secrets to -untrusted fork code. +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..324c3aea --- /dev/null +++ b/bin/deploy @@ -0,0 +1,12 @@ +#!/usr/bin/env ruby + +require_relative "lib/deploy/all" + +if $PROGRAM_NAME == __FILE__ + begin + TraditionalKnowledgeTemporaryDeployment.run(ARGV) + rescue ArgumentError => error + warn error.message + exit 1 + end +end diff --git a/bin/dev b/bin/dev index 4387bbfe..3a8badf6 100755 --- a/bin/dev +++ b/bin/dev @@ -66,27 +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 @@ -118,13 +119,13 @@ class DevHelper def api(*args, **kwargs) run(*%w[api], *args, **kwargs) end - def qa(*args, **kwargs) + def deploy(*args, **kwargs) unless kwargs.empty? raise ArgumentError, - "Unexpected options for qa: #{kwargs.keys.join(", ")}" + "Unexpected options for deploy: #{kwargs.keys.join(", ")}" end - exec("ruby", File.join(project_root, "bin/qa"), *args) + exec("ruby", File.join(project_root, "bin/deploy"), *args) end def archiver(*args, **kwargs) @@ -154,26 +155,11 @@ class DevHelper test_api(*args.drop(1), **kwargs) elsif service == "web" test_web(*args.drop(1), **kwargs) - elsif service == "qa" - test_qa(*args.drop(1), **kwargs) else test_api(*args, **kwargs) end end - def test_qa(*args, **kwargs) - unless args.empty? - raise ArgumentError, - "Unexpected argument(s) for qa tests: #{args.join(" ")}" - end - unless kwargs.empty? - raise ArgumentError, - "Unexpected options for qa tests: #{kwargs.keys.join(", ")}" - end - - exec("ruby", File.join(project_root, "bin/qa_test.rb")) - end - def test_api(*args, **kwargs) reformat_project_relative_path_filter_for_vitest!(args, "api/") run(*%w[test_api npm run test], *args, **kwargs) diff --git a/bin/lib/deploy/all.rb b/bin/lib/deploy/all.rb new file mode 100644 index 00000000..22a01c50 --- /dev/null +++ b/bin/lib/deploy/all.rb @@ -0,0 +1,14 @@ +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/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..b086f407 --- /dev/null +++ b/bin/lib/deploy/temporary/azure/resources.rb @@ -0,0 +1,197 @@ +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, "TK_TEMPORARY_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..2d04301d --- /dev/null +++ b/bin/lib/deploy/temporary/command.rb @@ -0,0 +1,122 @@ +module TraditionalKnowledgeTemporaryDeployment + ACTIONS = %w[up list status logs down].freeze + + def self.run(argv) + 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 + + run_temporary(argv) + rescue OptionParser::ParseError, ArgumentError, Error => error + warn error.message + exit 1 + end + + def self.run_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) + 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 self.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 TK_TEMPORARY_* values + for a non-production Azure Container Apps environment. + HELP + 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..2a2e5655 --- /dev/null +++ b/bin/lib/deploy/temporary/support/config.rb @@ -0,0 +1,156 @@ +module TraditionalKnowledgeTemporaryDeployment + class Config + REQUIRED = %w[ + TK_TEMPORARY_RESOURCE_GROUP + TK_TEMPORARY_ACA_ENVIRONMENT + TK_TEMPORARY_ACR_SERVER + TK_TEMPORARY_SUBSCRIPTION_ID + TK_TEMPORARY_DNS_SUFFIX + TK_TEMPORARY_STORAGE_ACCOUNT + TK_TEMPORARY_AUTH0_ALLOWED_HOST_SUFFIX + TK_TEMPORARY_AUTH0_MANAGEMENT_TOKEN + TK_TEMPORARY_BLOB_CONNECTION_STRING + TK_TEMPORARY_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, + "TK_TEMPORARY_BLOB_CONNECTION_STRING must belong to TK_TEMPORARY_STORAGE_ACCOUNT" + end + expected_suffix = ".#{dns_suffix}" + unless auth0_allowed_host_suffix == expected_suffix + raise Error, + "TK_TEMPORARY_AUTH0_ALLOWED_HOST_SUFFIX must be #{expected_suffix.inspect}" + end + self + end + + def validate_cleanup! + required = %w[ + TK_TEMPORARY_RESOURCE_GROUP + TK_TEMPORARY_ACA_ENVIRONMENT + TK_TEMPORARY_ACR_SERVER + TK_TEMPORARY_SUBSCRIPTION_ID + TK_TEMPORARY_DNS_SUFFIX + TK_TEMPORARY_STORAGE_ACCOUNT + TK_TEMPORARY_BLOB_CONNECTION_STRING + TK_TEMPORARY_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, + "TK_TEMPORARY_BLOB_CONNECTION_STRING must belong to TK_TEMPORARY_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("TK_TEMPORARY_RESOURCE_GROUP") + def aca_environment = fetch("TK_TEMPORARY_ACA_ENVIRONMENT") + def acr_server = fetch("TK_TEMPORARY_ACR_SERVER") + def acr_name = acr_server.split(".").first + def dns_suffix = + fetch("TK_TEMPORARY_DNS_SUFFIX").sub(%r{\Ahttps?://}, "").sub( + %r{/.*\z}, + "" + ) + def scope_tag = SCOPE_TAG + def storage_account = fetch("TK_TEMPORARY_STORAGE_ACCOUNT") + def auth0_allowed_host_suffix = + fetch("TK_TEMPORARY_AUTH0_ALLOWED_HOST_SUFFIX") + def auth0_domain = + environment.fetch("TK_TEMPORARY_AUTH0_DOMAIN", UAT_AUTH0_DOMAIN) + def auth0_audience = + environment.fetch("TK_TEMPORARY_AUTH0_AUDIENCE", UAT_AUTH0_AUDIENCE) + def auth0_client_id = + environment.fetch("TK_TEMPORARY_AUTH0_CLIENT_ID", UAT_AUTH0_CLIENT_ID) + def auth0_management_token = fetch("TK_TEMPORARY_AUTH0_MANAGEMENT_TOKEN") + def blob_connection_string = fetch("TK_TEMPORARY_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("TK_TEMPORARY_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("TK_TEMPORARY_REPOSITORY", REPOSITORY) + def state_directory = + environment.fetch("TK_TEMPORARY_STATE_DIR", DEFAULT_STATE_DIR) + def subscription_id = fetch("TK_TEMPORARY_SUBSCRIPTION_ID") + def timeout_seconds = + Integer(environment.fetch("TK_TEMPORARY_TIMEOUT_SECONDS", "300"), 10) + def http_timeout_seconds = + Integer(environment.fetch("TK_TEMPORARY_HTTP_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, "TK_TEMPORARY_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..c1b628f5 --- /dev/null +++ b/bin/lib/deploy/temporary/support/errors.rb @@ -0,0 +1,37 @@ +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( + { "TK_TEMPORARY_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({ "TK_TEMPORARY_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/source.rb b/bin/lib/deploy/temporary/support/source.rb new file mode 100644 index 00000000..7bf3e979 --- /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 + 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*\z/) + raise Error, "PR number must be positive" + 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..869b80e8 --- /dev/null +++ b/bin/lib/deploy/temporary/support/state_store.rb @@ -0,0 +1,63 @@ +module TraditionalKnowledgeTemporaryDeployment + class StateStore + attr_reader :directory + + def initialize( + directory = ENV.fetch("TK_TEMPORARY_STATE_DIR", 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/bin/lib/qa.rb b/bin/lib/qa.rb deleted file mode 100644 index e74b37b7..00000000 --- a/bin/lib/qa.rb +++ /dev/null @@ -1,1648 +0,0 @@ -#!/usr/bin/env ruby - -require "fileutils" -require "json" -require "net/http" -require "open3" -require "optparse" -require "securerandom" -require "tempfile" -require "tmpdir" -require "time" -require "uri" - -module TraditionalKnowledgeQa - REPOSITORY = "icefoganalytics/traditional-knowledge" - APP_PREFIX = "tk-qa-" - SCOPE_TAG = "traditional-knowledge-qa" - DEFAULT_STATE_DIR = File.expand_path("~/.traditional-knowledge-qa") - UAT_AUTH0_DOMAIN = "https://yukon-staging.eu.auth0.com" - UAT_AUTH0_AUDIENCE = "generic-uat" - UAT_AUTH0_CLIENT_ID = "11878vWk1pmhwyVQwsr2m2zM3w3e912U" - - 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( - { "TK_QA_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({ "TK_QA_AUTH0_MANAGEMENT_TOKEN" => nil }, *command) - raise Error, "Command failed (#{command.join(" ")})" - end - end - end - - class StateStore - attr_reader :directory - - def initialize(directory = ENV.fetch("TK_QA_STATE_DIR", 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[qa- .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 QA 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 QA 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?(/\Apr-\d+\z/) - raise Error, "Invalid environment identifier" - end - - File.join(directory, "#{environment_id}.json") - end - end - - class Config - REQUIRED = %w[ - TK_QA_RESOURCE_GROUP - TK_QA_ACA_ENVIRONMENT - TK_QA_ACR_SERVER - TK_QA_SUBSCRIPTION_ID - TK_QA_DNS_SUFFIX - TK_QA_STORAGE_ACCOUNT - TK_QA_AUTH0_ALLOWED_HOST_SUFFIX - TK_QA_AUTH0_MANAGEMENT_TOKEN - TK_QA_BLOB_CONNECTION_STRING - TK_QA_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 QA configuration: #{missing.join(", ")}" - end - validate_auth0_domain! - blob_container(1) - - validate_scope! - unless blob_connection_account == storage_account - raise Error, - "TK_QA_BLOB_CONNECTION_STRING must belong to TK_QA_STORAGE_ACCOUNT" - end - expected_suffix = ".#{dns_suffix}" - unless auth0_allowed_host_suffix == expected_suffix - raise Error, - "TK_QA_AUTH0_ALLOWED_HOST_SUFFIX must be #{expected_suffix.inspect}" - end - self - end - - def validate_cleanup! - required = %w[ - TK_QA_RESOURCE_GROUP - TK_QA_ACA_ENVIRONMENT - TK_QA_ACR_SERVER - TK_QA_SUBSCRIPTION_ID - TK_QA_DNS_SUFFIX - TK_QA_STORAGE_ACCOUNT - TK_QA_BLOB_CONNECTION_STRING - TK_QA_BLOB_CONTAINER - ] - missing = required.reject { |key| environment[key].to_s.strip != "" } - unless missing.empty? - raise Error, "Missing QA cleanup configuration: #{missing.join(", ")}" - end - - validate_scope! - unless blob_connection_account == storage_account - raise Error, - "TK_QA_BLOB_CONNECTION_STRING must belong to TK_QA_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 QA configuration in #{labels}" - end - - def resource_group = fetch("TK_QA_RESOURCE_GROUP") - def aca_environment = fetch("TK_QA_ACA_ENVIRONMENT") - def acr_server = fetch("TK_QA_ACR_SERVER") - def acr_name = acr_server.split(".").first - def dns_suffix = - fetch("TK_QA_DNS_SUFFIX").sub(%r{\Ahttps?://}, "").sub(%r{/.*\z}, "") - def scope_tag = SCOPE_TAG - def storage_account = fetch("TK_QA_STORAGE_ACCOUNT") - def auth0_allowed_host_suffix = fetch("TK_QA_AUTH0_ALLOWED_HOST_SUFFIX") - def auth0_domain = environment.fetch("TK_QA_AUTH0_DOMAIN", UAT_AUTH0_DOMAIN) - def auth0_audience = - environment.fetch("TK_QA_AUTH0_AUDIENCE", UAT_AUTH0_AUDIENCE) - def auth0_client_id = - environment.fetch("TK_QA_AUTH0_CLIENT_ID", UAT_AUTH0_CLIENT_ID) - def auth0_management_token = fetch("TK_QA_AUTH0_MANAGEMENT_TOKEN") - def blob_connection_string = fetch("TK_QA_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("TK_QA_BLOB_CONTAINER") - def blob_container(pr_number) - name = "#{blob_container_prefix}-pr-#{positive_pr_number(pr_number)}" - unless name.length.between?(3, 63) && - name.match?(/\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/) && - !name.include?("--") - raise Error, - "QA blob container must be 3–63 lowercase letters, numbers, and single hyphens" - end - - name - end - def repository = environment.fetch("TK_QA_REPOSITORY", REPOSITORY) - def state_directory = - environment.fetch("TK_QA_STATE_DIR", DEFAULT_STATE_DIR) - def subscription_id = fetch("TK_QA_SUBSCRIPTION_ID") - def timeout_seconds = - Integer(environment.fetch("TK_QA_TIMEOUT_SECONDS", "300"), 10) - def http_timeout_seconds = - Integer(environment.fetch("TK_QA_HTTP_TIMEOUT_SECONDS", "10"), 10) - - def environment_id(pr_number) = "pr-#{positive_pr_number(pr_number)}" - def app_name(pr_number) = "#{APP_PREFIX}#{positive_pr_number(pr_number)}" - def public_url(pr_number) = "https://#{app_name(pr_number)}.#{dns_suffix}" - - def state_environment_matches?(state) - state.fetch("resource_group") == resource_group && - state.fetch("aca_environment") == aca_environment - end - - def positive_pr_number(pr_number) - value = Integer(pr_number.to_s, 10) - raise Error, "PR number must be positive" unless value.positive? - - value - end - - private - - def validate_auth0_domain! - unless auth0_domain.start_with?("https://") - raise Error, "TK_QA_AUTH0_DOMAIN must use https://" - end - end - - def fetch(key) - value = environment[key].to_s.strip - raise Error, "Missing QA configuration: #{key}" if value.empty? - - value - end - end - - class GitHub - def initialize(runner, repository) - @runner = runner - @repository = repository - 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(number, sha, root) - ref = "refs/tk-qa/pr-#{Integer(number)}" - @runner.run( - "git", - "-C", - root, - "fetch", - "--force", - "origin", - "refs/pull/#{Integer(number)}/head:#{ref}" - ) - fetched_sha = @runner.run("git", "-C", root, "rev-parse", ref).strip - if fetched_sha != sha - begin - @runner.run("git", "-C", root, "update-ref", "-d", ref) - rescue StandardError - nil - end - raise Error, - "Fetched PR commit #{fetched_sha} does not match GitHub SHA #{sha}" - end - - ref - end - def remove_commit_ref(ref, root) - @runner.run("git", "-C", root, "update-ref", "-d", ref) - end - end - - 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 - - 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 QA 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_QA_HOST_SUFFIX=#{host_suffix}", - "--build-arg", - "VITE_QA_AUTH0_DOMAIN=#{@config.auth0_domain}", - "--build-arg", - "VITE_QA_AUTH0_AUDIENCE=#{@config.auth0_audience}", - "--build-arg", - "VITE_QA_AUTH0_CLIENT_ID=#{@config.auth0_client_id}", - worktree - ) - end - - def deploy( - pr_number, - sha, - image_tag, - expires_at, - provision_blob_container:, - remove_app_on_failure: - ) - app_name = @config.app_name(pr_number) - 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(pr_number) - 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( - pr_number:, - qa_environment_id: @config.environment_id(pr_number), - 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(pr_number), 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(pr_number), - "app_name" => app_name, - "pr_number" => Integer(pr_number), - "sha" => sha, - "image_tag" => image_tag, - "blob_container" => blob_container, - "public_url" => @config.public_url(pr_number), - "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_pr(state.fetch("pr_number")) - end - - def delete_images_for_pr(pr_number) - tags = - JSON.parse( - @runner.run( - "az", - "acr", - "repository", - "show-tags", - "--name", - @config.acr_name, - "--repository", - "traditional-knowledge", - "--subscription", - subscription_id, - "-o", - "json" - ) - ) - prefix = "qa-pr-#{Integer(pr_number)}-" - tags - .select { |tag| tag.start_with?(prefix) } - .each { |tag| delete_image(tag) } - rescue JSON::ParserError => error - raise Error, "Azure returned invalid QA 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 - - 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 QA 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 QA 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 - - def put_app(app_name, body) - Tempfile.create(%w[tk-qa- .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, "TK_QA_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}/qa-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["status"] == "ok" && - payload["gitCommitHash"] == expected_sha - return - end - if Time.now >= deadline - raise Error, - "Timed out waiting for #{url}/qa-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}/qa-status; inspect with `az containerapp logs show --name #{app_name} --resource-group #{@config.resource_group} --container web`" - end - - sleep 5 - end - end - - def deployment_body( - pr_number:, - qa_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(pr_number) - { - "location" => location, - "tags" => { - "traditional-knowledge-qa" => "true", - "qa-environment" => qa_environment_id, - "qa-pr-sha" => sha, - "qa-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" => "QA_ENVIRONMENT", "value" => "true" }, - { "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_qa" - }, - { - "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 - - class Worktree - def initialize(runner, root) - @runner = runner - @root = root - end - - def with(sha) - Dir.mktmpdir("tk-qa-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 - - 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(pr_number, ttl_hours) - @config.validate! - github = GitHub.new(@runner, @config.repository) - pr = github.pull_request(pr_number) - sha = pr.fetch("headSha") - image_tag = "qa-pr-#{Integer(pr_number)}-#{sha[0, 12]}" - expires_at = (Time.now.utc + ttl_hours * 3600).iso8601 - environment_id = @config.environment_id(pr_number) - existing_state = @store.find(environment_id) - preserve_existing_resources = preserve_existing_resources?(existing_state) - azure = Azure.new(@runner, @config) - ref = github.fetch_commit(pr_number, 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(pr_number), - "pr_number" => Integer(pr_number), - "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(pr_number), - "public_url" => @config.public_url(pr_number), - "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( - pr_number, - 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 "QA environment ready: #{state.fetch("public_url")}" - puts "Environment: #{state.fetch("environment_id")} App: #{state.fetch("app_name")}" - puts "PR: #{state.fetch("pr_number")} SHA: #{sha} Expires: #{expires_at}" - puts "Teardown: bin/qa down --pr #{pr_number}" - ensure - github.remove_commit_ref(ref, @root) if ref - end - end - - def list - states = @store.all - if states.empty? - puts "No QA 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(pr_number) - state = scoped_state_for(pr_number) - puts "#{state.fetch("environment_id")}: #{Azure.new(@runner, @config).status(state)}" - puts "URL: #{state.fetch("public_url")}" - end - - def logs(pr_number, follow) - state = scoped_state_for(pr_number) - 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(pr_number) - state = state_for(pr_number) - @config.validate_cleanup! - azure = Azure.new(@runner, @config) - azure.validate_environment! - safety_check!(state, azure) - state - end - - def state_for(pr_number, reconstruct: false) - state = @store.find(@config.environment_id(pr_number)) - return state if state - return reconstruct_state(pr_number) if reconstruct - - raise Error, "No QA state for PR #{pr_number}." - end - - def reconstruct_state(pr_number) - azure = Azure.new(@runner, @config) - { - "environment_id" => @config.environment_id(pr_number), - "app_name" => @config.app_name(pr_number), - "pr_number" => Integer(pr_number), - "blob_container" => @config.blob_container(pr_number), - "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(state.fetch("pr_number")) - unless state.fetch("blob_container") == expected_blob_container - raise Error, "Refusing to delete an unexpected QA 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 QA scope" - end - - azure.verify_app_scope!(state) - end - - public - - def down(pr_number, 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(pr_number, reconstruct: true)] - end - if states.empty? - puts expired ? "No expired QA environments." : "No QA environments." - 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} QA environment deletion(s) failed" - end - end - end - - def self.run(argv) - command = argv.shift - return puts(help) if command.nil? || %w[help --help -h].include?(command) - - options = { - ttl_hours: 4, - follow: false, - all: false, - expired: false, - yes: false - } - parser = - OptionParser.new do |option_parser| - option_parser.banner = - "Usage: bin/qa [options]" - option_parser.on("--pr NUMBER", Integer, "PR number") do |value| - options[:pr] = 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? - - application = Application.new - case command - when "up" - raise Error, "up requires --pr NUMBER" unless options[:pr] - unless options[:ttl_hours].positive? - raise Error, "--ttl-hours must be positive" - end - application.up(options[:pr], options[:ttl_hours]) - when "list" - application.list - when "status" - raise Error, "status requires --pr NUMBER" unless options[:pr] - application.status(options[:pr]) - when "logs" - raise Error, "logs requires --pr NUMBER" unless options[:pr] - application.logs(options[:pr], options[:follow]) - when "down" - unless options[:pr] || options[:all] - raise Error, "down requires --pr NUMBER or --all" - end - application.down( - options[:pr], - all: options[:all], - expired: options[:expired], - confirmed: options[:yes] - ) - else - raise Error, "Unknown command: #{command}" - end - rescue OptionParser::ParseError, ArgumentError, Error => error - warn error.message - exit 1 - end - - def self.help - <<~HELP - Disposable per-PR QA environments for Traditional Knowledge. - - Usage: - bin/qa up --pr NUMBER [--ttl-hours HOURS] - bin/qa list - bin/qa status --pr NUMBER - bin/qa logs --pr NUMBER [--follow] - bin/qa down --pr NUMBER - bin/qa down --all --expired --yes - - `up` requires TK_QA_* configuration for a non-production Azure Container Apps - environment. See README.md for the required variables and Auth0 wildcard setup. - HELP - end -end - -TraditionalKnowledgeQa.run(ARGV) if $PROGRAM_NAME == __FILE__ diff --git a/bin/qa b/bin/qa deleted file mode 100755 index 0c724ecd..00000000 --- a/bin/qa +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env ruby - -require_relative "lib/qa" - -TraditionalKnowledgeQa.run(ARGV) if $PROGRAM_NAME == __FILE__ diff --git a/bin/qa_test.rb b/bin/qa_test.rb deleted file mode 100755 index 612e759b..00000000 --- a/bin/qa_test.rb +++ /dev/null @@ -1,391 +0,0 @@ -#!/usr/bin/env ruby - -require "tmpdir" -load File.expand_path("qa", __dir__) - -module QaTest - module_function - - def assert(condition, message) - raise message unless condition - end - - def assert_raises(error_class, message) - yield - raise "Expected #{error_class}" - rescue error_class - nil - end - - def environment(state_directory, **overrides) - { - "TK_QA_RESOURCE_GROUP" => "CapAero_QA", - "TK_QA_ACA_ENVIRONMENT" => "tk-qa-env", - "TK_QA_ACR_SERVER" => "qa.azurecr.io", - "TK_QA_DNS_SUFFIX" => "qa.example.com", - "TK_QA_STORAGE_ACCOUNT" => "tkqaassets", - "TK_QA_AUTH0_ALLOWED_HOST_SUFFIX" => ".qa.example.com", - "TK_QA_AUTH0_DOMAIN" => "https://qa.example.auth0.com", - "TK_QA_AUTH0_AUDIENCE" => "traditional-knowledge-qa", - "TK_QA_AUTH0_CLIENT_ID" => "qa-client", - "TK_QA_AUTH0_MANAGEMENT_TOKEN" => "qa-management-token", - "TK_QA_BLOB_CONNECTION_STRING" => - "DefaultEndpointsProtocol=https;AccountName=tkqaassets;AccountKey=not-used;BlobEndpoint=https://tkqaassets.blob.core.windows.net/", - "TK_QA_BLOB_CONTAINER" => "tk-qa", - "TK_QA_STATE_DIR" => state_directory, - "TK_QA_SUBSCRIPTION_ID" => "qa-subscription" - }.merge(overrides) - end - - class Auth0HttpClient - attr_reader :token - - def get(_uri, token) - @token = token - response = Net::HTTPOK.new("1.1", "200", "OK") - response.instance_variable_set( - :@body, - JSON.generate( - "callbacks" => ["https://*.qa.example.com/callback"], - "allowed_logout_urls" => ["https://*.qa.example.com"], - "web_origins" => ["https://*.qa.example.com"] - ) - ) - response.instance_variable_set(:@read, true) - response - end - end - - class FailingDeleteRunner - attr_reader :commands - - def initialize - @commands = [] - end - - def run(*command) - @commands << command - return "true\n" if command.include?("tags['traditional-knowledge-qa']") - if command.include?("properties.customDomainConfiguration.dnsSuffix") - return "qa.example.com\n" - end - if command.include?("--query") && command.include?("id") - return( - "/subscriptions/qa/resourceGroups/CapAero_QA/providers/Microsoft.App/managedEnvironments/tk-qa-env\n" - ) - end - if command.include?("properties.managedEnvironmentId") - return( - "/subscriptions/qa/resourceGroups/CapAero_QA/providers/Microsoft.App/managedEnvironments/tk-qa-env\n" - ) - end - if command.include?("containerapp") && command.include?("delete") - raise TraditionalKnowledgeQa::CommandError.new( - command, - "simulated delete failure" - ) - end - - "" - end - - def run_with_environment(environment, *command) - run(*command) - end - end - - class PimRunner - def initialize(eligibilities, roles) - @eligibilities = eligibilities - @roles = roles - end - - def run(*command) - joined = command.join(" ") - if command.include?("--query") && command.include?("id") - return "qa-subscription\n" - end - if joined.include?("roleEligibilityScheduleInstances") - return JSON.generate("value" => @eligibilities) - end - - role_id = @roles.keys.find { |id| joined.include?(id) } - if role_id - return( - JSON.generate( - "properties" => { - "permissions" => @roles.fetch(role_id) - } - ) - ) - end - - raise "Unexpected PIM command: #{joined}" - end - end - class BuildRunner - attr_reader :commands - - def initialize - @commands = [] - end - - def run(*command) - @commands << command - if command.include?("--query") && command.include?("id") - return "qa-subscription\n" - end - - "" - end - end - - def eligibility(scope, role_id) - { - "properties" => { - "scope" => scope, - "roleDefinitionId" => - "/subscriptions/qa/providers/Microsoft.Authorization/roleDefinitions/#{role_id}", - "principalId" => "principal", - "roleEligibilityScheduleId" => "/eligibility/1" - } - } - end - - Dir.mktmpdir("tk-qa-test-") do |state_directory| - config = TraditionalKnowledgeQa::Config.new(environment(state_directory)) - config.validate! - auth0_client = Auth0HttpClient.new - TraditionalKnowledgeQa::Auth0.new( - config, - http_client: auth0_client - ).validate! - assert( - auth0_client.token == "qa-management-token", - "Auth0 preflight must use the management token" - ) - cleanup_keys = %w[ - TK_QA_RESOURCE_GROUP - TK_QA_ACA_ENVIRONMENT - TK_QA_ACR_SERVER - TK_QA_DNS_SUFFIX - TK_QA_STORAGE_ACCOUNT - TK_QA_BLOB_CONNECTION_STRING - TK_QA_BLOB_CONTAINER - TK_QA_STATE_DIR - TK_QA_SUBSCRIPTION_ID - ] - TraditionalKnowledgeQa::Config.new( - environment(state_directory).slice(*cleanup_keys) - ).validate_cleanup! - assert( - config.environment_id(12) == "pr-12", - "PR environment IDs must be stable" - ) - assert(config.app_name(12) == "tk-qa-12", "Azure app names must be stable") - assert( - config.blob_container(12) == "tk-qa-pr-12", - "blob containers must be per PR" - ) - assert_raises( - TraditionalKnowledgeQa::Error, - "production-looking resources must be rejected" - ) do - TraditionalKnowledgeQa::Config.new( - environment(state_directory, "TK_QA_RESOURCE_GROUP" => "production") - ).validate! - end - assert_raises( - TraditionalKnowledgeQa::Error, - "insecure Auth0 domains must be rejected" - ) do - TraditionalKnowledgeQa::Config.new( - environment( - state_directory, - "TK_QA_AUTH0_DOMAIN" => "http://qa.example.auth0.com" - ) - ).validate! - end - assert_raises( - TraditionalKnowledgeQa::Error, - "invalid blob names must be rejected" - ) do - TraditionalKnowledgeQa::Config.new( - environment(state_directory, "TK_QA_BLOB_CONTAINER" => "TK-INVALID") - ).validate! - end - - subscription_scope = "/subscriptions/qa-subscription" - resource_group_scope = - "/subscriptions/qa-subscription/resourceGroups/CapAero_QA" - insufficient = eligibility(resource_group_scope, "insufficient") - sufficient = eligibility(subscription_scope, "sufficient") - runner = - PimRunner.new( - [insufficient, sufficient], - { - "insufficient" => [ - { "actions" => ["Microsoft.Storage/*"], "notActions" => [] } - ], - "sufficient" => [ - { - "actions" => ["Microsoft.App/containerApps/write"], - "notActions" => [] - } - ] - } - ) - azure = TraditionalKnowledgeQa::Azure.new(runner, config) - selected = azure.send(:activation_eligibility) - assert( - selected == sufficient, - "PIM must select an eligible role that grants Container Apps write access" - ) - - insufficient_runner = - PimRunner.new( - [insufficient], - { - "insufficient" => [ - { "actions" => ["Microsoft.Storage/*"], "notActions" => [] } - ] - } - ) - insufficient_azure = - TraditionalKnowledgeQa::Azure.new(insufficient_runner, config) - assert( - insufficient_azure.send(:activation_eligibility).nil?, - "PIM must reject eligible roles without Container Apps write access" - ) - override_config = - TraditionalKnowledgeQa::Config.new( - environment( - state_directory, - "TK_QA_AUTH0_DOMAIN" => "https://custom.example.auth0.com", - "TK_QA_AUTH0_AUDIENCE" => "custom-audience", - "TK_QA_AUTH0_CLIENT_ID" => "custom-client" - ) - ) - build_runner = BuildRunner.new - TraditionalKnowledgeQa::Azure.new( - build_runner, - override_config - ).build_image("/tmp/worktree", "qa-pr-12-tag", "a" * 40, ".qa.example.com") - build_command = build_runner.commands.last - assert( - build_command.include?( - "VITE_QA_AUTH0_DOMAIN=https://custom.example.auth0.com" - ) && build_command.include?("VITE_QA_AUTH0_AUDIENCE=custom-audience") && - build_command.include?("VITE_QA_AUTH0_CLIENT_ID=custom-client"), - "Auth0 overrides must be passed into the QA web image build" - ) - deployment = - TraditionalKnowledgeQa::Azure.new(build_runner, override_config).send( - :deployment_body, - pr_number: 12, - qa_environment_id: "pr-12", - managed_environment_id: "/managed-environments/qa", - location: "canadacentral", - image_tag: "qa-pr-12-tag", - sha: "a" * 40, - expires_at: "2026-07-24T00:00:00Z", - acr_user: "acr-user", - acr_password: "acr-password", - db_password: "db-password", - blob_container: "tk-qa-pr-12", - blob_connection_string: - "BlobEndpoint=https://blob.example;SharedAccessSignature=sas" - ) - web_container = - deployment - .fetch("properties") - .fetch("template") - .fetch("containers") - .find { |container| container.fetch("name") == "web" } - web_environment = - web_container - .fetch("env") - .select do |entry| - %w[VITE_AUTH0_DOMAIN VITE_AUTH0_AUDIENCE].include?( - entry.fetch("name") - ) - end - .to_h { |entry| [entry.fetch("name"), entry.fetch("value")] } - assert( - web_environment["VITE_AUTH0_DOMAIN"] == - "https://custom.example.auth0.com" && - web_environment["VITE_AUTH0_AUDIENCE"] == "custom-audience", - "Auth0 overrides must be passed into the QA API runtime" - ) - - application_without_initialization = - TraditionalKnowledgeQa::Application.allocate - assert( - application_without_initialization.send( - :preserve_existing_resources?, - { "phase" => "ready" } - ), - "ready state must preserve resources" - ) - assert( - application_without_initialization.send( - :preserve_existing_resources?, - { "preserve_existing_resources" => true } - ), - "interrupted update must preserve resources" - ) - assert( - !application_without_initialization.send( - :preserve_existing_resources?, - { "phase" => "provisioning" } - ), - "initial provisioning must not preserve resources" - ) - - store = TraditionalKnowledgeQa::StateStore.new(state_directory) - store.save( - "environment_id" => "pr-12", - "app_name" => "tk-qa-12", - "pr_number" => 12, - "sha" => "a" * 40, - "image_tag" => "qa-pr-12-aaaaaaaaaaaa", - "blob_container" => "tk-qa-pr-12", - "public_url" => "https://tk-qa-12.qa.example.com", - "expires_at" => "2026-07-23T22:00:00Z", - "resource_group" => "CapAero_QA", - "aca_environment" => "tk-qa-env", - "subscription_id" => "qa-subscription", - "phase" => "ready" - ) - assert( - store.find("pr-12").fetch("sha") == "a" * 40, - "state must persist PR metadata" - ) - runner = FailingDeleteRunner.new - application = - TraditionalKnowledgeQa::Application.new( - runner:, - environment: environment(state_directory) - ) - assert_raises( - TraditionalKnowledgeQa::Error, - "cleanup failure must fail the command" - ) { application.down(12) } - assert(store.find("pr-12"), "cleanup failure must retain state for retry") - tampered_state = store.find("pr-12") - tampered_state["blob_container"] = "tk-qa-pr-999" - store.save(tampered_state) - command_count = runner.commands.length - assert_raises( - TraditionalKnowledgeQa::Error, - "unexpected blob containers must be rejected" - ) { application.down(12) } - new_commands = runner.commands.drop(command_count) - assert( - new_commands.none? { |command| command.include?("delete") }, - "unexpected blob containers must not be deleted" - ) - end - - puts "QA command checks passed" -end diff --git a/web/src/config.ts b/web/src/config.ts index bc152f5e..a16621f9 100644 --- a/web/src/config.ts +++ b/web/src/config.ts @@ -34,13 +34,13 @@ const localProductionConfig = { apiBaseUrl: "http://localhost:8080", applicationName: "Traditional Knowledge", } -const qaConfig = { - hostSuffix: import.meta.env.VITE_QA_HOST_SUFFIX || "", - domain: import.meta.env.VITE_QA_AUTH0_DOMAIN || uatConfig.domain, - clientId: import.meta.env.VITE_QA_AUTH0_CLIENT_ID || uatConfig.clientId, - audience: import.meta.env.VITE_QA_AUTH0_AUDIENCE || uatConfig.audience, +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 - QA", + applicationName: "Traditional Knowledge - Temporary", } let config = prodConfig @@ -57,8 +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 (qaConfig.hostSuffix && window.location.hostname.endsWith(qaConfig.hostSuffix)) { - config = qaConfig +} else if (temporaryConfig.hostSuffix && window.location.hostname.endsWith(temporaryConfig.hostSuffix)) { + config = temporaryConfig } export const APPLICATION_NAME = config.applicationName From f35ee0ab19c350ef45846b917e4ac3a94a404220 Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Fri, 31 Jul 2026 14:32:54 -0700 Subject: [PATCH 04/10] :japanese_castle: Bound temporary deployment source names. --- bin/lib/deploy/temporary/support/source.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/lib/deploy/temporary/support/source.rb b/bin/lib/deploy/temporary/support/source.rb index 7bf3e979..b62bfa99 100644 --- a/bin/lib/deploy/temporary/support/source.rb +++ b/bin/lib/deploy/temporary/support/source.rb @@ -57,8 +57,8 @@ def slug def validate! case kind when :pr - unless value.match?(/\A[1-9]\d*\z/) - raise Error, "PR number must be positive" + 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? From f9f1ccffeab3b2732fcdc71720f16dd18b0f1ced Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Fri, 31 Jul 2026 14:37:00 -0700 Subject: [PATCH 05/10] :japanese_castle: Normalize temporary deployment source hashes. --- bin/lib/deploy/temporary/support/source.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/lib/deploy/temporary/support/source.rb b/bin/lib/deploy/temporary/support/source.rb index b62bfa99..148c0559 100644 --- a/bin/lib/deploy/temporary/support/source.rb +++ b/bin/lib/deploy/temporary/support/source.rb @@ -12,10 +12,10 @@ def self.parse(kind, value) 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 From aed09344cb0e6162a046e8645136dd35f1310323 Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Fri, 31 Jul 2026 14:38:46 -0700 Subject: [PATCH 06/10] :lock: Reject conflicting temporary deployment selectors. --- bin/lib/deploy/temporary/command.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bin/lib/deploy/temporary/command.rb b/bin/lib/deploy/temporary/command.rb index 2d04301d..887bcd7f 100644 --- a/bin/lib/deploy/temporary/command.rb +++ b/bin/lib/deploy/temporary/command.rb @@ -69,6 +69,9 @@ def self.run_temporary(argv) 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 From ff882726a9a57944170b70d0597a8734e3f67160 Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Fri, 31 Jul 2026 14:44:00 -0700 Subject: [PATCH 07/10] :recycle: Preserve unrelated API configuration. --- api/src/config.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/api/src/config.ts b/api/src/config.ts index 7183a4c3..08ed8782 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -48,6 +48,7 @@ export const RUN_SCHEDULER = process.env.RUN_SCHEDULER || "false" 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 // ==================== From d7de39656c70e9f31a0a78fa7b70d094f51b3135 Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Fri, 31 Jul 2026 15:34:15 -0700 Subject: [PATCH 08/10] :recycle: Clarify temporary deployment configuration names. --- CHANGELOG.md | 4 + bin/README.md | 55 +++++------ bin/lib/deploy/temporary/azure/resources.rb | 3 +- bin/lib/deploy/temporary/command.rb | 2 +- bin/lib/deploy/temporary/support/config.rb | 97 +++++++++++-------- bin/lib/deploy/temporary/support/errors.rb | 9 +- .../deploy/temporary/support/state_store.rb | 5 +- 7 files changed, 102 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 793714a7..cb97c825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,3 +7,7 @@ All notable changes to this project will be documented in this file. ### 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/bin/README.md b/bin/README.md index 70a12d97..a52236a2 100644 --- a/bin/README.md +++ b/bin/README.md @@ -30,26 +30,26 @@ 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 +URL is `https://tk-temporary-.`. Each deployment has isolated SQL Server, Redis, MailDev, and blob storage. -State is stored in `TK_TEMPORARY_STATE_DIR` (default: +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 `TK_TEMPORARY_*` variables: +Before use, configure these local-only `TEMPORARY_DEPLOYMENT_*` variables: -- `TK_TEMPORARY_RESOURCE_GROUP`, `TK_TEMPORARY_ACA_ENVIRONMENT`, - `TK_TEMPORARY_ACR_SERVER`, `TK_TEMPORARY_SUBSCRIPTION_ID` -- `TK_TEMPORARY_DNS_SUFFIX`, with an ACA custom-domain suffix and wildcard +- `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 -- `TK_TEMPORARY_STORAGE_ACCOUNT`, `TK_TEMPORARY_BLOB_CONNECTION_STRING`, - `TK_TEMPORARY_BLOB_CONTAINER` -- `TK_TEMPORARY_AUTH0_MANAGEMENT_TOKEN`, - `TK_TEMPORARY_AUTH0_ALLOWED_HOST_SUFFIX` +- `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 `TK_TEMPORARY_AUTH0_*` overrides only when using another compatible +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 @@ -58,7 +58,7 @@ 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 `TK_TEMPORARY_DNS_SUFFIX`: +`` with `TEMPORARY_DEPLOYMENT_DOMAIN_SUFFIX`: ```text Allowed Callback URLs: https://*./callback @@ -82,29 +82,26 @@ permissions: id-token: write env: - TK_TEMPORARY_RESOURCE_GROUP: ${{ vars.TK_TEMPORARY_RESOURCE_GROUP }} - TK_TEMPORARY_ACA_ENVIRONMENT: ${{ vars.TK_TEMPORARY_ACA_ENVIRONMENT }} - TK_TEMPORARY_ACR_SERVER: ${{ vars.TK_TEMPORARY_ACR_SERVER }} - TK_TEMPORARY_SUBSCRIPTION_ID: ${{ vars.TK_TEMPORARY_SUBSCRIPTION_ID }} - TK_TEMPORARY_DNS_SUFFIX: ${{ vars.TK_TEMPORARY_DNS_SUFFIX }} - TK_TEMPORARY_STORAGE_ACCOUNT: ${{ vars.TK_TEMPORARY_STORAGE_ACCOUNT }} - TK_TEMPORARY_BLOB_CONTAINER: ${{ vars.TK_TEMPORARY_BLOB_CONTAINER }} - TK_TEMPORARY_AUTH0_DOMAIN: ${{ vars.TK_TEMPORARY_AUTH0_DOMAIN }} - TK_TEMPORARY_AUTH0_AUDIENCE: ${{ vars.TK_TEMPORARY_AUTH0_AUDIENCE }} - TK_TEMPORARY_AUTH0_CLIENT_ID: ${{ vars.TK_TEMPORARY_AUTH0_CLIENT_ID }} - TK_TEMPORARY_AUTH0_ALLOWED_HOST_SUFFIX: ${{ vars.TK_TEMPORARY_AUTH0_ALLOWED_HOST_SUFFIX }} - TK_TEMPORARY_BLOB_CONNECTION_STRING: ${{ secrets.TK_TEMPORARY_BLOB_CONNECTION_STRING }} - TK_TEMPORARY_AUTH0_MANAGEMENT_TOKEN: ${{ secrets.TK_TEMPORARY_AUTH0_MANAGEMENT_TOKEN }} - TK_TEMPORARY_STATE_DIR: ${{ runner.temp }}/traditional-knowledge-temporary + 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.TK_TEMPORARY_AZURE_CLIENT_ID }} - tenant-id: ${{ secrets.TK_TEMPORARY_AZURE_TENANT_ID }} - subscription-id: ${{ vars.TK_TEMPORARY_SUBSCRIPTION_ID }} + 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 diff --git a/bin/lib/deploy/temporary/azure/resources.rb b/bin/lib/deploy/temporary/azure/resources.rb index b086f407..501a731b 100644 --- a/bin/lib/deploy/temporary/azure/resources.rb +++ b/bin/lib/deploy/temporary/azure/resources.rb @@ -66,7 +66,8 @@ def container_sas_connection_string(name, expires_at) endpoint = @config.blob_endpoint if endpoint.to_s.empty? - raise Error, "TK_TEMPORARY_BLOB_CONNECTION_STRING has no BlobEndpoint" + raise Error, + "TEMPORARY_DEPLOYMENT_BLOB_CONNECTION_STRING has no BlobEndpoint" end "BlobEndpoint=#{endpoint};SharedAccessSignature=#{sas.delete_prefix("?")}" diff --git a/bin/lib/deploy/temporary/command.rb b/bin/lib/deploy/temporary/command.rb index 887bcd7f..d96ece99 100644 --- a/bin/lib/deploy/temporary/command.rb +++ b/bin/lib/deploy/temporary/command.rb @@ -118,7 +118,7 @@ def self.help bin/deploy temporary down --git-hash SHA bin/deploy temporary down --all --expired --yes - `ephemeral` is an alias for `temporary`. Configure TK_TEMPORARY_* values + `ephemeral` is an alias for `temporary`. Configure TEMPORARY_DEPLOYMENT_* values for a non-production Azure Container Apps environment. HELP end diff --git a/bin/lib/deploy/temporary/support/config.rb b/bin/lib/deploy/temporary/support/config.rb index 2a2e5655..d1636311 100644 --- a/bin/lib/deploy/temporary/support/config.rb +++ b/bin/lib/deploy/temporary/support/config.rb @@ -1,16 +1,16 @@ module TraditionalKnowledgeTemporaryDeployment class Config REQUIRED = %w[ - TK_TEMPORARY_RESOURCE_GROUP - TK_TEMPORARY_ACA_ENVIRONMENT - TK_TEMPORARY_ACR_SERVER - TK_TEMPORARY_SUBSCRIPTION_ID - TK_TEMPORARY_DNS_SUFFIX - TK_TEMPORARY_STORAGE_ACCOUNT - TK_TEMPORARY_AUTH0_ALLOWED_HOST_SUFFIX - TK_TEMPORARY_AUTH0_MANAGEMENT_TOKEN - TK_TEMPORARY_BLOB_CONNECTION_STRING - TK_TEMPORARY_BLOB_CONTAINER + 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 @@ -31,26 +31,26 @@ def validate! validate_scope! unless blob_connection_account == storage_account raise Error, - "TK_TEMPORARY_BLOB_CONNECTION_STRING must belong to TK_TEMPORARY_STORAGE_ACCOUNT" + "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, - "TK_TEMPORARY_AUTH0_ALLOWED_HOST_SUFFIX must be #{expected_suffix.inspect}" + "TEMPORARY_DEPLOYMENT_AUTH0_ALLOWED_HOST_SUFFIX must be #{expected_suffix.inspect}" end self end def validate_cleanup! required = %w[ - TK_TEMPORARY_RESOURCE_GROUP - TK_TEMPORARY_ACA_ENVIRONMENT - TK_TEMPORARY_ACR_SERVER - TK_TEMPORARY_SUBSCRIPTION_ID - TK_TEMPORARY_DNS_SUFFIX - TK_TEMPORARY_STORAGE_ACCOUNT - TK_TEMPORARY_BLOB_CONNECTION_STRING - TK_TEMPORARY_BLOB_CONTAINER + 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? @@ -61,7 +61,7 @@ def validate_cleanup! validate_scope! unless blob_connection_account == storage_account raise Error, - "TK_TEMPORARY_BLOB_CONNECTION_STRING must belong to TK_TEMPORARY_STORAGE_ACCOUNT" + "TEMPORARY_DEPLOYMENT_BLOB_CONNECTION_STRING must belong to TEMPORARY_DEPLOYMENT_STORAGE_ACCOUNT" end end @@ -84,31 +84,40 @@ def validate_scope! "Refusing production-looking temporary configuration in #{labels}" end - def resource_group = fetch("TK_TEMPORARY_RESOURCE_GROUP") - def aca_environment = fetch("TK_TEMPORARY_ACA_ENVIRONMENT") - def acr_server = fetch("TK_TEMPORARY_ACR_SERVER") + 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("TK_TEMPORARY_DNS_SUFFIX").sub(%r{\Ahttps?://}, "").sub( + fetch("TEMPORARY_DEPLOYMENT_DOMAIN_SUFFIX").sub(%r{\Ahttps?://}, "").sub( %r{/.*\z}, "" ) def scope_tag = SCOPE_TAG - def storage_account = fetch("TK_TEMPORARY_STORAGE_ACCOUNT") + def storage_account = fetch("TEMPORARY_DEPLOYMENT_STORAGE_ACCOUNT") def auth0_allowed_host_suffix = - fetch("TK_TEMPORARY_AUTH0_ALLOWED_HOST_SUFFIX") + fetch("TEMPORARY_DEPLOYMENT_AUTH0_ALLOWED_HOST_SUFFIX") def auth0_domain = - environment.fetch("TK_TEMPORARY_AUTH0_DOMAIN", UAT_AUTH0_DOMAIN) + environment.fetch("TEMPORARY_DEPLOYMENT_AUTH0_DOMAIN", UAT_AUTH0_DOMAIN) def auth0_audience = - environment.fetch("TK_TEMPORARY_AUTH0_AUDIENCE", UAT_AUTH0_AUDIENCE) + environment.fetch( + "TEMPORARY_DEPLOYMENT_AUTH0_AUDIENCE", + UAT_AUTH0_AUDIENCE + ) def auth0_client_id = - environment.fetch("TK_TEMPORARY_AUTH0_CLIENT_ID", UAT_AUTH0_CLIENT_ID) - def auth0_management_token = fetch("TK_TEMPORARY_AUTH0_MANAGEMENT_TOKEN") - def blob_connection_string = fetch("TK_TEMPORARY_BLOB_CONNECTION_STRING") + 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("TK_TEMPORARY_BLOB_CONTAINER") + 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) && @@ -120,14 +129,24 @@ def blob_container(source) name end - def repository = environment.fetch("TK_TEMPORARY_REPOSITORY", REPOSITORY) + def repository = + environment.fetch("TEMPORARY_DEPLOYMENT_SOURCE_REPOSITORY", REPOSITORY) def state_directory = - environment.fetch("TK_TEMPORARY_STATE_DIR", DEFAULT_STATE_DIR) - def subscription_id = fetch("TK_TEMPORARY_SUBSCRIPTION_ID") + environment.fetch( + "TEMPORARY_DEPLOYMENT_STATE_DIRECTORY", + DEFAULT_STATE_DIR + ) + def subscription_id = fetch("TEMPORARY_DEPLOYMENT_AZURE_SUBSCRIPTION_ID") def timeout_seconds = - Integer(environment.fetch("TK_TEMPORARY_TIMEOUT_SECONDS", "300"), 10) + Integer( + environment.fetch("TEMPORARY_DEPLOYMENT_TIMEOUT_SECONDS", "300"), + 10 + ) def http_timeout_seconds = - Integer(environment.fetch("TK_TEMPORARY_HTTP_TIMEOUT_SECONDS", "10"), 10) + 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}" @@ -142,7 +161,7 @@ def state_environment_matches?(state) def validate_auth0_domain! unless auth0_domain.start_with?("https://") - raise Error, "TK_TEMPORARY_AUTH0_DOMAIN must use https://" + raise Error, "TEMPORARY_DEPLOYMENT_AUTH0_DOMAIN must use https://" end end diff --git a/bin/lib/deploy/temporary/support/errors.rb b/bin/lib/deploy/temporary/support/errors.rb index c1b628f5..da389e39 100644 --- a/bin/lib/deploy/temporary/support/errors.rb +++ b/bin/lib/deploy/temporary/support/errors.rb @@ -20,7 +20,9 @@ def run(*command) def run_with_environment(environment, *command) stdout, stderr, status = Open3.capture3( - { "TK_TEMPORARY_AUTH0_MANAGEMENT_TOKEN" => nil }.merge(environment), + { "TEMPORARY_DEPLOYMENT_AUTH0_MANAGEMENT_TOKEN" => nil }.merge( + environment + ), *command ) output = [stdout, stderr].reject(&:empty?).join @@ -29,7 +31,10 @@ def run_with_environment(environment, *command) stdout end def stream(*command) - unless system({ "TK_TEMPORARY_AUTH0_MANAGEMENT_TOKEN" => nil }, *command) + unless system( + { "TEMPORARY_DEPLOYMENT_AUTH0_MANAGEMENT_TOKEN" => nil }, + *command + ) raise Error, "Command failed (#{command.join(" ")})" end end diff --git a/bin/lib/deploy/temporary/support/state_store.rb b/bin/lib/deploy/temporary/support/state_store.rb index 869b80e8..f3cb2731 100644 --- a/bin/lib/deploy/temporary/support/state_store.rb +++ b/bin/lib/deploy/temporary/support/state_store.rb @@ -3,7 +3,10 @@ class StateStore attr_reader :directory def initialize( - directory = ENV.fetch("TK_TEMPORARY_STATE_DIR", DEFAULT_STATE_DIR) + directory = ENV.fetch( + "TEMPORARY_DEPLOYMENT_STATE_DIRECTORY", + DEFAULT_STATE_DIR + ) ) @directory = File.expand_path(directory) end From 008a71f7fd1b99b49eb63a9128c28cf6a93848a8 Mon Sep 17 00:00:00 2001 From: Marlen Brunner Date: Fri, 31 Jul 2026 15:50:39 -0700 Subject: [PATCH 09/10] :recycle: Keep API and web lint checks passing. --- api/src/db/templates/sample-migration.ts | 4 ++-- api/src/db/utils/safe-parse-json.ts | 2 +- api/src/services/base-service.ts | 22 +++++++--------------- web/src/components/groups/GroupCard.vue | 4 ++-- 4 files changed, 12 insertions(+), 20 deletions(-) diff --git a/api/src/db/templates/sample-migration.ts b/api/src/db/templates/sample-migration.ts index 8f39bb72..f1d9db6e 100644 --- a/api/src/db/templates/sample-migration.ts +++ b/api/src/db/templates/sample-migration.ts @@ -1,9 +1,9 @@ import type { Knex } from "knex" -export async function up(knex: Knex): Promise { +export async function up(_knex: Knex): Promise { throw new Error("Not implemented") } -export async function down(knex: Knex): Promise { +export async function down(_knex: Knex): Promise { throw new Error("Not implemented") } diff --git a/api/src/db/utils/safe-parse-json.ts b/api/src/db/utils/safe-parse-json.ts index 41611f3b..392efa89 100644 --- a/api/src/db/utils/safe-parse-json.ts +++ b/api/src/db/utils/safe-parse-json.ts @@ -1,6 +1,6 @@ import { logger } from "@/utils/logger" -export function safeJsonParse(values: string): any[] { +export function safeJsonParse(values: string): unknown[] { try { const lines = JSON.parse(values) if (Array.isArray(lines)) { diff --git a/api/src/services/base-service.ts b/api/src/services/base-service.ts index 217c8047..fcfaaeb2 100644 --- a/api/src/services/base-service.ts +++ b/api/src/services/base-service.ts @@ -1,21 +1,13 @@ -type HasNoArgsConstructor = T extends { new (): any } ? true : false - -type CleanConstructorParameters = HasNoArgsConstructor extends true - ? [] - : ConstructorParameters - export class BaseService { - constructor(...args: any[]) {} - - static perform( - this: T, - ...args: CleanConstructorParameters - ): ReturnType["perform"]> { - const instance = new this(...args) - return instance.perform() + + static perform( + this: new (...args: Args) => { perform(): Result }, + ...args: Args + ): Result { + return new this(...args).perform() } - perform(): any { + perform(): unknown { throw new Error("Not Implemented") } } diff --git a/web/src/components/groups/GroupCard.vue b/web/src/components/groups/GroupCard.vue index db22e1d5..94cfc8e5 100644 --- a/web/src/components/groups/GroupCard.vue +++ b/web/src/components/groups/GroupCard.vue @@ -48,7 +48,7 @@