This commit is contained in:
Executable
+281
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SPEC_FILE="${1:-$ROOT_DIR/openapi.yaml}"
|
||||
# shellcheck disable=SC2016 # The single-quoted block is Ruby source, not shell text.
|
||||
ruby -ryaml -e '
|
||||
document = YAML.load_file(ARGV.fetch(0))
|
||||
paths = document.fetch("paths")
|
||||
finance_lists = {
|
||||
"/api/v1/contracts" => "listFinanceContracts",
|
||||
"/api/v1/costs" => "listFinanceCosts",
|
||||
"/api/v1/payables" => "listFinancePayables"
|
||||
}
|
||||
finance_lists.each do |path, operation_id|
|
||||
operation = paths.fetch(path).fetch("get")
|
||||
abort "#{path} has unstable operationId" unless operation["operationId"] == operation_id
|
||||
sort_parameter = operation.fetch("parameters").find { |parameter| parameter["name"] == "sort" }
|
||||
abort "#{path} is missing repeatable sort schema" unless sort_parameter&.dig("schema", "type") == "array"
|
||||
end
|
||||
|
||||
rescan_operations = {
|
||||
["/api/v1/files/{publicId}/rescan", "post"] => "rescanOwnedFile",
|
||||
["/api/v1/archive/files/{publicId}/rescan", "post"] => "rescanArchiveFile"
|
||||
}
|
||||
rescan_operations.each do |(path, method), operation_id|
|
||||
actual = paths.fetch(path).fetch(method).fetch("operationId")
|
||||
abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id
|
||||
end
|
||||
|
||||
fund_ledger = paths.fetch("/api/v1/funds/ledger").fetch("get")
|
||||
abort "fund ledger has unstable operationId" unless fund_ledger["operationId"] == "listFundLedger"
|
||||
fund_parameter_names = fund_ledger.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort
|
||||
expected_fund_parameters = %w[currency dateFrom dateTo entryType page projectId size sortBy sortDirection]
|
||||
abort "fund ledger parameter contract drifted: #{fund_parameter_names.inspect}" unless
|
||||
fund_parameter_names == expected_fund_parameters
|
||||
|
||||
workflow_operations = {
|
||||
["/api/v1/workflow/tasks", "get"] => "listWorkflowTasks",
|
||||
["/api/v1/workflow/tasks/{publicId}", "get"] => "getWorkflowTask",
|
||||
["/api/v1/workflow/tasks/{publicId}/approve", "post"] => "approveWorkflowTask",
|
||||
["/api/v1/workflow/tasks/{publicId}/return", "post"] => "returnWorkflowTask",
|
||||
["/api/v1/workflow/tasks/{publicId}/reject", "post"] => "rejectWorkflowTask",
|
||||
["/api/v1/workflow/instances/{instancePublicId}/withdraw", "post"] => "withdrawWorkflowInstance",
|
||||
["/api/v1/workflow/instances/{instancePublicId}/void", "post"] => "voidWorkflowInstance"
|
||||
}
|
||||
workflow_operations.each do |(path, method), operation_id|
|
||||
actual = paths.fetch(path).fetch(method).fetch("operationId")
|
||||
abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id
|
||||
end
|
||||
workflow_list = paths.fetch("/api/v1/workflow/tasks").fetch("get")
|
||||
workflow_parameters = workflow_list.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort
|
||||
expected_workflow_parameters = %w[
|
||||
arrivedDateFrom arrivedDateTo formType keyword overdue page projectId size sort status view
|
||||
]
|
||||
abort "workflow task parameter contract drifted: #{workflow_parameters.inspect}" unless
|
||||
workflow_parameters == expected_workflow_parameters
|
||||
workflow_sort = workflow_list.fetch("parameters").find { |parameter| parameter["name"] == "sort" }
|
||||
expected_workflow_sorts = %w[arrivedAt,asc arrivedAt,desc dueAt,asc dueAt,desc updatedAt,asc updatedAt,desc]
|
||||
abort "workflow task sort whitelist drifted" unless workflow_sort&.dig("schema", "enum") == expected_workflow_sorts
|
||||
workflow_size = workflow_list.fetch("parameters").find { |parameter| parameter["name"] == "size" }
|
||||
abort "workflow task size schema drifted" unless workflow_size&.dig("schema", "type") == "integer"
|
||||
abort "workflow task size whitelist drifted" unless workflow_size&.dig("schema", "enum") == [20, 50, 100]
|
||||
|
||||
source_forms = paths.fetch("/api/v1/source/forms").fetch("get")
|
||||
abort "source forms has unstable operationId" unless source_forms["operationId"] == "listSourceForms"
|
||||
source_form_parameters = source_forms.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort
|
||||
expected_source_form_parameters = %w[
|
||||
createdByMe createdDateFrom createdDateTo formType groupCode keyword page projectId size sort sourceSystem
|
||||
status templateVersion
|
||||
]
|
||||
abort "source forms parameter contract drifted: #{source_form_parameters.inspect}" unless
|
||||
source_form_parameters == expected_source_form_parameters
|
||||
source_form_sort = source_forms.fetch("parameters").find { |parameter| parameter["name"] == "sort" }
|
||||
expected_source_form_sorts = %w[
|
||||
updatedAt,asc updatedAt,desc createdAt,asc createdAt,desc businessNo,asc businessNo,desc status,asc status,desc
|
||||
]
|
||||
abort "source forms sort whitelist drifted" unless
|
||||
source_form_sort&.dig("schema", "enum") == expected_source_form_sorts
|
||||
source_form_size = source_forms.fetch("parameters").find { |parameter| parameter["name"] == "size" }
|
||||
abort "source forms size whitelist drifted" unless source_form_size&.dig("schema", "enum") == [20, 50, 100]
|
||||
|
||||
accounting_operations = {
|
||||
["/api/v1/accounting/accounts", "get"] => "listAccountingAccounts",
|
||||
["/api/v1/accounting/events", "get"] => "listAccountingEvents",
|
||||
["/api/v1/accounting/events/{publicId}/generate-draft", "post"] => "generateVoucherDraft",
|
||||
["/api/v1/accounting/vouchers", "get"] => "listVouchers",
|
||||
["/api/v1/accounting/vouchers/{publicId}", "get"] => "getVoucher",
|
||||
["/api/v1/accounting/vouchers/{publicId}", "patch"] => "updateVoucher",
|
||||
["/api/v1/accounting/vouchers/{publicId}/submit", "post"] => "submitVoucher",
|
||||
["/api/v1/accounting/vouchers/{publicId}/approve", "post"] => "approveVoucher",
|
||||
["/api/v1/accounting/vouchers/{publicId}/return", "post"] => "returnVoucher",
|
||||
["/api/v1/accounting/vouchers/{publicId}/export", "post"] => "exportVoucher",
|
||||
["/api/v1/accounting/vouchers/{publicId}/export-file", "get"] => "downloadVoucherExport",
|
||||
["/api/v1/accounting/vouchers/{voucherPublicId}/result-files/{filePublicId}/content", "get"] => "downloadVoucherResultEvidence",
|
||||
["/api/v1/accounting/vouchers/{publicId}/record-result", "post"] => "recordVoucherResult",
|
||||
["/api/v1/accounting/vouchers/{publicId}/verify-result", "post"] => "verifyVoucherResult",
|
||||
["/api/v1/accounting/vouchers/{publicId}/reverse-result", "post"] => "reverseVoucherResult",
|
||||
["/api/v1/accounting/vouchers/{publicId}/reopen-result", "post"] => "reopenVoucherResult",
|
||||
["/api/v1/accounting/vouchers/{publicId}/void", "post"] => "voidVoucher"
|
||||
}
|
||||
accounting_operations.each do |(path, method), operation_id|
|
||||
actual = paths.fetch(path).fetch(method).fetch("operationId")
|
||||
abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id
|
||||
end
|
||||
|
||||
audit_operations = {
|
||||
["/api/v1/audit/logs", "get"] => "listAuditLogs",
|
||||
["/api/v1/audit/logs/{publicId}", "get"] => "getAuditLog",
|
||||
["/api/v1/audit/exports", "post"] => "exportAuditLogs"
|
||||
}
|
||||
audit_operations.each do |(path, method), operation_id|
|
||||
actual = paths.fetch(path).fetch(method).fetch("operationId")
|
||||
abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id
|
||||
end
|
||||
audit_list = paths.fetch("/api/v1/audit/logs").fetch("get")
|
||||
audit_parameters = audit_list.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort
|
||||
expected_audit_parameters = %w[
|
||||
action actorId identityCode keyword objectId objectType occurredFrom occurredTo page requestId result size sort
|
||||
]
|
||||
abort "audit list parameter contract drifted: #{audit_parameters.inspect}" unless
|
||||
audit_parameters == expected_audit_parameters
|
||||
audit_result = audit_list.fetch("parameters").find { |parameter| parameter["name"] == "result" }
|
||||
expected_audit_results = %w[SUCCESS FAILED DENIED BLOCKED]
|
||||
abort "audit result whitelist drifted" unless audit_result&.dig("schema", "enum") == expected_audit_results
|
||||
audit_size = audit_list.fetch("parameters").find { |parameter| parameter["name"] == "size" }
|
||||
abort "audit size whitelist drifted" unless audit_size&.dig("schema", "enum") == [20, 50, 100]
|
||||
audit_actor = audit_list.fetch("parameters").find { |parameter| parameter["name"] == "actorId" }
|
||||
abort "audit actorId length drifted" unless audit_actor&.dig("schema", "maxLength") == 26
|
||||
audit_sort = audit_list.fetch("parameters").find { |parameter| parameter["name"] == "sort" }
|
||||
expected_audit_sorts = %w[occurredAt,asc occurredAt,desc eventSequence,asc eventSequence,desc]
|
||||
abort "audit sort whitelist drifted" unless audit_sort&.dig("schema", "enum") == expected_audit_sorts
|
||||
audit_response_ref = lambda { |operation|
|
||||
operation.dig("responses", "200", "content", "application/json", "schema", "$ref")
|
||||
}
|
||||
abort "audit list response drifted" unless audit_response_ref.call(audit_list) ==
|
||||
"#/components/schemas/ApiResponseListAuditLogView"
|
||||
abort "audit detail response drifted" unless audit_response_ref.call(
|
||||
paths.fetch("/api/v1/audit/logs/{publicId}").fetch("get")
|
||||
) == "#/components/schemas/ApiResponseAuditLogView"
|
||||
abort "audit export response drifted" unless audit_response_ref.call(
|
||||
paths.fetch("/api/v1/audit/exports").fetch("post")
|
||||
) == "#/components/schemas/ApiResponseAuditExportView"
|
||||
problem_ref = "#/components/schemas/ApiProblem"
|
||||
problem_response_ref = lambda { |operation, code|
|
||||
operation.dig("responses", code, "content", "application/problem+json", "schema", "$ref")
|
||||
}
|
||||
{
|
||||
audit_list => %w[400 401 403 422],
|
||||
paths.fetch("/api/v1/audit/logs/{publicId}").fetch("get") => %w[401 403 404],
|
||||
paths.fetch("/api/v1/audit/exports").fetch("post") => %w[400 401 403 422]
|
||||
}.each do |operation, codes|
|
||||
codes.each do |code|
|
||||
abort "audit problem response #{code} drifted" unless
|
||||
problem_response_ref.call(operation, code) == problem_ref
|
||||
end
|
||||
end
|
||||
|
||||
versions = paths.fetch("/api/v1/masterdata/{resource}/{publicId}/versions").fetch("get")
|
||||
abort "master-data versions has unstable operationId" unless
|
||||
versions["operationId"] == "listMasterDataVersions"
|
||||
versions_size = versions.fetch("parameters").find { |parameter| parameter["name"] == "size" }
|
||||
abort "master-data versions size whitelist drifted" unless versions_size&.dig("schema", "enum") == [20, 50, 100]
|
||||
%w[400 401 403 404 422].each do |code|
|
||||
abort "master-data versions problem response #{code} drifted" unless
|
||||
problem_response_ref.call(versions, code) == problem_ref
|
||||
end
|
||||
|
||||
archive_operations = {
|
||||
["/api/v1/archive/packages", "get"] => "listArchivePackages",
|
||||
["/api/v1/archive/packages/{publicId}", "get"] => "getArchivePackage",
|
||||
["/api/v1/archive/packages", "post"] => "createArchivePackage",
|
||||
["/api/v1/archive/packages/{publicId}/check", "post"] => "checkArchivePackage",
|
||||
["/api/v1/archive/packages/{publicId}/submit", "post"] => "submitArchivePackage",
|
||||
["/api/v1/archive/packages/{publicId}/return", "post"] => "returnArchivePackage",
|
||||
["/api/v1/archive/packages/{publicId}/revise", "post"] => "reviseArchivePackage",
|
||||
["/api/v1/archive/packages/{publicId}/archive", "post"] => "archivePackage",
|
||||
["/api/v1/archive/packages/{publicId}/freeze", "post"] => "freezeArchivePackage",
|
||||
["/api/v1/archive/packages/{publicId}/unfreeze", "post"] => "unfreezeArchivePackage",
|
||||
["/api/v1/archive/packages/{packageId}/items/{itemId}/not-applicable", "post"] => "markArchiveItemNotApplicable",
|
||||
["/api/v1/archive/packages/{packageId}/items/{itemId}/not-applicable/review", "post"] => "reviewArchiveItemNotApplicable",
|
||||
["/api/v1/archive/files", "get"] => "listArchiveFiles",
|
||||
["/api/v1/archive/files", "post"] => "uploadArchiveFile",
|
||||
["/api/v1/archive/files/{publicId}", "get"] => "getArchiveFile",
|
||||
["/api/v1/archive/files/{publicId}/rescan", "post"] => "rescanArchiveFile",
|
||||
["/api/v1/archive/files/{publicId}/content", "get"] => "getArchiveFileContent",
|
||||
["/api/v1/archive/borrows", "get"] => "listBorrows",
|
||||
["/api/v1/archive/borrows", "post"] => "createBorrow",
|
||||
["/api/v1/archive/borrows/{publicId}/approve", "post"] => "approveBorrow",
|
||||
["/api/v1/archive/borrows/{publicId}/reject", "post"] => "rejectBorrow",
|
||||
["/api/v1/archive/borrows/{publicId}/return", "post"] => "returnBorrow"
|
||||
}
|
||||
archive_operations.each do |(path, method), operation_id|
|
||||
actual = paths.fetch(path).fetch(method).fetch("operationId")
|
||||
abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id
|
||||
end
|
||||
|
||||
project_operations = {
|
||||
["/api/v1/projects/{publicId}/finance-complete", "post"] => "completeProjectFinance",
|
||||
["/api/v1/projects/{projectPublicId}/forms", "get"] => "getProjectForms",
|
||||
["/api/v1/projects/{projectPublicId}/contracts", "get"] => "getProjectContracts",
|
||||
["/api/v1/projects/{projectPublicId}/receipts", "get"] => "getProjectReceipts",
|
||||
["/api/v1/projects/{projectPublicId}/invoices", "get"] => "getProjectInvoices",
|
||||
["/api/v1/projects/{projectPublicId}/payments", "get"] => "getProjectPayments",
|
||||
["/api/v1/projects/{projectPublicId}/progress", "get"] => "getProjectProgress",
|
||||
["/api/v1/projects/{projectPublicId}/people", "get"] => "getProjectPeople",
|
||||
["/api/v1/projects/{projectPublicId}/accounting", "get"] => "getProjectAccounting",
|
||||
["/api/v1/projects/{projectPublicId}/archives", "get"] => "getProjectArchives",
|
||||
["/api/v1/projects/{projectPublicId}/timeline", "get"] => "getProjectTimeline",
|
||||
["/api/v1/projects/{projectPublicId}/risk-flags", "get"] => "getProjectRiskFlags"
|
||||
}
|
||||
project_operations.each do |(path, method), operation_id|
|
||||
actual = paths.fetch(path).fetch(method).fetch("operationId")
|
||||
abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id
|
||||
end
|
||||
|
||||
governance_operations = {
|
||||
["/api/v1/admin/{resource}", "get"] => "listGovernanceResources",
|
||||
["/api/v1/admin/{resource}/{publicId}", "get"] => "getGovernanceResource",
|
||||
["/api/v1/admin/{resource}", "post"] => "createGovernanceResource",
|
||||
["/api/v1/admin/{resource}/{publicId}", "patch"] => "updateGovernanceResource",
|
||||
["/api/v1/admin/{resource}/{publicId}/{command}", "post"] => "commandGovernanceResource"
|
||||
}
|
||||
governance_operations.each do |(path, method), operation_id|
|
||||
actual = paths.fetch(path).fetch(method).fetch("operationId")
|
||||
abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id
|
||||
end
|
||||
|
||||
update_operations = {
|
||||
["/api/v1/admin/system-update", "get"] => "getSystemUpdateStatus",
|
||||
["/api/v1/admin/system-update/check", "post"] => "checkSystemUpdate",
|
||||
["/api/v1/admin/system-update/download", "post"] => "downloadSystemUpdate",
|
||||
["/api/v1/admin/system-update/install", "post"] => "installSystemUpdate"
|
||||
}
|
||||
update_operations.each do |(path, method), operation_id|
|
||||
actual = paths.fetch(path).fetch(method).fetch("operationId")
|
||||
abort "#{method.upcase} #{path} has unstable operationId #{actual.inspect}" unless actual == operation_id
|
||||
end
|
||||
update_response = paths.fetch("/api/v1/admin/system-update").fetch("get")
|
||||
.dig("responses", "200", "content", "application/json", "schema", "$ref")
|
||||
abort "system update response drifted" unless update_response ==
|
||||
"#/components/schemas/ApiResponseSystemUpdateView"
|
||||
|
||||
governance_list = paths.fetch("/api/v1/admin/{resource}").fetch("get")
|
||||
parameter_names = governance_list.fetch("parameters").map { |parameter| parameter.fetch("name") }.sort
|
||||
expected_parameter_names = %w[keyword page resource size]
|
||||
abort "governance list parameter whitelist drifted: #{parameter_names.inspect}" unless parameter_names == expected_parameter_names
|
||||
resource_parameter = governance_list.fetch("parameters").find { |parameter| parameter["name"] == "resource" }
|
||||
resource_values = resource_parameter&.dig("schema", "enum")
|
||||
expected_resources = %w[users roles scopes templates parameters]
|
||||
abort "governance resource enum drifted: #{resource_values.inspect}" unless resource_values == expected_resources
|
||||
|
||||
schemas = document.fetch("components", {}).fetch("schemas", {})
|
||||
abort "internal query map leaked into OpenAPI" if schemas.key?("MultiValueMapStringString")
|
||||
task_properties = schemas.fetch("WorkflowTaskSummaryView").fetch("properties")
|
||||
abort "workflow keyAmount must remain a decimal string" unless task_properties.dig("keyAmount", "type") == "string"
|
||||
list_response_ref = governance_list.dig("responses", "200", "content", "application/json", "schema", "$ref")
|
||||
expected_list_response_ref = "#/components/schemas/ApiResponseListResourceListView"
|
||||
abort "governance list response drifted: #{list_response_ref.inspect}" unless list_response_ref == expected_list_response_ref
|
||||
list_data_ref = schemas.dig("ApiResponseListResourceListView", "properties", "data", "items", "$ref")
|
||||
abort "governance list data is not ResourceListView" unless list_data_ref == "#/components/schemas/ResourceListView"
|
||||
|
||||
resource_properties = schemas.fetch("ResourceListView").fetch("properties")
|
||||
expected_resource_fields = %w[
|
||||
resource publicId username displayName departmentName enabled mustChangePassword roleCodes
|
||||
code name description sortOrder memberCount userPublicId roleCode permissionCode scopeType
|
||||
companyPublicId projectPublicId amountLimit status formType templateVersion schemaVersion
|
||||
parameterGroup versionNo version publishedAt effectiveAt createdAt updatedAt
|
||||
]
|
||||
missing_fields = expected_resource_fields - resource_properties.keys
|
||||
extra_fields = resource_properties.keys - expected_resource_fields
|
||||
abort "ResourceListView fields drifted; missing=#{missing_fields.inspect}, extra=#{extra_fields.inspect}" unless missing_fields.empty? && extra_fields.empty?
|
||||
abort "amountLimit must remain a decimal string" unless resource_properties.dig("amountLimit", "type") == "string"
|
||||
abort "roleCodes must remain a string array" unless resource_properties.dig("roleCodes", "type") == "array" &&
|
||||
resource_properties.dig("roleCodes", "items", "type") == "string"
|
||||
%w[publishedAt effectiveAt createdAt updatedAt].each do |field|
|
||||
schema = resource_properties.fetch(field)
|
||||
abort "#{field} must remain an ISO date-time string" unless schema["type"] == "string" && schema["format"] == "date-time"
|
||||
end
|
||||
puts "OpenAPI contract checks passed"
|
||||
' "$SPEC_FILE"
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
|
||||
VERSION=${1:-}
|
||||
SEMVER_PATTERN='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'
|
||||
|
||||
if [ "${#VERSION}" -gt 128 ] || [[ ! "$VERSION" =~ $SEMVER_PATTERN ]]; then
|
||||
printf 'Invalid SemVer: %s\n' "$VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${1:-http://127.0.0.1:18080}"
|
||||
VERSION="${OPENAPI_VERSION:-1.0.0}"
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
RAW_FILE="$(mktemp)"
|
||||
NORMALIZED_FILE="$(mktemp)"
|
||||
YAML_FILE="$(mktemp)"
|
||||
OUTPUT_FILE="$(mktemp)"
|
||||
trap 'rm -f "$RAW_FILE" "$NORMALIZED_FILE" "$YAML_FILE" "$OUTPUT_FILE"' EXIT
|
||||
|
||||
curl --fail --silent --show-error "${BASE_URL%/}/api-docs" -o "$RAW_FILE"
|
||||
jq -S --arg version "$VERSION" \
|
||||
'del(.servers)
|
||||
| .info.title = "Kaidi Finance API"
|
||||
| .info.version = $version
|
||||
| .paths |= with_entries(
|
||||
if (.key | test("^/api/v1/archives?(/|$)"))
|
||||
then .value |= with_entries(
|
||||
if (.value.operationId? // "" | test("_1$"))
|
||||
then .value.operationId |= sub("_1$"; "")
|
||||
else .
|
||||
end
|
||||
)
|
||||
else .
|
||||
end
|
||||
)
|
||||
# OpenAPI annotations expose allowableValues as strings even for an integer schema.
|
||||
| (.paths["/api/v1/workflow/tasks"].get.parameters[]
|
||||
| select(.name == "size").schema.enum) |= map(tonumber)
|
||||
| (.paths["/api/v1/source/forms"].get.parameters[]
|
||||
| select(.name == "size").schema.enum) |= map(tonumber)
|
||||
| (.paths["/api/v1/audit/logs"].get.parameters[]
|
||||
| select(.name == "size").schema.enum) |= map(tonumber)
|
||||
| (.paths["/api/v1/masterdata/{resource}/{publicId}/versions"].get.parameters[]
|
||||
| select(.name == "size").schema.enum) |= map(tonumber)' \
|
||||
"$RAW_FILE" > "$NORMALIZED_FILE"
|
||||
ruby -ryaml -rjson -e 'puts JSON.parse(File.read(ARGV[0])).to_yaml' \
|
||||
"$NORMALIZED_FILE" > "$YAML_FILE"
|
||||
{
|
||||
printf '# Generated from Springdoc /api-docs by scripts/export-openapi.sh. Do not edit manually.\n'
|
||||
cat "$YAML_FILE"
|
||||
} > "$OUTPUT_FILE"
|
||||
|
||||
"$ROOT_DIR/scripts/check-openapi.sh" "$OUTPUT_FILE"
|
||||
mv "$OUTPUT_FILE" "$ROOT_DIR/openapi.yaml"
|
||||
printf 'Generated %s from %s/api-docs\n' "$ROOT_DIR/openapi.yaml" "$BASE_URL"
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
OUTPUT=${1:-release-signing-private.pem}
|
||||
[ ! -e "$OUTPUT" ] || { echo "$OUTPUT already exists" >&2; exit 1; }
|
||||
umask 077
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:3072 -out "$OUTPUT"
|
||||
openssl pkey -in "$OUTPUT" -pubout -out "${OUTPUT%.pem}-public.pem"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
PUBLIC_KEY_SHA256=$(sha256sum "${OUTPUT%.pem}-public.pem" | awk '{print $1}')
|
||||
else
|
||||
PUBLIC_KEY_SHA256=$(shasum -a 256 "${OUTPUT%.pem}-public.pem" | awk '{print $1}')
|
||||
fi
|
||||
printf 'Private key: %s\nPublic key: %s\nTrusted public-key SHA-256: %s\n' \
|
||||
"$OUTPUT" "${OUTPUT%.pem}-public.pem" "$PUBLIC_KEY_SHA256"
|
||||
Executable
+221
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
VERSION=${1:-${KAIDI_RELEASE_VERSION:-}}
|
||||
OUTPUT_DIR=${KAIDI_RELEASE_OUTPUT_DIR:-$ROOT/dist/release}
|
||||
SIGNING_KEY=${KAIDI_RELEASE_SIGNING_KEY:-}
|
||||
TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256:-}
|
||||
WORK=$(mktemp -d)
|
||||
FRONTEND_VERSION_OVERRIDDEN=false
|
||||
|
||||
cleanup() {
|
||||
if [ "$FRONTEND_VERSION_OVERRIDDEN" = true ]; then
|
||||
cp "$WORK/package.json" "$ROOT/frontend/package.json"
|
||||
cp "$WORK/package-lock.json" "$ROOT/frontend/package-lock.json"
|
||||
fi
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
sha256_file() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
else
|
||||
shasum -a 256 "$1" | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
"$ROOT/scripts/check-semver.sh" "$VERSION" \
|
||||
|| { printf 'Usage: %s VERSION\n' "$0" >&2; exit 2; }
|
||||
|
||||
SOURCE_REVISION=${KAIDI_SOURCE_REVISION:-}
|
||||
if [ -z "$SOURCE_REVISION" ]; then
|
||||
SOURCE_REVISION=$(git -C "$ROOT" rev-parse --verify HEAD 2>/dev/null || true)
|
||||
fi
|
||||
[[ "$SOURCE_REVISION" =~ ^[0-9A-Fa-f]{40}$ ]] \
|
||||
|| { echo 'KAIDI_SOURCE_REVISION must be a 40-character Git commit SHA' >&2; exit 1; }
|
||||
SOURCE_REVISION=$(printf '%s' "$SOURCE_REVISION" | tr '[:upper:]' '[:lower:]')
|
||||
SOURCE_REF=${KAIDI_SOURCE_REF:-}
|
||||
if [ -z "$SOURCE_REF" ]; then
|
||||
SOURCE_REF=$(git -C "$ROOT" tag --points-at "$SOURCE_REVISION" 2>/dev/null \
|
||||
| awk -v expected="v$VERSION" '$0 == expected { print; exit }')
|
||||
fi
|
||||
[ -n "$SOURCE_REF" ] || SOURCE_REF=local
|
||||
[ "${#SOURCE_REF}" -le 128 ] && [[ "$SOURCE_REF" != *$'\n'* ]] && [[ "$SOURCE_REF" != *$'\r'* ]] \
|
||||
|| { echo 'KAIDI_SOURCE_REF must be a single line of at most 128 characters' >&2; exit 1; }
|
||||
SOURCE_DIRTY=${KAIDI_SOURCE_DIRTY:-}
|
||||
if [ -z "$SOURCE_DIRTY" ]; then
|
||||
if [ -n "$(git -C "$ROOT" status --porcelain --untracked-files=all 2>/dev/null)" ]; then
|
||||
SOURCE_DIRTY=true
|
||||
else
|
||||
SOURCE_DIRTY=false
|
||||
fi
|
||||
fi
|
||||
case "$SOURCE_DIRTY" in
|
||||
true|false) ;;
|
||||
*) echo 'KAIDI_SOURCE_DIRTY must be true or false' >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
if [ "${KAIDI_SKIP_BUILD:-false}" != "true" ]; then
|
||||
cp "$ROOT/frontend/package.json" "$WORK/package.json"
|
||||
cp "$ROOT/frontend/package-lock.json" "$WORK/package-lock.json"
|
||||
FRONTEND_VERSION_OVERRIDDEN=true
|
||||
(cd "$ROOT/frontend" \
|
||||
&& npm version "$VERSION" --no-git-tag-version --allow-same-version --ignore-scripts >/dev/null)
|
||||
(cd "$ROOT/backend" && ./mvnw -q -Drevision="$VERSION" -DskipTests package)
|
||||
(cd "$ROOT/frontend" && HUSKY=0 npm ci && npm run build)
|
||||
fi
|
||||
|
||||
JAR="$ROOT/backend/target/finance-system-$VERSION.jar"
|
||||
[ -s "$JAR" ] || { echo 'Backend release jar is missing' >&2; exit 1; }
|
||||
[ -s "$ROOT/backend/target/backend-sbom.json" ] \
|
||||
|| { echo 'Backend CycloneDX SBOM is missing; run the Maven package phase first' >&2; exit 1; }
|
||||
[ -s "$ROOT/frontend/dist/index.html" ] || { echo 'Frontend release build is missing' >&2; exit 1; }
|
||||
JAR_METADATA_DIR="$WORK/jar-metadata"
|
||||
mkdir -p "$JAR_METADATA_DIR"
|
||||
(cd "$JAR_METADATA_DIR" && jar -xf "$JAR" \
|
||||
META-INF/MANIFEST.MF META-INF/maven/com.kaidi/finance-system/pom.properties)
|
||||
JAR_POM_VERSION=$(sed -n 's/^version=//p' \
|
||||
"$JAR_METADATA_DIR/META-INF/maven/com.kaidi/finance-system/pom.properties")
|
||||
JAR_IMPLEMENTATION_VERSION=$(sed -n 's/^Implementation-Version: //p' \
|
||||
"$JAR_METADATA_DIR/META-INF/MANIFEST.MF" | tr -d '\r')
|
||||
[ "$JAR_POM_VERSION" = "$VERSION" ] && [ "$JAR_IMPLEMENTATION_VERSION" = "$VERSION" ] \
|
||||
|| { printf 'JAR metadata versions do not match release %s\n' "$VERSION" >&2; exit 1; }
|
||||
|
||||
rm -rf "$OUTPUT_DIR"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
STAGE="$WORK/stage"
|
||||
mkdir -p "$STAGE/public" "$STAGE/ops"
|
||||
cp "$JAR" "$STAGE/app.jar"
|
||||
cp -R "$ROOT/frontend/dist/." "$STAGE/public/"
|
||||
printf '%s\n' "$VERSION" > "$STAGE/VERSION"
|
||||
cp "$ROOT/deploy/update.sh" "$STAGE/ops/update.sh"
|
||||
cp "$ROOT/deploy/nginx/kaidi-finance.conf" "$STAGE/ops/kaidi-finance.conf"
|
||||
cp "$ROOT/deploy/systemd/kaidi-finance.service" "$STAGE/ops/kaidi-finance.service"
|
||||
cp "$ROOT/deploy/systemd/kaidi-update.service" "$STAGE/ops/kaidi-update.service"
|
||||
cp "$ROOT/deploy/systemd/kaidi-update.path" "$STAGE/ops/kaidi-update.path"
|
||||
chmod 0755 "$STAGE/ops/update.sh"
|
||||
|
||||
ARTIFACT="kaidi-finance-$VERSION.tar.gz"
|
||||
COPYFILE_DISABLE=1 tar -czf "$OUTPUT_DIR/$ARTIFACT" -C "$STAGE" .
|
||||
SHA256=$(sha256_file "$OUTPUT_DIR/$ARTIFACT")
|
||||
cp "$ROOT/backend/target/backend-sbom.json" "$OUTPUT_DIR/backend-sbom.cdx.json"
|
||||
(cd "$ROOT/frontend" && npm sbom --omit=dev --package-lock-only \
|
||||
--sbom-format cyclonedx --sbom-type application) > "$OUTPUT_DIR/frontend-sbom.cdx.json"
|
||||
BACKEND_BUILD_VERSION=$(jq -er '.metadata.component.version | strings | select(length > 0)' \
|
||||
"$OUTPUT_DIR/backend-sbom.cdx.json")
|
||||
FRONTEND_BUILD_VERSION=$(jq -er '.metadata.component.version | strings | select(length > 0)' \
|
||||
"$OUTPUT_DIR/frontend-sbom.cdx.json")
|
||||
[ "$BACKEND_BUILD_VERSION" = "$VERSION" ] \
|
||||
|| { printf 'Backend build version %s does not match release %s\n' "$BACKEND_BUILD_VERSION" "$VERSION" >&2; exit 1; }
|
||||
[ "$FRONTEND_BUILD_VERSION" = "$VERSION" ] \
|
||||
|| { printf 'Frontend build version %s does not match release %s\n' "$FRONTEND_BUILD_VERSION" "$VERSION" >&2; exit 1; }
|
||||
for sbom in "$OUTPUT_DIR/backend-sbom.cdx.json" "$OUTPUT_DIR/frontend-sbom.cdx.json"; do
|
||||
jq -e --arg version "$VERSION" \
|
||||
'. as $document | .metadata.component.version == $version
|
||||
and (.metadata.component["bom-ref"] | strings | length > 0)
|
||||
and (.metadata.component.purl | strings | length > 0)
|
||||
and any(.dependencies[]; .ref == $document.metadata.component["bom-ref"])' \
|
||||
"$sbom" >/dev/null \
|
||||
|| { printf 'SBOM build-version references are inconsistent in %s\n' "$sbom" >&2; exit 1; }
|
||||
done
|
||||
BACKEND_SBOM_SHA256=$(sha256_file "$OUTPUT_DIR/backend-sbom.cdx.json")
|
||||
FRONTEND_SBOM_SHA256=$(sha256_file "$OUTPUT_DIR/frontend-sbom.cdx.json")
|
||||
|
||||
if [ -n "$SIGNING_KEY" ]; then
|
||||
[ -s "$SIGNING_KEY" ] || { echo 'KAIDI_RELEASE_SIGNING_KEY does not exist' >&2; exit 1; }
|
||||
openssl rsa -in "$SIGNING_KEY" -check -noout >/dev/null 2>&1 \
|
||||
|| { echo 'KAIDI_RELEASE_SIGNING_KEY must be an RSA private key' >&2; exit 1; }
|
||||
SIGNING_KEY_BITS=$(openssl rsa -in "$SIGNING_KEY" -text -noout 2>/dev/null \
|
||||
| sed -n 's/^Private-Key: (\([0-9][0-9]*\) bit.*$/\1/p' | head -n 1)
|
||||
[ -n "$SIGNING_KEY_BITS" ] && [ "$SIGNING_KEY_BITS" -ge 3072 ] \
|
||||
|| { echo 'KAIDI_RELEASE_SIGNING_KEY must use at least 3072 bits' >&2; exit 1; }
|
||||
openssl pkey -in "$SIGNING_KEY" -pubout -out "$OUTPUT_DIR/release-public.pem"
|
||||
elif [ "${KAIDI_ALLOW_UNSIGNED_RELEASE:-false}" = "true" ]; then
|
||||
: > "$OUTPUT_DIR/release-public.pem"
|
||||
else
|
||||
echo 'KAIDI_RELEASE_SIGNING_KEY is required for an online-update release' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -m 0755 "$ROOT/deploy/install.sh" "$OUTPUT_DIR/install.sh"
|
||||
PUBLIC_KEY_SHA256=$(sha256_file "$OUTPUT_DIR/release-public.pem")
|
||||
INSTALLER_SHA256=$(sha256_file "$OUTPUT_DIR/install.sh")
|
||||
if [ -n "$TRUSTED_PUBLIC_KEY_SHA256" ]; then
|
||||
NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256=$(printf '%s' "$TRUSTED_PUBLIC_KEY_SHA256" | tr '[:upper:]' '[:lower:]')
|
||||
[[ "$NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256" =~ ^[0-9a-f]{64}$ ]] \
|
||||
|| { echo 'KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256 must be 64 hexadecimal characters' >&2; exit 1; }
|
||||
[ "$PUBLIC_KEY_SHA256" = "$NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256" ] \
|
||||
|| { echo 'Release signing key does not match the trusted public-key SHA-256' >&2; exit 1; }
|
||||
elif [ "${GITHUB_ACTIONS:-false}" = true ] || [ "${GITEA_ACTIONS:-false}" = true ]; then
|
||||
echo 'KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256 is required in release CI' >&2
|
||||
exit 1
|
||||
fi
|
||||
cat > "$OUTPUT_DIR/bootstrap-checksums.txt" <<EOF
|
||||
KAIDI_RELEASE_PUBLIC_KEY_SHA256=$PUBLIC_KEY_SHA256
|
||||
KAIDI_INSTALLER_SHA256=$INSTALLER_SHA256
|
||||
EOF
|
||||
BOOTSTRAP_SHA256=$(sha256_file "$OUTPUT_DIR/bootstrap-checksums.txt")
|
||||
|
||||
RELEASE_NOTES_VALUE=${KAIDI_RELEASE_NOTES:-"Kaidi Finance Preview $VERSION"}
|
||||
VERSION="$VERSION" ARTIFACT="$ARTIFACT" SHA256="$SHA256" RELEASE_NOTES="$RELEASE_NOTES_VALUE" \
|
||||
BACKEND_SBOM_SHA256="$BACKEND_SBOM_SHA256" FRONTEND_SBOM_SHA256="$FRONTEND_SBOM_SHA256" \
|
||||
BACKEND_BUILD_VERSION="$BACKEND_BUILD_VERSION" FRONTEND_BUILD_VERSION="$FRONTEND_BUILD_VERSION" \
|
||||
INSTALLER_SHA256="$INSTALLER_SHA256" PUBLIC_KEY_SHA256="$PUBLIC_KEY_SHA256" \
|
||||
BOOTSTRAP_SHA256="$BOOTSTRAP_SHA256" SOURCE_REVISION="$SOURCE_REVISION" SOURCE_REF="$SOURCE_REF" \
|
||||
SOURCE_DIRTY="$SOURCE_DIRTY" \
|
||||
node <<'NODE' > "$OUTPUT_DIR/release-manifest.json"
|
||||
const manifest = {
|
||||
version: process.env.VERSION,
|
||||
artifact: process.env.ARTIFACT,
|
||||
sha256: process.env.SHA256,
|
||||
publishedAt: new Date().toISOString(),
|
||||
minimumJava: 17,
|
||||
source: {
|
||||
revision: process.env.SOURCE_REVISION,
|
||||
ref: process.env.SOURCE_REF,
|
||||
dirty: process.env.SOURCE_DIRTY === 'true',
|
||||
},
|
||||
buildVersions: {
|
||||
backend: process.env.BACKEND_BUILD_VERSION,
|
||||
frontend: process.env.FRONTEND_BUILD_VERSION,
|
||||
},
|
||||
releaseNotes: process.env.RELEASE_NOTES,
|
||||
sboms: [
|
||||
{ ecosystem: 'maven', artifact: 'backend-sbom.cdx.json', sha256: process.env.BACKEND_SBOM_SHA256 },
|
||||
{ ecosystem: 'npm', artifact: 'frontend-sbom.cdx.json', sha256: process.env.FRONTEND_SBOM_SHA256 },
|
||||
],
|
||||
bootstrap: {
|
||||
installer: 'install.sh',
|
||||
installerSha256: process.env.INSTALLER_SHA256,
|
||||
publicKey: 'release-public.pem',
|
||||
publicKeySha256: process.env.PUBLIC_KEY_SHA256,
|
||||
checksums: 'bootstrap-checksums.txt',
|
||||
checksumsSha256: process.env.BOOTSTRAP_SHA256,
|
||||
},
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`);
|
||||
NODE
|
||||
|
||||
if [ -n "$SIGNING_KEY" ]; then
|
||||
openssl dgst -sha256 -sign "$SIGNING_KEY" \
|
||||
-out "$OUTPUT_DIR/release-manifest.sig" "$OUTPUT_DIR/release-manifest.json"
|
||||
else
|
||||
: > "$OUTPUT_DIR/release-manifest.sig"
|
||||
fi
|
||||
(
|
||||
cd "$OUTPUT_DIR"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$ARTIFACT" backend-sbom.cdx.json frontend-sbom.cdx.json \
|
||||
release-manifest.json release-manifest.sig release-public.pem install.sh \
|
||||
bootstrap-checksums.txt > SHA256SUMS
|
||||
else
|
||||
shasum -a 256 "$ARTIFACT" backend-sbom.cdx.json frontend-sbom.cdx.json \
|
||||
release-manifest.json release-manifest.sig release-public.pem install.sh \
|
||||
bootstrap-checksums.txt > SHA256SUMS
|
||||
fi
|
||||
)
|
||||
printf 'Release assets created in %s\n' "$OUTPUT_DIR"
|
||||
printf 'Trusted release public-key SHA-256: %s\n' "$PUBLIC_KEY_SHA256"
|
||||
printf 'Installer SHA-256: %s\n' "$INSTALLER_SHA256"
|
||||
Executable
+128
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
umask 077
|
||||
|
||||
SERVER_URL=${GITEA_SERVER_URL:-}
|
||||
REPOSITORY=${GITEA_REPOSITORY:-}
|
||||
TAG=${GITEA_REF_NAME:-}
|
||||
SOURCE_SHA=${GITEA_SHA:-}
|
||||
TOKEN=${GITEA_TOKEN:-}
|
||||
RELEASE_DIR=${KAIDI_RELEASE_DIR:-dist/release}
|
||||
RELEASE_NAME=${KAIDI_RELEASE_NAME:-Kaidi Finance $TAG}
|
||||
RELEASE_BODY=${KAIDI_RELEASE_BODY:-Kaidi Finance $TAG
|
||||
|
||||
Source: $SOURCE_SHA}
|
||||
WORK=$(mktemp -d)
|
||||
AUTH_HEADER=$WORK/gitea-auth-header
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
fail() {
|
||||
printf 'Gitea release failed: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
case "$SERVER_URL" in
|
||||
https://*) ;;
|
||||
*) fail 'GITEA_SERVER_URL must use HTTPS' ;;
|
||||
esac
|
||||
[[ "$REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] \
|
||||
|| fail 'GITEA_REPOSITORY must be OWNER/REPO'
|
||||
[[ "$TAG" =~ ^v[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$ ]] || fail 'GITEA_REF_NAME is invalid'
|
||||
[[ "$SOURCE_SHA" =~ ^[0-9A-Fa-f]{40}$ ]] || fail 'GITEA_SHA must be a 40-character commit SHA'
|
||||
[ -n "$TOKEN" ] && [ "${#TOKEN}" -le 512 ] && [[ "$TOKEN" != *$'\n'* ]] && [[ "$TOKEN" != *$'\r'* ]] \
|
||||
|| fail 'GITEA_TOKEN is invalid'
|
||||
[ -d "$RELEASE_DIR" ] || fail 'release directory is missing'
|
||||
|
||||
printf 'Authorization: token %s\nAccept: application/json\n' "$TOKEN" > "$AUTH_HEADER"
|
||||
chmod 0600 "$AUTH_HEADER"
|
||||
|
||||
API="${SERVER_URL%/}/api/v1/repos/$REPOSITORY"
|
||||
ENCODED_TAG=$(jq -rn --arg value "$TAG" '$value | @uri')
|
||||
|
||||
request() {
|
||||
local method=$1 url=$2 output=$3 input=${4:-} status
|
||||
local args=(
|
||||
--silent --show-error --proto '=https' --tlsv1.2
|
||||
--request "$method" --header "@$AUTH_HEADER"
|
||||
--output "$output" --write-out '%{http_code}'
|
||||
)
|
||||
if [ -n "$input" ]; then
|
||||
args+=(--header 'Content-Type: application/json' --data-binary "@$input")
|
||||
fi
|
||||
status=$(curl "${args[@]}" "$url") || fail "$method $url could not be completed"
|
||||
printf '%s\n' "$status"
|
||||
}
|
||||
|
||||
existing_status=$(request GET "$API/releases/tags/$ENCODED_TAG" "$WORK/existing.json")
|
||||
case "$existing_status" in
|
||||
404) ;;
|
||||
200) fail "release $TAG already exists; published releases are immutable" ;;
|
||||
*) fail "release lookup returned HTTP $existing_status" ;;
|
||||
esac
|
||||
|
||||
jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg commit "$SOURCE_SHA" \
|
||||
--arg name "$RELEASE_NAME" \
|
||||
--arg body "$RELEASE_BODY" \
|
||||
'{tag_name:$tag,target_commitish:$commit,name:$name,body:$body,draft:true,prerelease:false}' \
|
||||
> "$WORK/create.json"
|
||||
create_status=$(request POST "$API/releases" "$WORK/release.json" "$WORK/create.json")
|
||||
[ "$create_status" = 201 ] || fail "draft release creation returned HTTP $create_status"
|
||||
RELEASE_ID=$(jq -er '.id | numbers' "$WORK/release.json") || fail 'draft release id is missing'
|
||||
|
||||
VERSION=${TAG#v}
|
||||
ARTIFACT="kaidi-finance-$VERSION.tar.gz"
|
||||
ASSETS=(
|
||||
"$ARTIFACT"
|
||||
backend-sbom.cdx.json
|
||||
frontend-sbom.cdx.json
|
||||
release-manifest.json
|
||||
release-manifest.sig
|
||||
release-public.pem
|
||||
install.sh
|
||||
bootstrap-checksums.txt
|
||||
SHA256SUMS
|
||||
)
|
||||
|
||||
: > "$WORK/expected-assets.jsonl"
|
||||
for name in "${ASSETS[@]}"; do
|
||||
path="$RELEASE_DIR/$name"
|
||||
[ -f "$path" ] && [ ! -L "$path" ] || fail "release asset $name is missing or unsafe"
|
||||
size=$(wc -c < "$path" | tr -d '[:space:]')
|
||||
jq -cn --arg name "$name" --argjson size "$size" '{name:$name,size:$size}' \
|
||||
>> "$WORK/expected-assets.jsonl"
|
||||
encoded_name=$(jq -rn --arg value "$name" '$value | @uri')
|
||||
upload_status=$(curl --silent --show-error --proto '=https' --tlsv1.2 \
|
||||
--header "@$AUTH_HEADER" \
|
||||
--form "attachment=@$path;type=application/octet-stream" \
|
||||
--output "$WORK/upload.json" --write-out '%{http_code}' \
|
||||
"$API/releases/$RELEASE_ID/assets?name=$encoded_name") \
|
||||
|| fail "asset $name upload could not be completed"
|
||||
[ "$upload_status" = 201 ] || fail "asset $name upload returned HTTP $upload_status"
|
||||
done
|
||||
jq -s 'sort_by(.name)' "$WORK/expected-assets.jsonl" > "$WORK/expected-assets.json"
|
||||
|
||||
verify_status=$(request GET "$API/releases/$RELEASE_ID" "$WORK/verify.json")
|
||||
[ "$verify_status" = 200 ] || fail "draft release verification returned HTTP $verify_status"
|
||||
jq -e --slurpfile expected "$WORK/expected-assets.json" \
|
||||
'([.assets[] | {name,size}] | sort_by(.name)) == $expected[0]' "$WORK/verify.json" >/dev/null \
|
||||
|| fail 'uploaded release asset names or sizes do not match the signed local set'
|
||||
|
||||
jq -n \
|
||||
--arg commit "$SOURCE_SHA" \
|
||||
--arg name "$RELEASE_NAME" \
|
||||
--arg body "$RELEASE_BODY" \
|
||||
'{target_commitish:$commit,name:$name,body:$body,draft:false,prerelease:false}' \
|
||||
> "$WORK/publish.json"
|
||||
publish_status=$(request PATCH "$API/releases/$RELEASE_ID" "$WORK/published.json" "$WORK/publish.json")
|
||||
[ "$publish_status" = 200 ] || fail "release publication returned HTTP $publish_status"
|
||||
jq -e '.draft == false and .prerelease == false and (.assets | length) == 9' \
|
||||
"$WORK/published.json" >/dev/null || fail 'published release state is invalid'
|
||||
|
||||
printf 'Published Gitea release %s with nine verified assets\n' "$TAG"
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
FIXTURE=$(mktemp -d)
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$FIXTURE"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$FIXTURE/repo/deploy" "$FIXTURE/bin"
|
||||
cp "$ROOT/deploy/install-from-git.sh" "$FIXTURE/repo/deploy/install-from-git.sh"
|
||||
|
||||
cat > "$FIXTURE/bin/sudo" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
exec "$@"
|
||||
SH
|
||||
|
||||
cat > "$FIXTURE/repo/deploy/install.sh" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
[ "$KAIDI_RELEASE_API_URL" = 'https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest' ]
|
||||
[ "$KAIDI_RELEASE_PUBLIC_KEY_SHA256" = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' ]
|
||||
if [ "${KAIDI_SETUP_WIZARD:-false}" = true ]; then
|
||||
[ -z "${KAIDI_DB_URL:-}${KAIDI_DB_USERNAME:-}${KAIDI_DB_PASSWORD:-}" ]
|
||||
else
|
||||
[ "$KAIDI_DB_URL" = 'jdbc:mysql://DB_HOST:3306/kaidi_finance' ]
|
||||
[ "$KAIDI_DB_USERNAME" = 'kaidi' ]
|
||||
[ "$KAIDI_DB_PASSWORD" = 'fixture-password' ]
|
||||
fi
|
||||
[ "$(cat "$KAIDI_RELEASE_TOKEN_FILE")" = 'fixture-read-token' ]
|
||||
printf 'fixture-installer: PASS\n'
|
||||
SH
|
||||
|
||||
chmod 0755 "$FIXTURE/bin/sudo" "$FIXTURE/repo/deploy/install.sh" "$FIXTURE/repo/deploy/install-from-git.sh"
|
||||
printf '%s' 'fixture-read-token' > "$FIXTURE/token"
|
||||
chmod 0600 "$FIXTURE/token"
|
||||
installer_sha256=$(sha256sum "$FIXTURE/repo/deploy/install.sh" | awk '{print $1}')
|
||||
|
||||
PATH="$FIXTURE/bin:$PATH" \
|
||||
KAIDI_RELEASE_TOKEN_FILE="$FIXTURE/token" \
|
||||
KAIDI_INSTALLER_SHA256="$installer_sha256" \
|
||||
KAIDI_RELEASE_API_URL=https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest \
|
||||
KAIDI_RELEASE_PUBLIC_KEY_SHA256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
|
||||
KAIDI_DB_URL=jdbc:mysql://DB_HOST:3306/kaidi_finance \
|
||||
KAIDI_DB_USERNAME=kaidi \
|
||||
KAIDI_DB_PASSWORD=fixture-password \
|
||||
"$FIXTURE/repo/deploy/install-from-git.sh"
|
||||
|
||||
PATH="$FIXTURE/bin:$PATH" \
|
||||
KAIDI_RELEASE_TOKEN_FILE="$FIXTURE/token" \
|
||||
KAIDI_INSTALLER_SHA256="$installer_sha256" \
|
||||
KAIDI_RELEASE_API_URL=https://git.example.test/api/v1/repos/TEAM/REPO/releases/latest \
|
||||
KAIDI_RELEASE_PUBLIC_KEY_SHA256=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
|
||||
KAIDI_SETUP_WIZARD=true \
|
||||
"$FIXTURE/repo/deploy/install-from-git.sh"
|
||||
|
||||
if PATH="$FIXTURE/bin:$PATH" \
|
||||
KAIDI_RELEASE_TOKEN_FILE="$FIXTURE/token" \
|
||||
KAIDI_INSTALLER_SHA256=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb \
|
||||
"$FIXTURE/repo/deploy/install-from-git.sh" > "$FIXTURE/hash-mismatch.log" 2>&1; then
|
||||
printf 'Mismatched installer SHA-256 was accepted\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -q 'does not match the trusted SHA-256' "$FIXTURE/hash-mismatch.log"
|
||||
|
||||
printf 'Git checkout installation wrapper fixture passed\n'
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
fail() {
|
||||
printf 'Gitea publish fixture failed: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
VERSION=1.2.3-preview.1
|
||||
TAG=v$VERSION
|
||||
TOKEN=fixture-release-token
|
||||
RELEASE_DIR=$WORK/release
|
||||
MOCK_BIN=$WORK/bin
|
||||
mkdir -p "$RELEASE_DIR" "$MOCK_BIN"
|
||||
for name in \
|
||||
"kaidi-finance-$VERSION.tar.gz" \
|
||||
backend-sbom.cdx.json \
|
||||
frontend-sbom.cdx.json \
|
||||
release-manifest.json \
|
||||
release-manifest.sig \
|
||||
release-public.pem \
|
||||
install.sh \
|
||||
bootstrap-checksums.txt \
|
||||
SHA256SUMS; do
|
||||
printf 'fixture asset %s\n' "$name" > "$RELEASE_DIR/$name"
|
||||
done
|
||||
|
||||
cat > "$MOCK_BIN/curl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
method=GET
|
||||
output=
|
||||
form=
|
||||
url=
|
||||
header_file=
|
||||
printf '%s\n' "$*" >> "$MOCK_CURL_LOG"
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--request) shift; method=$1 ;;
|
||||
--output) shift; output=$1 ;;
|
||||
--form) shift; form=$1; method=POST ;;
|
||||
--header)
|
||||
shift
|
||||
case "$1" in @*) header_file=${1#@} ;; esac
|
||||
;;
|
||||
https://*) url=$1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[ -r "$header_file" ] || exit 90
|
||||
grep -Fqx "Authorization: token $MOCK_GITEA_TOKEN" "$header_file" || exit 91
|
||||
case "$method:$url" in
|
||||
GET:*/releases/tags/*)
|
||||
printf '{"message":"not found"}\n' > "$output"
|
||||
printf '404'
|
||||
;;
|
||||
POST:*/releases)
|
||||
printf '{"id":7,"draft":true,"prerelease":false}\n' > "$output"
|
||||
printf '201'
|
||||
;;
|
||||
POST:*/releases/7/assets*)
|
||||
path=${form#attachment=@}
|
||||
path=${path%%;type=*}
|
||||
name=$(basename "$path")
|
||||
size=$(wc -c < "$path" | tr -d '[:space:]')
|
||||
jq -cn --arg name "$name" --argjson size "$size" '{name:$name,size:$size}' >> "$MOCK_ASSET_LOG"
|
||||
printf '{"name":"%s","size":%s}\n' "$name" "$size" > "$output"
|
||||
printf '201'
|
||||
;;
|
||||
GET:*/releases/7)
|
||||
jq -s '{id:7,draft:true,prerelease:false,assets:.}' "$MOCK_ASSET_LOG" > "$output"
|
||||
printf '200'
|
||||
;;
|
||||
PATCH:*/releases/7)
|
||||
jq -s '{id:7,draft:false,prerelease:false,assets:.}' "$MOCK_ASSET_LOG" > "$output"
|
||||
printf '200'
|
||||
;;
|
||||
*)
|
||||
printf 'unexpected mock request: %s %s\n' "$method" "$url" >&2
|
||||
exit 92
|
||||
;;
|
||||
esac
|
||||
SH
|
||||
chmod 0755 "$MOCK_BIN/curl"
|
||||
: > "$WORK/curl.log"
|
||||
: > "$WORK/assets.jsonl"
|
||||
|
||||
PATH="$MOCK_BIN:$PATH" \
|
||||
MOCK_CURL_LOG="$WORK/curl.log" \
|
||||
MOCK_ASSET_LOG="$WORK/assets.jsonl" \
|
||||
MOCK_GITEA_TOKEN="$TOKEN" \
|
||||
GITEA_SERVER_URL=https://gitea.fixture.invalid \
|
||||
GITEA_REPOSITORY=ERP-Team/kaidi \
|
||||
GITEA_REF_NAME="$TAG" \
|
||||
GITEA_SHA=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \
|
||||
GITEA_TOKEN="$TOKEN" \
|
||||
KAIDI_RELEASE_DIR="$RELEASE_DIR" \
|
||||
"$ROOT/scripts/publish-gitea-release.sh" > "$WORK/publish.log"
|
||||
|
||||
grep -Fqx "Published Gitea release $TAG with nine verified assets" "$WORK/publish.log" \
|
||||
|| fail 'publish script did not report success'
|
||||
[ "$(wc -l < "$WORK/assets.jsonl" | tr -d '[:space:]')" -eq 9 ] \
|
||||
|| fail 'publish script did not upload exactly nine assets'
|
||||
! grep -Fq "$TOKEN" "$WORK/curl.log" \
|
||||
|| fail 'Gitea write token leaked into curl process arguments'
|
||||
|
||||
printf 'Gitea draft, nine-asset verification, and publication fixture passed\n'
|
||||
Executable
+221
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
fail() {
|
||||
printf 'Install fixture failed: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Load only pure helper functions. The installer itself must never run in this fixture.
|
||||
{
|
||||
sed -n '/^decode_env_value()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^read_env_file()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^read_existing_env()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^read_setup_env()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^read_reinstall_env()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^is_semver()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^write_env_file_preserving_unknown()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^azul_arch()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^sha256_file()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^prepare_java()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^download_release_url()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^release_asset_url()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
sed -n '/^download_release_asset()/,/^}/p' "$ROOT/deploy/install.sh"
|
||||
} > "$WORK/helpers.sh"
|
||||
# shellcheck disable=SC1090,SC1091
|
||||
source "$WORK/helpers.sh"
|
||||
|
||||
# shellcheck disable=SC2034 # Referenced by the extracted installer helper.
|
||||
REINSTALL=true
|
||||
# shellcheck disable=SC2034 # Referenced by the extracted installer helper.
|
||||
WORK_DIR=$WORK
|
||||
# shellcheck disable=SC2034 # Referenced by the extracted installer helper.
|
||||
CONFIG_ROOT=$WORK
|
||||
STATE_ROOT=$WORK/state
|
||||
mkdir -p "$STATE_ROOT/setup"
|
||||
env_file="$WORK/kaidi.env"
|
||||
cat > "$env_file" <<'ENV'
|
||||
# operator-owned values must survive a reinstall
|
||||
FINANCE_SESSION_ABSOLUTE_TIMEOUT="45m"
|
||||
FILE_SCANNER_HOST="scanner.internal"
|
||||
FILE_SCANNER_PORT="3310"
|
||||
DB_URL="jdbc:mysql://old/kaidi_finance"
|
||||
APP_VERSION="old"
|
||||
ENV
|
||||
|
||||
# shellcheck disable=SC2016 # These shell characters must remain literal data.
|
||||
literal_password='pw$HOME-$(touch PLACEHOLDER)-`id`-back\slash-"quote"'
|
||||
literal_password=${literal_password/PLACEHOLDER/$WORK\/executed}
|
||||
|
||||
write_env_file_preserving_unknown "$env_file" \
|
||||
DB_URL 'jdbc:mysql://new/kaidi_finance' \
|
||||
DB_PASSWORD "$literal_password" \
|
||||
APP_VERSION '1.0.0-preview.1'
|
||||
|
||||
grep -qx 'FINANCE_SESSION_ABSOLUTE_TIMEOUT="45m"' "$env_file" \
|
||||
|| fail 'reinstall removed the custom session timeout'
|
||||
grep -qx 'FILE_SCANNER_HOST="scanner.internal"' "$env_file" \
|
||||
|| fail 'reinstall removed the custom scanner host'
|
||||
grep -qx 'DB_URL="jdbc:mysql://new/kaidi_finance"' "$env_file" \
|
||||
|| fail 'reinstall did not replace the managed database URL'
|
||||
[ "$(grep -c '^DB_URL=' "$env_file")" -eq 1 ] || fail 'reinstall duplicated a managed key'
|
||||
[ "$(grep -c '^APP_VERSION=' "$env_file")" -eq 1 ] || fail 'reinstall duplicated the application version'
|
||||
[ "$(read_existing_env DB_PASSWORD)" = "$literal_password" ] \
|
||||
|| fail 'reinstall changed literal shell characters in the database password'
|
||||
[ ! -e "$WORK/executed" ] || fail 'reinstall executed database password content'
|
||||
|
||||
cat > "$STATE_ROOT/setup/application.env" <<'ENV'
|
||||
DB_URL="jdbc:mysql://runtime/kaidi_finance"
|
||||
DB_USERNAME="runtime-user"
|
||||
DB_PASSWORD="runtime-password"
|
||||
ENV
|
||||
grep -qx 'jdbc:mysql://runtime/kaidi_finance' <(read_reinstall_env DB_URL) \
|
||||
|| fail 'reinstall did not prefer the completed setup runtime database URL'
|
||||
grep -qx 'runtime-user' <(read_reinstall_env DB_USERNAME) \
|
||||
|| fail 'reinstall did not prefer the completed setup runtime database user'
|
||||
|
||||
for version in 0.0.0 1.2.3-alpha- 1.2.3--alpha 1.2.3-alpha+build.07; do
|
||||
is_semver "$version" || fail "installer rejected valid SemVer $version"
|
||||
"$ROOT/scripts/check-semver.sh" "$version" || fail "release workflow rejected valid SemVer $version"
|
||||
done
|
||||
for version in 01.2.3 1.02.3 1.2.03 1.2.3-01 1.2.3-alpha..1; do
|
||||
! is_semver "$version" || fail "installer accepted invalid SemVer $version"
|
||||
! "$ROOT/scripts/check-semver.sh" "$version" >/dev/null 2>&1 \
|
||||
|| fail "release workflow accepted invalid SemVer $version"
|
||||
done
|
||||
|
||||
ARCH_FIXTURE=
|
||||
uname() {
|
||||
if [ "${1:-}" = -m ]; then
|
||||
printf '%s\n' "$ARCH_FIXTURE"
|
||||
else
|
||||
command uname "$@"
|
||||
fi
|
||||
}
|
||||
die() {
|
||||
return 1
|
||||
}
|
||||
for arch in i386 i486 i586 i686; do
|
||||
ARCH_FIXTURE=$arch
|
||||
[ "$(azul_arch)" = i686 ] || fail "$arch did not map to the Azul i686 runtime"
|
||||
done
|
||||
|
||||
mkdir -p "$WORK/fake-jre/bin"
|
||||
cat > "$WORK/fake-jre/bin/java" <<'JAVA'
|
||||
#!/usr/bin/env sh
|
||||
printf 'openjdk version "17-fixture"\n' >&2
|
||||
JAVA
|
||||
chmod 0755 "$WORK/fake-jre/bin/java"
|
||||
tar -czf "$WORK/java-fixture.tar.gz" -C "$WORK" fake-jre
|
||||
java_fixture_sha=$(sha256sum "$WORK/java-fixture.tar.gz" | awk '{print $1}')
|
||||
cat > "$WORK/java-list.json" <<'JSON'
|
||||
[{"name":"zulu17-fixture-linux_i686.tar.gz","package_uuid":"fixture-package"}]
|
||||
JSON
|
||||
cat > "$WORK/java-detail.json" <<JSON
|
||||
{"download_url":"https://cdn.azul.com/zulu/bin/zulu17-fixture-linux_i686.tar.gz",
|
||||
"sha256_hash":"$java_fixture_sha"}
|
||||
JSON
|
||||
download() {
|
||||
case "$1" in
|
||||
*'/packages/?'*) cp "$WORK/java-list.json" "$2" ;;
|
||||
*/packages/fixture-package) cp "$WORK/java-detail.json" "$2" ;;
|
||||
https://cdn.azul.com/*) cp "$WORK/java-fixture.tar.gz" "$2" ;;
|
||||
*) fail "unexpected Java fixture URL: $1" ;;
|
||||
esac
|
||||
}
|
||||
ARCH_FIXTURE=i686
|
||||
prepare_java >/dev/null 2>&1
|
||||
[ -x "$JAVA_STAGED_DIR/bin/java" ] || fail 'verified i686 Java runtime was not staged'
|
||||
|
||||
export RELEASE_API_URL=https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest
|
||||
RELEASE_TOKEN=fixture-read-only-token
|
||||
RELEASE_AUTH_HEADER_FILE=$WORK/release-auth-header
|
||||
printf 'Authorization: token %s\n' "$RELEASE_TOKEN" > "$RELEASE_AUTH_HEADER_FILE"
|
||||
chmod 0600 "$RELEASE_AUTH_HEADER_FILE"
|
||||
cat > "$WORK/release-api.json" <<'JSON'
|
||||
{"assets":[
|
||||
{"name":"release-manifest.json","browser_download_url":"https://gitea.fixture.invalid/assets/release-manifest.json"}
|
||||
]}
|
||||
JSON
|
||||
printf 'signed manifest fixture\n' > "$WORK/release-manifest.fixture"
|
||||
: > "$WORK/release-curl.log"
|
||||
curl() {
|
||||
local output='' url='' header_file='' argument=''
|
||||
printf '%s\n' "$*" >> "$WORK/release-curl.log"
|
||||
for argument in "$@"; do
|
||||
case "$argument" in
|
||||
@*) header_file=${argument#@} ;;
|
||||
https://*) url=$argument ;;
|
||||
esac
|
||||
done
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-o) shift; output=$1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
[ "$header_file" = "$RELEASE_AUTH_HEADER_FILE" ] || return 90
|
||||
grep -Fqx "Authorization: token $RELEASE_TOKEN" "$header_file" || return 91
|
||||
[ "$url" = https://gitea.fixture.invalid/assets/release-manifest.json ] || return 92
|
||||
cp "$WORK/release-manifest.fixture" "$output"
|
||||
}
|
||||
[ "$(release_asset_url release-manifest.json)" = \
|
||||
https://gitea.fixture.invalid/assets/release-manifest.json ] \
|
||||
|| fail 'installer did not resolve the private Gitea release asset'
|
||||
download_release_asset release-manifest.json "$WORK/downloaded-manifest.json"
|
||||
cmp -s "$WORK/release-manifest.fixture" "$WORK/downloaded-manifest.json" \
|
||||
|| fail 'installer did not download the private Gitea release asset'
|
||||
! grep -Fq "$RELEASE_TOKEN" "$WORK/release-curl.log" \
|
||||
|| fail 'installer leaked the private Gitea token into curl process arguments'
|
||||
! grep -Fq -- '--location' "$WORK/release-curl.log" \
|
||||
|| fail 'installer allowed authenticated Gitea redirects'
|
||||
|
||||
jq '.assets[0].browser_download_url = "https://assets.fixture.invalid/release-manifest.json"' \
|
||||
"$WORK/release-api.json" > "$WORK/release-api.cross-origin.json"
|
||||
mv "$WORK/release-api.cross-origin.json" "$WORK/release-api.json"
|
||||
if release_asset_url release-manifest.json >/dev/null 2>&1; then
|
||||
fail 'installer accepted a cross-origin Gitea release asset'
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2016 # Match literal installer source.
|
||||
grep -Fq '[ "$APP_ROOT" = /opt/kaidi ]' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer rejects unsupported custom roots'
|
||||
grep -Fq '8.4.*) ;;' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer enforces MySQL 8.4.x'
|
||||
# shellcheck disable=SC2016 # Match literal installer source.
|
||||
grep -Fq 'write_env_file_preserving_unknown "$CONFIG_ROOT/kaidi.env"' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer uses the reinstall-safe environment writer'
|
||||
# shellcheck disable=SC2016 # Match literal installer source.
|
||||
grep -Fq 'download "$api" "$java_metadata"' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer applies the restricted downloader to Java metadata'
|
||||
# shellcheck disable=SC2016 # Match literal installer source.
|
||||
grep -Fq 'java_sha256=$(jq -er' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer obtains the Java runtime SHA-256'
|
||||
# shellcheck disable=SC2016 # Match literal installer source.
|
||||
grep -Fq '[ "$actual_sha256" = "$java_sha256" ]' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer verifies the Java runtime SHA-256'
|
||||
grep -Fq 'https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer default release source is not the private Gitea latest API'
|
||||
grep -Fq 'KAIDI_RELEASE_TOKEN_FILE' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer supports a protected initial Gitea token file'
|
||||
grep -Fq 'KAIDI_SETUP_WIZARD' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer supports first-run setup mode'
|
||||
grep -Fq 'FINANCE_SETUP_TOKEN_SHA256' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'installer no longer writes the one-time setup-code hash'
|
||||
grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \
|
||||
"$ROOT/deploy/systemd/kaidi-finance.service" \
|
||||
|| fail 'application service no longer loads the setup-completion environment'
|
||||
grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \
|
||||
"$ROOT/deploy/systemd/kaidi-update.service" \
|
||||
|| fail 'update service no longer loads setup database overrides'
|
||||
# shellcheck disable=SC2016 # Match the literal installer source.
|
||||
grep -Fq 'chown root:kaidi "$UPDATE_STATE_ROOT"' "$ROOT/deploy/install.sh" \
|
||||
|| fail 'update state parent is not group-accessible to the application user'
|
||||
grep -Fq 'KAIDI_SETUP_WIZARD=true' "$ROOT/README.md" \
|
||||
|| fail 'README does not document the setup-wizard install path'
|
||||
|
||||
printf 'Install configuration, private Gitea, i686, setup wizard, and MySQL 8.4 fixtures passed\n'
|
||||
Executable
+444
@@ -0,0 +1,444 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
REAL_OPENSSL=$(command -v openssl)
|
||||
|
||||
fail() {
|
||||
printf 'Update fixture failed: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
sed -n '/^is_semver()/,/^}/p' "$ROOT/deploy/update.sh" > "$WORK/update-semver.sh"
|
||||
# shellcheck disable=SC1090,SC1091
|
||||
source "$WORK/update-semver.sh"
|
||||
for version in 0.0.0 1.2.3-alpha- 1.2.3--alpha 1.2.3-alpha+build.07; do
|
||||
is_semver "$version" || fail "updater rejected valid SemVer $version"
|
||||
done
|
||||
for version in 01.2.3 1.02.3 1.2.03 1.2.3-01 1.2.3-alpha..1; do
|
||||
! is_semver "$version" || fail "updater accepted invalid SemVer $version"
|
||||
done
|
||||
|
||||
write_mock_commands() {
|
||||
local mock_bin=$1
|
||||
mkdir -p "$mock_bin"
|
||||
|
||||
cat > "$mock_bin/curl" <<'SH'
|
||||
#!/bin/sh
|
||||
output=
|
||||
url=
|
||||
header_file=
|
||||
printf '%s\n' "$*" >> "${MOCK_CURL_LOG:-/dev/null}"
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
-o) shift; output=$1 ;;
|
||||
--header|-H)
|
||||
shift
|
||||
case "${1:-}" in @*) header_file=${1#@} ;; esac
|
||||
;;
|
||||
-*) ;;
|
||||
*) url=$1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
case "$url" in
|
||||
"${MOCK_RELEASE_ORIGIN:-https://release.fixture.invalid}"/*)
|
||||
if [ -n "${MOCK_EXPECT_RELEASE_TOKEN:-}" ]; then
|
||||
[ -r "$header_file" ] || exit 90
|
||||
grep -Fqx "Authorization: token $MOCK_EXPECT_RELEASE_TOKEN" "$header_file" || exit 91
|
||||
printf 'AUTH_FILE_OK\n' >> "${MOCK_CURL_LOG:-/dev/null}"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
if [ -n "$output" ]; then
|
||||
if [ -n "${MOCK_RELEASE_API_URL:-}" ] && [ "$url" = "$MOCK_RELEASE_API_URL" ]; then
|
||||
cp "$FIXTURE_RELEASE_ROOT/release-api.json" "$output"
|
||||
elif [ "$url" = "${KAIDI_PUBLIC_INDEX_URL:-http://127.0.0.1/}" ]; then
|
||||
cp "$MOCK_APP_ROOT/current/public/index.html" "$output"
|
||||
else
|
||||
[ "${MOCK_DOWNLOAD_FAILURE:-}" != "${url##*/}" ] || exit 22
|
||||
cp "$FIXTURE_RELEASE_ROOT/${url##*/}" "$output"
|
||||
fi
|
||||
elif [ "${MOCK_HEALTH:-success}" = success ]; then
|
||||
printf '{"status":"UP"}\n'
|
||||
elif [ "${MOCK_HEALTH:-success}" = fail-new ] \
|
||||
&& [ "$(cat "$MOCK_APP_ROOT/current/VERSION" 2>/dev/null)" = '1.0.0-preview.1' ]; then
|
||||
printf '{"status":"UP"}\n'
|
||||
else
|
||||
exit 22
|
||||
fi
|
||||
SH
|
||||
|
||||
cat > "$mock_bin/systemctl" <<'SH'
|
||||
#!/bin/sh
|
||||
printf '%s\n' "$*" >> "$MOCK_SYSTEMCTL_LOG"
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cat > "$mock_bin/nginx" <<'SH'
|
||||
#!/bin/sh
|
||||
printf '%s\n' "$*" >> "$MOCK_NGINX_LOG"
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cat > "$mock_bin/flock" <<'SH'
|
||||
#!/bin/sh
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cat > "$mock_bin/systemd-analyze" <<'SH'
|
||||
#!/bin/sh
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cat > "$mock_bin/mysqldump" <<'SH'
|
||||
#!/bin/sh
|
||||
if [ "${MOCK_MYSQLDUMP:-success}" = fail ]; then
|
||||
printf 'partial dump\n'
|
||||
exit 9
|
||||
fi
|
||||
printf '%s\n' '-- valid fixture dump' 'CREATE TABLE fixture (id INT);'
|
||||
SH
|
||||
|
||||
cat > "$mock_bin/chown" <<'SH'
|
||||
#!/bin/sh
|
||||
exit 0
|
||||
SH
|
||||
|
||||
cat > "$mock_bin/mv" <<'SH'
|
||||
#!/bin/sh
|
||||
case "${1:-}" in
|
||||
-Tf | -fT)
|
||||
source=$2
|
||||
destination=$3
|
||||
/bin/rm -f "$destination"
|
||||
/bin/mv "$source" "$destination"
|
||||
;;
|
||||
*) /bin/mv "$@" ;;
|
||||
esac
|
||||
SH
|
||||
|
||||
cat > "$mock_bin/sha256sum" <<'SH'
|
||||
#!/bin/sh
|
||||
hash=$($REAL_OPENSSL dgst -sha256 "$1" | awk '{print $NF}')
|
||||
printf '%s %s\n' "$hash" "$1"
|
||||
SH
|
||||
chmod 0755 "$mock_bin"/*
|
||||
}
|
||||
|
||||
build_release() {
|
||||
local fixture=$1
|
||||
local version=$2
|
||||
local stage="$fixture/stage"
|
||||
mkdir -p "$fixture/release" "$stage/public" "$stage/ops"
|
||||
printf 'new application\n' > "$stage/app.jar"
|
||||
printf '<!doctype html><title>new</title>\n' > "$stage/public/index.html"
|
||||
printf '%s\n' "$version" > "$stage/VERSION"
|
||||
printf '#!/bin/sh\nprintf "new updater\\n"\n' > "$stage/ops/update.sh"
|
||||
chmod 0755 "$stage/ops/update.sh"
|
||||
for name in kaidi-finance.service kaidi-update.service kaidi-update.path kaidi-finance.conf; do
|
||||
printf 'new %s\n' "$name" > "$stage/ops/$name"
|
||||
done
|
||||
|
||||
local artifact="kaidi-finance-$version.tar.gz"
|
||||
COPYFILE_DISABLE=1 tar -czf "$fixture/release/$artifact" -C "$stage" .
|
||||
local sha
|
||||
sha=$($REAL_OPENSSL dgst -sha256 "$fixture/release/$artifact" | awk '{print $NF}')
|
||||
jq -n --arg version "$version" --arg artifact "$artifact" --arg sha "$sha" \
|
||||
'{version:$version,artifact:$artifact,sha256:$sha,publishedAt:"2026-08-16T00:00:00Z",releaseNotes:"fixture"}' \
|
||||
> "$fixture/release/release-manifest.json"
|
||||
"$REAL_OPENSSL" genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 \
|
||||
-out "$fixture/private.pem" >/dev/null 2>&1
|
||||
"$REAL_OPENSSL" pkey -in "$fixture/private.pem" -pubout \
|
||||
-out "$fixture/release-public.pem" >/dev/null 2>&1
|
||||
"$REAL_OPENSSL" dgst -sha256 -sign "$fixture/private.pem" \
|
||||
-out "$fixture/release/release-manifest.sig" "$fixture/release/release-manifest.json"
|
||||
}
|
||||
|
||||
build_gitea_release_index() {
|
||||
local fixture=$1
|
||||
local origin=${2:-https://gitea.fixture.invalid}
|
||||
local artifact
|
||||
artifact=$(jq -er '.artifact' "$fixture/release/release-manifest.json")
|
||||
jq -n --arg origin "$origin" --arg artifact "$artifact" \
|
||||
'{assets:[
|
||||
{name:"release-manifest.json",browser_download_url:($origin + "/assets/release-manifest.json")},
|
||||
{name:"release-manifest.sig",browser_download_url:($origin + "/assets/release-manifest.sig")},
|
||||
{name:$artifact,browser_download_url:($origin + "/assets/" + $artifact)}
|
||||
]}' > "$fixture/release/release-api.json"
|
||||
}
|
||||
|
||||
prepare_installation() {
|
||||
local fixture=$1
|
||||
local version=$2
|
||||
mkdir -p "$fixture/app/releases/1.0.0-preview.1/public" "$fixture/app/bin" \
|
||||
"$fixture/state/inbox" "$fixture/systemd" "$fixture/nginx" "$fixture/log"
|
||||
printf 'old application\n' > "$fixture/app/releases/1.0.0-preview.1/app.jar"
|
||||
printf '<!doctype html><title>old</title>\n' > "$fixture/app/releases/1.0.0-preview.1/public/index.html"
|
||||
printf '1.0.0-preview.1\n' > "$fixture/app/releases/1.0.0-preview.1/VERSION"
|
||||
ln -s "$fixture/app/releases/1.0.0-preview.1" "$fixture/app/current"
|
||||
printf 'old update.sh\n' > "$fixture/app/bin/update.sh"
|
||||
chmod 0755 "$fixture/app/bin/update.sh"
|
||||
for name in kaidi-finance.service kaidi-update.service kaidi-update.path; do
|
||||
printf 'old %s\n' "$name" > "$fixture/systemd/$name"
|
||||
done
|
||||
printf 'old kaidi-finance.conf\n' > "$fixture/nginx/kaidi-finance.conf"
|
||||
write_request "$fixture" "$version" DOWNLOAD
|
||||
}
|
||||
|
||||
write_request() {
|
||||
local fixture=$1
|
||||
local version=$2
|
||||
local action=$3
|
||||
mkdir -p "$fixture/state/inbox"
|
||||
jq -n --arg action "$action" --arg version "$version" \
|
||||
'{action:$action,version:$version,reason:"fixture"}' > "$fixture/state/inbox/request.json"
|
||||
}
|
||||
|
||||
download_and_prepare_install() {
|
||||
local fixture=$1
|
||||
local version=$2
|
||||
run_update "$fixture" success
|
||||
[ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \
|
||||
|| fail 'download phase did not persist READY'
|
||||
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|
||||
|| fail 'download phase changed the active application'
|
||||
[ -s "$fixture/state/cache/$version/release.tar.gz" ] \
|
||||
|| fail 'download phase did not persist the verified artifact cache'
|
||||
write_request "$fixture" "$version" INSTALL
|
||||
}
|
||||
|
||||
run_update() {
|
||||
local fixture=$1
|
||||
local health=$2
|
||||
local skip_backup=${3:-true}
|
||||
env \
|
||||
PATH="$fixture/mock-bin:$PATH" \
|
||||
REAL_OPENSSL="$REAL_OPENSSL" \
|
||||
FIXTURE_RELEASE_ROOT="$fixture/release" \
|
||||
MOCK_APP_ROOT="$fixture/app" \
|
||||
MOCK_HEALTH="$health" \
|
||||
MOCK_DOWNLOAD_FAILURE="${MOCK_DOWNLOAD_FAILURE:-}" \
|
||||
MOCK_MYSQLDUMP="${MOCK_MYSQLDUMP:-success}" \
|
||||
MOCK_CURL_LOG="${MOCK_CURL_LOG:-$fixture/curl.log}" \
|
||||
MOCK_RELEASE_API_URL="${FIXTURE_RELEASE_API_URL-}" \
|
||||
MOCK_RELEASE_ORIGIN="${FIXTURE_RELEASE_ORIGIN:-https://release.fixture.invalid}" \
|
||||
MOCK_EXPECT_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \
|
||||
MOCK_SYSTEMCTL_LOG="$fixture/systemctl.log" \
|
||||
MOCK_NGINX_LOG="$fixture/nginx.log" \
|
||||
KAIDI_APP_ROOT="$fixture/app" \
|
||||
KAIDI_UPDATE_STATE_ROOT="$fixture/state" \
|
||||
KAIDI_LOG_ROOT="$fixture/log" \
|
||||
KAIDI_SYSTEMD_ROOT="$fixture/systemd" \
|
||||
KAIDI_NGINX_CONFIG="$fixture/nginx/kaidi-finance.conf" \
|
||||
KAIDI_UPDATER_PATH="$fixture/app/bin/update.sh" \
|
||||
KAIDI_SKIP_DB_BACKUP="$skip_backup" \
|
||||
KAIDI_UPDATE_HEALTH_ATTEMPTS=1 \
|
||||
KAIDI_UPDATE_HEALTH_INTERVAL_SECONDS=0 \
|
||||
UPDATE_RELEASE_BASE_URL="${FIXTURE_RELEASE_BASE_URL-https://release.fixture.invalid}" \
|
||||
UPDATE_RELEASE_API_URL="${FIXTURE_RELEASE_API_URL-}" \
|
||||
UPDATE_RELEASE_TOKEN="${FIXTURE_RELEASE_TOKEN-}" \
|
||||
UPDATE_PUBLIC_KEY="$fixture/release-public.pem" \
|
||||
UPDATE_REQUEST_FILE="$fixture/state/inbox/request.json" \
|
||||
UPDATE_STATUS_FILE="$fixture/state/status.json" \
|
||||
KAIDI_HEALTH_URL=http://127.0.0.1:18080/actuator/health \
|
||||
KAIDI_PUBLIC_HEALTH_URL=http://127.0.0.1/actuator/health \
|
||||
KAIDI_PUBLIC_INDEX_URL=http://127.0.0.1/ \
|
||||
"$ROOT/deploy/update.sh"
|
||||
}
|
||||
|
||||
assert_private_gitea_release_case() {
|
||||
local fixture="$WORK/private-gitea"
|
||||
local version='1.0.0-preview.2'
|
||||
local api_url='https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest'
|
||||
local token='fixture-read-only-token'
|
||||
mkdir -p "$fixture"
|
||||
write_mock_commands "$fixture/mock-bin"
|
||||
build_release "$fixture" "$version"
|
||||
build_gitea_release_index "$fixture"
|
||||
prepare_installation "$fixture" "$version"
|
||||
|
||||
FIXTURE_RELEASE_BASE_URL='' \
|
||||
FIXTURE_RELEASE_API_URL="$api_url" \
|
||||
FIXTURE_RELEASE_ORIGIN=https://gitea.fixture.invalid \
|
||||
FIXTURE_RELEASE_TOKEN="$token" \
|
||||
MOCK_CURL_LOG="$fixture/curl.log" \
|
||||
run_update "$fixture" success
|
||||
|
||||
[ "$(jq -r '.state' "$fixture/state/status.json")" = READY ] \
|
||||
|| fail 'private Gitea download did not persist READY'
|
||||
! grep -Fq "$token" "$fixture/curl.log" \
|
||||
|| fail 'private Gitea token leaked into curl process arguments'
|
||||
[ "$(grep -c '^AUTH_FILE_OK$' "$fixture/curl.log")" -eq 4 ] \
|
||||
|| fail 'private Gitea requests did not use the protected header file'
|
||||
! grep -Fq -- '--location' "$fixture/curl.log" \
|
||||
|| fail 'authenticated private Gitea requests unexpectedly enabled redirects'
|
||||
}
|
||||
|
||||
assert_cross_origin_gitea_asset_rejected() {
|
||||
local fixture="$WORK/cross-origin-gitea"
|
||||
local version='1.0.0-preview.2'
|
||||
mkdir -p "$fixture"
|
||||
write_mock_commands "$fixture/mock-bin"
|
||||
build_release "$fixture" "$version"
|
||||
build_gitea_release_index "$fixture" https://assets.fixture.invalid
|
||||
prepare_installation "$fixture" "$version"
|
||||
|
||||
if FIXTURE_RELEASE_BASE_URL='' \
|
||||
FIXTURE_RELEASE_API_URL=https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest \
|
||||
FIXTURE_RELEASE_ORIGIN=https://gitea.fixture.invalid \
|
||||
FIXTURE_RELEASE_TOKEN=fixture-read-only-token \
|
||||
MOCK_CURL_LOG="$fixture/curl.log" \
|
||||
run_update "$fixture" success > "$fixture/update.log" 2>&1; then
|
||||
fail 'cross-origin Gitea asset unexpectedly succeeded'
|
||||
fi
|
||||
grep -q 'Release manifest asset is missing' "$fixture/update.log" \
|
||||
|| fail 'cross-origin Gitea asset rejection was not reported'
|
||||
! grep -Fq 'assets.fixture.invalid' "$fixture/curl.log" \
|
||||
|| fail 'cross-origin Gitea asset was requested'
|
||||
}
|
||||
|
||||
assert_success_case() {
|
||||
local fixture="$WORK/success"
|
||||
local version='1.0.0-preview.2+build.7'
|
||||
mkdir -p "$fixture"
|
||||
write_mock_commands "$fixture/mock-bin"
|
||||
build_release "$fixture" "$version"
|
||||
prepare_installation "$fixture" "$version"
|
||||
|
||||
download_and_prepare_install "$fixture" "$version"
|
||||
run_update "$fixture" success
|
||||
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/$version" ] \
|
||||
|| fail 'success case did not activate the new application'
|
||||
cmp -s "$fixture/app/bin/update.sh" "$fixture/app/current/ops/update.sh" \
|
||||
|| fail 'success case did not activate the signed updater'
|
||||
[ "$(jq -r '.state' "$fixture/state/status.json")" = SUCCEEDED ] \
|
||||
|| fail 'success case did not persist SUCCEEDED'
|
||||
[ ! -e "$fixture/state/processing/request.json" ] \
|
||||
|| fail 'success case left a claimed request behind'
|
||||
grep -qx 'daemon-reload' "$fixture/systemctl.log" || fail 'systemd units were not reloaded'
|
||||
grep -qx 'reload nginx' "$fixture/systemctl.log" || fail 'Nginx was not reloaded'
|
||||
}
|
||||
|
||||
assert_rollback_case() {
|
||||
local fixture="$WORK/rollback"
|
||||
local version='1.0.0-preview.2'
|
||||
mkdir -p "$fixture"
|
||||
write_mock_commands "$fixture/mock-bin"
|
||||
build_release "$fixture" "$version"
|
||||
prepare_installation "$fixture" "$version"
|
||||
download_and_prepare_install "$fixture" "$version"
|
||||
|
||||
if run_update "$fixture" fail-new > "$fixture/update.log" 2>&1; then
|
||||
fail 'rollback case unexpectedly succeeded'
|
||||
fi
|
||||
grep -q 'previous release was restored and verified' "$fixture/update.log" \
|
||||
|| fail 'rollback case did not report a complete restoration'
|
||||
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|
||||
|| fail 'rollback case did not restore the previous application'
|
||||
grep -qx 'old update.sh' "$fixture/app/bin/update.sh" \
|
||||
|| fail 'rollback case did not restore the previous updater'
|
||||
grep -qx 'old kaidi-update.path' "$fixture/systemd/kaidi-update.path" \
|
||||
|| fail 'rollback case did not restore the previous path unit'
|
||||
[ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \
|
||||
|| fail 'rollback case did not persist FAILED'
|
||||
[ ! -e "$fixture/app/releases/$version" ] \
|
||||
|| fail 'rollback case left the failed release installed'
|
||||
find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|
||||
|| fail 'rollback case did not archive the failed request'
|
||||
}
|
||||
|
||||
assert_download_failure_case() {
|
||||
local fixture="$WORK/download-failure"
|
||||
local version='1.0.0-preview.2'
|
||||
mkdir -p "$fixture"
|
||||
write_mock_commands "$fixture/mock-bin"
|
||||
build_release "$fixture" "$version"
|
||||
prepare_installation "$fixture" "$version"
|
||||
|
||||
if MOCK_DOWNLOAD_FAILURE=release-manifest.json run_update "$fixture" success > "$fixture/update.log" 2>&1; then
|
||||
fail 'download failure case unexpectedly succeeded'
|
||||
fi
|
||||
[ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \
|
||||
|| fail 'download failure did not persist FAILED'
|
||||
[ ! -e "$fixture/state/processing/request.json" ] \
|
||||
|| fail 'download failure left a busy processing request'
|
||||
find "$fixture/state/failed" -type f -name 'request-*.json' -print -quit | grep -q . \
|
||||
|| fail 'download failure did not archive the request'
|
||||
}
|
||||
|
||||
assert_database_failure_case() {
|
||||
local fixture="$WORK/database-failure"
|
||||
local version='1.0.0-preview.2'
|
||||
mkdir -p "$fixture"
|
||||
write_mock_commands "$fixture/mock-bin"
|
||||
build_release "$fixture" "$version"
|
||||
prepare_installation "$fixture" "$version"
|
||||
download_and_prepare_install "$fixture" "$version"
|
||||
|
||||
if MOCK_MYSQLDUMP=fail run_update "$fixture" success false > "$fixture/update.log" 2>&1; then
|
||||
fail 'database failure case unexpectedly succeeded'
|
||||
fi
|
||||
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|
||||
|| fail 'database failure changed the active application'
|
||||
find "$fixture/state/backups" -type f -name 'mysql-*.sql.gz' -print -quit | grep -q . \
|
||||
&& fail 'database failure produced a trusted backup artifact'
|
||||
[ "$(jq -r '.state' "$fixture/state/status.json")" = FAILED ] \
|
||||
|| fail 'database failure did not persist FAILED'
|
||||
}
|
||||
|
||||
assert_symlink_request_rejected() {
|
||||
local fixture="$WORK/symlink-request"
|
||||
local version='1.0.0-preview.2'
|
||||
mkdir -p "$fixture"
|
||||
write_mock_commands "$fixture/mock-bin"
|
||||
build_release "$fixture" "$version"
|
||||
prepare_installation "$fixture" "$version"
|
||||
printf '%s\n' 'operator-owned sentinel' > "$fixture/sentinel"
|
||||
rm "$fixture/state/inbox/request.json"
|
||||
ln -s "$fixture/sentinel" "$fixture/state/inbox/request.json"
|
||||
|
||||
if run_update "$fixture" success > "$fixture/update.log" 2>&1; then
|
||||
fail 'symlink request case unexpectedly succeeded'
|
||||
fi
|
||||
grep -q 'Update request must be a regular file' "$fixture/update.log" \
|
||||
|| fail 'symlink request case did not report the unsafe request'
|
||||
[ "$(cat "$fixture/sentinel")" = 'operator-owned sentinel' ] \
|
||||
|| fail 'symlink request case changed the link target'
|
||||
[ ! -e "$fixture/state/inbox/request.json" ] && [ ! -L "$fixture/state/inbox/request.json" ] \
|
||||
|| fail 'symlink request case left the unsafe request in the inbox'
|
||||
local actual_state
|
||||
actual_state=$(jq -r '.state // "MISSING"' "$fixture/state/status.json")
|
||||
[ "$actual_state" = FAILED ] \
|
||||
|| fail "symlink request case persisted state $actual_state instead of FAILED"
|
||||
}
|
||||
|
||||
assert_install_without_verified_cache_rejected() {
|
||||
local fixture="$WORK/install-without-cache"
|
||||
local version='1.0.0-preview.2'
|
||||
mkdir -p "$fixture"
|
||||
write_mock_commands "$fixture/mock-bin"
|
||||
build_release "$fixture" "$version"
|
||||
prepare_installation "$fixture" "$version"
|
||||
write_request "$fixture" "$version" INSTALL
|
||||
|
||||
if run_update "$fixture" success > "$fixture/update.log" 2>&1; then
|
||||
fail 'install without cache unexpectedly succeeded'
|
||||
fi
|
||||
grep -q 'Verified release cache is missing' "$fixture/update.log" \
|
||||
|| fail 'install without cache did not report the missing verified cache'
|
||||
[ "$(readlink "$fixture/app/current")" = "$fixture/app/releases/1.0.0-preview.1" ] \
|
||||
|| fail 'install without cache changed the active application'
|
||||
}
|
||||
|
||||
assert_success_case
|
||||
assert_rollback_case
|
||||
assert_download_failure_case
|
||||
assert_database_failure_case
|
||||
assert_symlink_request_rejected
|
||||
assert_install_without_verified_cache_rejected
|
||||
assert_private_gitea_release_case
|
||||
assert_cross_origin_gitea_asset_rejected
|
||||
printf 'Online update download, confirmation, success, rollback, failure, and unsafe-request fixtures passed\n'
|
||||
Executable
+170
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
RELEASE_DIR=${1:-$ROOT/dist/release}
|
||||
TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256:-}
|
||||
EXPECTED_SOURCE_REVISION=${KAIDI_EXPECTED_SOURCE_REVISION:-}
|
||||
EXPECTED_SOURCE_REF=${KAIDI_EXPECTED_SOURCE_REF:-}
|
||||
EXPECTED_SOURCE_DIRTY=${KAIDI_EXPECTED_SOURCE_DIRTY:-}
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
|
||||
sha256_file() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
else
|
||||
shasum -a 256 "$1" | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
cd "$RELEASE_DIR"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum -c SHA256SUMS
|
||||
else
|
||||
shasum -a 256 -c SHA256SUMS
|
||||
fi
|
||||
openssl dgst -sha256 -verify release-public.pem \
|
||||
-signature release-manifest.sig release-manifest.json
|
||||
|
||||
VERSION=$(jq -er '.version | strings | select(length > 0)' release-manifest.json)
|
||||
"$ROOT/scripts/check-semver.sh" "$VERSION" \
|
||||
|| { printf 'Release version is not valid SemVer\n' >&2; exit 1; }
|
||||
ARTIFACT=$(jq -er '.artifact | strings | select(length > 0)' release-manifest.json)
|
||||
[ -s "$ARTIFACT" ] || { printf 'Release artifact is missing\n' >&2; exit 1; }
|
||||
[ "$ARTIFACT" = "kaidi-finance-$VERSION.tar.gz" ] \
|
||||
|| { printf 'Release artifact name does not match the version\n' >&2; exit 1; }
|
||||
printf '%s\n' \
|
||||
SHA256SUMS \
|
||||
backend-sbom.cdx.json \
|
||||
bootstrap-checksums.txt \
|
||||
frontend-sbom.cdx.json \
|
||||
install.sh \
|
||||
"$ARTIFACT" \
|
||||
release-manifest.json \
|
||||
release-manifest.sig \
|
||||
release-public.pem | LC_ALL=C sort > "$WORK/expected-assets.txt"
|
||||
for path in ./* ./.[!.]* ./..?*; do
|
||||
[ -e "$path" ] || [ -L "$path" ] || continue
|
||||
basename "$path"
|
||||
done | LC_ALL=C sort > "$WORK/actual-assets.txt"
|
||||
diff -u "$WORK/expected-assets.txt" "$WORK/actual-assets.txt" \
|
||||
|| { printf 'Release directory must contain exactly the nine published assets\n' >&2; exit 1; }
|
||||
EXPECTED_ARTIFACT_SHA256=$(jq -er \
|
||||
'.sha256 | strings | ascii_downcase | select(test("^[0-9a-f]{64}$"))' \
|
||||
release-manifest.json)
|
||||
[ "$(sha256_file "$ARTIFACT")" = "$EXPECTED_ARTIFACT_SHA256" ] \
|
||||
|| { printf 'Release artifact digest does not match the signed manifest\n' >&2; exit 1; }
|
||||
[ "$(jq '.sboms | length' release-manifest.json)" -eq 2 ] \
|
||||
|| { printf 'Release manifest must bind two SBOMs\n' >&2; exit 1; }
|
||||
jq -e --arg version "$VERSION" \
|
||||
'.source.revision | strings | test("^[0-9a-f]{40}$")' release-manifest.json >/dev/null \
|
||||
|| { printf 'Release manifest source revision is invalid\n' >&2; exit 1; }
|
||||
jq -e \
|
||||
'.source.ref | strings | (length > 0 and length <= 128)' release-manifest.json >/dev/null \
|
||||
|| { printf 'Release manifest source ref is invalid\n' >&2; exit 1; }
|
||||
jq -e '(.source.dirty | type) == "boolean"' release-manifest.json >/dev/null \
|
||||
|| { printf 'Release manifest source dirty flag is invalid\n' >&2; exit 1; }
|
||||
SOURCE_REVISION=$(jq -er '.source.revision | ascii_downcase' release-manifest.json)
|
||||
SOURCE_REF=$(jq -er '.source.ref' release-manifest.json)
|
||||
SOURCE_DIRTY=$(jq -er '.source.dirty | tostring' release-manifest.json)
|
||||
if [[ "$SOURCE_REF" == v* ]] && [ "$SOURCE_REF" != "v$VERSION" ]; then
|
||||
printf 'Release manifest source tag does not match the version\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$EXPECTED_SOURCE_REVISION" ]; then
|
||||
NORMALIZED_EXPECTED_SOURCE_REVISION=$(printf '%s' "$EXPECTED_SOURCE_REVISION" | tr '[:upper:]' '[:lower:]')
|
||||
[[ "$NORMALIZED_EXPECTED_SOURCE_REVISION" =~ ^[0-9a-f]{40}$ ]] \
|
||||
|| { printf 'KAIDI_EXPECTED_SOURCE_REVISION must be a 40-character Git commit SHA\n' >&2; exit 1; }
|
||||
[ "$SOURCE_REVISION" = "$NORMALIZED_EXPECTED_SOURCE_REVISION" ] \
|
||||
|| { printf 'Release manifest source revision does not match the expected commit\n' >&2; exit 1; }
|
||||
fi
|
||||
if [ -n "$EXPECTED_SOURCE_REF" ] && [ "$SOURCE_REF" != "$EXPECTED_SOURCE_REF" ]; then
|
||||
printf 'Release manifest source ref does not match the expected ref\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$EXPECTED_SOURCE_DIRTY" ]; then
|
||||
[[ "$EXPECTED_SOURCE_DIRTY" = true || "$EXPECTED_SOURCE_DIRTY" = false ]] \
|
||||
|| { printf 'KAIDI_EXPECTED_SOURCE_DIRTY must be true or false\n' >&2; exit 1; }
|
||||
[ "$SOURCE_DIRTY" = "$EXPECTED_SOURCE_DIRTY" ] \
|
||||
|| { printf 'Release manifest source dirty flag does not match the expected state\n' >&2; exit 1; }
|
||||
fi
|
||||
jq -e --arg version "$VERSION" \
|
||||
'.buildVersions.backend == $version and .buildVersions.frontend == $version' \
|
||||
release-manifest.json >/dev/null \
|
||||
|| { printf 'Release build versions do not match the manifest version\n' >&2; exit 1; }
|
||||
|
||||
for ecosystem in maven npm; do
|
||||
file=$(jq -er --arg ecosystem "$ecosystem" \
|
||||
'.sboms[] | select(.ecosystem == $ecosystem) | .artifact' release-manifest.json)
|
||||
expected=$(jq -er --arg ecosystem "$ecosystem" \
|
||||
'.sboms[] | select(.ecosystem == $ecosystem) | .sha256' release-manifest.json)
|
||||
[ "$(sha256_file "$file")" = "$expected" ] \
|
||||
|| { printf '%s SBOM digest does not match the manifest\n' "$ecosystem" >&2; exit 1; }
|
||||
jq -e --arg version "$VERSION" \
|
||||
'. as $document | .metadata.component.version == $version
|
||||
and any(.dependencies[]; .ref == $document.metadata.component["bom-ref"])' \
|
||||
"$file" >/dev/null
|
||||
done
|
||||
|
||||
PUBLIC_KEY_SHA256=$(sha256_file release-public.pem)
|
||||
INSTALLER_SHA256=$(sha256_file install.sh)
|
||||
BOOTSTRAP_SHA256=$(sha256_file bootstrap-checksums.txt)
|
||||
SIGNED_INSTALLER=$(jq -er '.bootstrap.installer' release-manifest.json)
|
||||
SIGNED_PUBLIC_KEY=$(jq -er '.bootstrap.publicKey' release-manifest.json)
|
||||
SIGNED_BOOTSTRAP=$(jq -er '.bootstrap.checksums' release-manifest.json)
|
||||
[ "$SIGNED_INSTALLER" = install.sh ] && [ "$SIGNED_PUBLIC_KEY" = release-public.pem ] \
|
||||
&& [ "$SIGNED_BOOTSTRAP" = bootstrap-checksums.txt ] \
|
||||
|| { printf 'Release manifest bootstrap asset names are invalid\n' >&2; exit 1; }
|
||||
jq -e --arg installer "$INSTALLER_SHA256" --arg publicKey "$PUBLIC_KEY_SHA256" \
|
||||
--arg bootstrap "$BOOTSTRAP_SHA256" \
|
||||
'.bootstrap.installerSha256 == $installer
|
||||
and .bootstrap.publicKeySha256 == $publicKey
|
||||
and .bootstrap.checksumsSha256 == $bootstrap' release-manifest.json >/dev/null \
|
||||
|| { printf 'Bootstrap assets do not match the signed manifest\n' >&2; exit 1; }
|
||||
NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256=$(printf '%s' "$TRUSTED_PUBLIC_KEY_SHA256" | tr '[:upper:]' '[:lower:]')
|
||||
[[ "$NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256" =~ ^[0-9a-f]{64}$ ]] \
|
||||
|| { printf 'KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256 must be 64 hexadecimal characters\n' >&2; exit 1; }
|
||||
[ "$PUBLIC_KEY_SHA256" = "$NORMALIZED_TRUSTED_PUBLIC_KEY_SHA256" ] \
|
||||
|| { printf 'Release public key does not match the trusted fingerprint\n' >&2; exit 1; }
|
||||
[ "$(sed -n 's/^KAIDI_RELEASE_PUBLIC_KEY_SHA256=//p' bootstrap-checksums.txt)" = "$PUBLIC_KEY_SHA256" ] \
|
||||
|| { printf 'Bootstrap public-key fingerprint does not match\n' >&2; exit 1; }
|
||||
[ "$(sed -n 's/^KAIDI_INSTALLER_SHA256=//p' bootstrap-checksums.txt)" = "$INSTALLER_SHA256" ] \
|
||||
|| { printf 'Bootstrap installer digest does not match\n' >&2; exit 1; }
|
||||
|
||||
ARCHIVE_LIST=$(tar -tzf "$ARTIFACT")
|
||||
if grep -Eq '(^/|(^|/)\.\.(/|$))' <<< "$ARCHIVE_LIST"; then
|
||||
printf 'Release archive contains an unsafe path\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
if grep -Eq '(^|/)(signing-private|private-key|id_rsa)' <<< "$ARCHIVE_LIST"; then
|
||||
printf 'Release archive contains private key material\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
[ "$(tar -xOf "$ARTIFACT" ./VERSION)" = "$VERSION" ] \
|
||||
|| { printf 'Release archive version does not match the manifest\n' >&2; exit 1; }
|
||||
tar -xOf "$ARTIFACT" ./app.jar > "$WORK/app.jar"
|
||||
(cd "$WORK" && jar -xf app.jar \
|
||||
META-INF/MANIFEST.MF META-INF/maven/com.kaidi/finance-system/pom.properties)
|
||||
JAR_POM_VERSION=$(sed -n 's/^version=//p' \
|
||||
"$WORK/META-INF/maven/com.kaidi/finance-system/pom.properties")
|
||||
JAR_IMPLEMENTATION_VERSION=$(sed -n 's/^Implementation-Version: //p' \
|
||||
"$WORK/META-INF/MANIFEST.MF" | tr -d '\r')
|
||||
[ "$JAR_POM_VERSION" = "$VERSION" ] && [ "$JAR_IMPLEMENTATION_VERSION" = "$VERSION" ] \
|
||||
|| { printf 'Release JAR metadata versions do not match the manifest\n' >&2; exit 1; }
|
||||
|
||||
compare_archive_file() {
|
||||
archive_path=$1
|
||||
source_path=$2
|
||||
cmp <(tar -xOf "$ARTIFACT" "$archive_path") "$ROOT/$source_path"
|
||||
}
|
||||
|
||||
compare_archive_file ./ops/update.sh deploy/update.sh
|
||||
compare_archive_file ./ops/kaidi-update.path deploy/systemd/kaidi-update.path
|
||||
compare_archive_file ./ops/kaidi-update.service deploy/systemd/kaidi-update.service
|
||||
compare_archive_file ./ops/kaidi-finance.service deploy/systemd/kaidi-finance.service
|
||||
compare_archive_file ./ops/kaidi-finance.conf deploy/nginx/kaidi-finance.conf
|
||||
cmp install.sh "$ROOT/deploy/install.sh" \
|
||||
|| { printf 'Release installer does not match deploy/install.sh\n' >&2; exit 1; }
|
||||
|
||||
printf 'Release %s verified; installer SHA-256: %s\n' "$VERSION" "$INSTALLER_SHA256"
|
||||
Reference in New Issue
Block a user