Files
go-omni/scripts/verifyManagedRelease.py

259 lines
22 KiB
Python

#!/usr/bin/env python3
"""Accept a fixed managed-project release using a fresh remote module cache."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import signal
import tempfile
import time
from verifyErrorInheritanceRelease import run
from verifyManagedProject import serve, verify_contract_changes
ROOT = Path(__file__).resolve().parents[1]
MODULE = "git.yuntuo.hk/k2safe/go-omni"
def verify_shared_catalog(cli, temp, env, version):
workspace = temp / "workspace"
(workspace / "internal/apierrors").mkdir(parents=True)
(workspace / "contracts").mkdir()
(workspace / "go.mod").write_text(f"module example.com/releaseWorkspace\n\ngo 1.22.0\nrequire {MODULE} {version}\n")
provider = workspace / "internal/apierrors/catalog.go"
provider.write_text('package apierrors\nimport errors "' + MODULE + '/errors"\n'
'func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitions:map[string]errors.Definition{'
'"INVALID":{Code:"INVALID",Message:"invalid"},"MISSING":{Status:404,Code:"MISSING",Message:"missing"}}}}\n')
run(["go", "mod", "tidy"], workspace, env)
for name in ("api", "id"):
service = workspace / "services" / name
run([cli, "new", "--module", "example.com/" + name, service], workspace, env)
(service / "api/omni.imports.json").write_text(json.dumps({
"roots": {"common": {"path": "../../../contracts"}},
"errors": {"source": "@common/errors.json", "include": ["INVALID"],
"routes": {"GET /api/greet/:name": ["MISSING"]}}}))
manifest = workspace / "omni.workspace.json"
manifest.write_text(json.dumps({"errorCatalogs": [{"dir": ".", "package": "example.com/releaseWorkspace/internal/apierrors",
"function": "Catalog", "output": "contracts/errors.json"}],
"services": [{"dir": "services/api"}, {"dir": "services/id"}]}))
for command in ("generate", "format", "generate", "check"):
run([cli, "workspace", command, "--file", manifest], workspace, env)
for name in ("api", "id"):
service = workspace / "services" / name
assert not (service / "api/omni.errors.json").exists()
assert not (service / "internal/apierrors/errorsGen.go").exists()
doc = json.loads((service / "docs/openapi.json").read_text())
responses = doc["paths"]["/api/greet/{name}"]["get"]["responses"]
assert "404" in responses
run(["go", "test", "-mod=mod", "./..."], service, env)
snapshot = lambda: {str(p.relative_to(workspace)): hashlib.sha256(p.read_bytes()).hexdigest()
for p in workspace.rglob("*") if p.is_file()}
before = snapshot()
run([cli, "workspace", "check", "--file", manifest], workspace, env)
assert before == snapshot(), "workspace check changed project files"
provider.write_text(provider.read_text().replace('Message:"invalid"', 'Message:"new message"'))
before = snapshot()
try:
run([cli, "workspace", "check", "--file", manifest], workspace, env)
except RuntimeError as err:
assert "catalog drift" in str(err)
else:
raise AssertionError("stale catalog accepted")
assert before == snapshot(), "drift check changed project files"
run([cli, "workspace", "generate", "--file", manifest], workspace, env)
run([cli, "workspace", "check", "--file", manifest], workspace, env)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--report", type=Path, default=ROOT / "performance/managedProjectRemote.json")
args = parser.parse_args()
if not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version):
parser.error("a fixed release version is required")
env = {k: v for k, v in os.environ.items() if not k.startswith("GO_OMNI_")}
env.update(GOWORK="off", GOFLAGS="", GOPRIVATE="git.yuntuo.hk", GONOSUMDB="git.yuntuo.hk")
started = time.monotonic()
checks = ["remoteCLIInstall", "freshModuleCache", "fixedVersionNoReplace"]
with tempfile.TemporaryDirectory(prefix="goOmniManagedRelease-") as directory:
temp = Path(directory)
(temp / "bin").mkdir()
env.update(GOBIN=str(temp / "bin"), GOMODCACHE=str(temp / "modcache"))
print(f"Installing remote CLI {args.version} in an empty module cache", flush=True)
run(["go", "install", MODULE + "/cmd/go-omni@" + args.version], temp, env)
cli = temp / "bin/go-omni"
# Do not pass --framework-version: verify that the installed CLI pins
# its own downloaded version, without a source-directory replacement.
starter = temp / "starter"
run([cli, "new", "--module", "example.com/releaseStarter", starter], temp, env)
run(["go", "mod", "tidy"], starter, env)
dependency = json.loads(run(["go", "list", "-m", "-json", MODULE], starter, env))
assert dependency["Version"] == args.version and dependency.get("Sum") and not dependency.get("Replace")
verify_contract_changes(cli, starter, env)
checks += ["contractChangePreview", "persistentLogicSignatureFailure", "businessSourcePreserved", "fixedConsumerBuild"]
serve(["go", "run", "."], starter, env, "nethttp", signal.SIGTERM, False, logs=temp)
for transport in ("nethttp", "native"):
for sig in (signal.SIGINT, signal.SIGTERM):
serve([str(starter / "releaseStarter")], starter, env, transport, sig, True, logs=temp)
checks += ["rootGoRun", "nethttpAndNativeSignals", "listenerReleased"]
# Only project-owned business implementations come from the example.
# Main, handlers, DTOs and startup are emitted by the downloaded CLI.
managed = temp / "managed"
run([cli, "new", "--api", ROOT / "examples/contracts/managed.api", "--module", "example.com/managed", managed], temp, env)
example = ROOT / "examples/managedService"
sources = list((example / "internal/logic/demo").glob("*.go"))
sources += [example / "internal/svc/options.go", example / "internal/apierrors/catalog.go"]
for source in sources:
target = managed / source.relative_to(example)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, target)
run([cli, "api", "generate", "--check"], managed, env)
run(["go", "mod", "tidy"], managed, env)
run(["go", "build", "-o", temp / "managedServer", "."], managed, env)
managed_dependency = json.loads(run(["go", "list", "-m", "-json", MODULE], managed, env))
assert managed_dependency["Sum"] == dependency["Sum"] and not managed_dependency.get("Replace")
for transport in ("nethttp", "native"):
serve([str(temp / "managedServer")], managed, env, transport, signal.SIGTERM, True, contract=True, logs=temp)
checks += ["default404And405", "goOwnedErrorCatalog", "unwrappedDownloads", "rawCallback", "multipart400And413", "nethttpAndNativeTransfers"]
verify_shared_catalog(cli, temp, env, args.version)
checks += ["sharedGoCatalogExport", "documentOnlyErrorSelection", "workspaceCheckNoWrites", "staleCatalogRejected", "twoIndependentServiceBuilds"]
env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version)
tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer", "TestCopyxThreeEndpointConsumer", "TestGeneratedConfigurationConsumer", "TestGeneratedCORSConsumer", "TestI18nGeneratedConsumer"]
print("Running generated consumers against the remote framework, including lifecycle failure cleanup", flush=True)
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s", "./cmd/go-omni/apigen", "-run", "^(" + "|".join(tests) + ")$"], ROOT, env)
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e and "/" not in e["Test"]]
assert sorted(passed) == sorted(tests), output
evidence = "".join(e.get("Output", "") for e in events)
sums = re.findall(r"checksum (h1:\S+); no local replace", evidence)
assert len(sums) == len(tests) and all(s == dependency["Sum"] for s in sums), "remote consumer checksum mismatch"
checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup", "responseArrayNullableNull", "primitiveAndDynamicArrays", "strictRequestPolicies", "declarativeMultipartBinding", "backgroundStopDrainResourceOrder", "backgroundInitAndListenFailureCleanup", "backgroundTimeoutRetainsResourcesAndExits"]
checks += ["generatedIncompleteHTTPDrainRetainsDependenciesAndLogger"]
# Run tests from the downloaded module, not the framework checkout.
http_output = run(["go", "test", "-race", "-count=1", "-json", MODULE + "/httpc"], starter, env)
http_events = [json.loads(line) for line in http_output.splitlines() if line.startswith("{")]
http_passed = {e.get("Test") for e in http_events if e.get("Action") == "pass"}
assert {"TestProxyRouting", "TestJSONHeadersConcurrentSnapshot", "TestPoolLimitsAndIdleExpiry",
"TestErrorBodyPoolReuseAndDrainBound", "TestSlowKnownErrorBodyHasShortDrainBudget",
"TestJSONSuccessStatuses", "TestJSONSuccessStatusConcurrentOptions", "TestJSONRejectedSuccessBodyRecovery"} <= http_passed
example_output = run(["go", "run", MODULE + "/examples/httpClient"], starter, env)
assert "authenticated request succeeded" in example_output
checks += ["remoteHTTPClientRaceTests", "remoteHTTPProxyAndTimeouts", "remoteHTTPHeaderIsolation",
"remoteHTTPPoolAndDrainBudgets", "remoteHTTPExample"]
config_output = run(["go", "test", "-race", "-count=1", "-json", MODULE + "/config"], starter, env)
config_events = [json.loads(line) for line in config_output.splitlines() if line.startswith("{")]
config_passed = {e.get("Test") for e in config_events if e.get("Action") == "pass"}
assert {"TestExplicitReferencesAcrossFormats", "TestReferenceFallbackAndFixedValues",
"TestReferenceDiagnosticsAndScalarLimits", "TestRemovedEnvTagsAndFieldSuggestion",
"TestModuleFieldDiagnostics", "TestModuleDiagnosticsKeepReferenceAndCause",
"TestHTTPBodyAndCancellationSettings"} <= config_passed
run([cli, "api", "generate", "--check"], starter, env)
run(["go", "run", ".", "-config", "etc/config.yaml", "-check-config"], starter, env)
checks += ["remoteExplicitConfigurationRaceTests", "literalAndRequiredDefaultReferences",
"remoteCheckConfigBeforeDependencies", "removedImplicitEnvironmentRules"]
for package, expected in {
"copyx": {"TestCheckTypesAndEmptyCollections", "TestCheckRulesAndConverterBoundaries", "TestCopierCheckConcurrentAndOptionsIsolation"},
"logx": {"TestLoggerTimeZoneIsolation", "TestLoggerTimeZoneValidationAndDST", "TestLoggerTimeZoneConcurrentChildren"},
"cors": {"TestPolicyPreflightAndActual", "TestPolicyValidation", "TestPolicyCredentialsWildcardAndDuplicates", "TestPolicyPreservesHTTPAndSnapshot"},
"app": {"TestAppCORSRoutingAndErrors", "TestAppCORSConfigIsolation", "TestCORSTransports", "TestI18nRouteScopesEarlyFailuresAndReplay", "TestI18nExplicitProtocolsAndConcurrentApps", "TestI18nAdapterFailurePreservesBusiness",
"TestUploadPolicyAcrossTransports", "TestConfiguredBodyPolicyBeforeMiddleware", "TestBodyHardLimitConflictBeforeServing", "TestDefaultBodyLimitRegistrationPaths",
"TestShutdownWaitsForCancellationCleanup", "TestExternalServingEntriesRetainActiveResources", "TestRequestAdmissionAndCloseRace", "TestConfiguredShutdownDoesNotWaitForUncooperativeHandler"},
"lifecycle": {"TestFinishRetainsDependenciesAfterIncompleteHTTPDrain"},
"transport/http2": {"TestShutdownWaitsForHandlerAfterClientDisconnect"},
"transport/native": {"TestApplicationRejectsUnreadBodyPromptly", "TestBodyRejectionWithBusinessHTTP200DoesNotDrain"},
"contract": {"TestBodyPolicyPropagatesOuterLimit", "TestJSONClosingDelimiterPreservesReaderCause"},
"i18n": {"TestIndependentImmutableBundlesAndFallback", "TestResourceAndParameterDiagnostics", "TestStrictLoadingAndExplicitOverlay", "TestPinnedCLDRSamples", "TestBrowserConformance", "TestSourceLocaleCanBeFallbackOnly", "TestTypeScriptConsumer"},
"i18n/httpi18n": {"TestNegotiationAndExplicitResolvers"},
"i18n/errori18n": {"TestCatalogAdapterIsExplicitAndDetached"},
"database": {"TestInTxStagesAndCauses", "TestInTxCommitOutcomeUnknown", "TestInTxCancellationAndOwnership", "TestInTxCancellationRace", "TestInTxSuccessPanicAndConcurrency", "TestInTxPublicErrorCompatibility"},
}.items():
output = run(["go", "test", "-race", "-count=1", "-json", MODULE + "/" + package], starter, env)
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
passed = {event.get("Test") for event in events if event.get("Action") == "pass"}
assert expected <= passed
checks += ["remotePreciseModuleDiagnostics", "remoteJSONSuccessStatuses", "remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight", "remoteEmptyCollectionTypeRejection", "remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace", "remoteCORSErrorResponses", "remoteCORSTransports", "generatedCORSConfigOnly"]
run(["go", "build", "-o", temp / "httpSafety", MODULE + "/examples/httpSafety"], starter, env)
checks += ["remoteUnifiedBodyPolicyTransports", "remoteBodyLimitsBeforeMiddleware", "remoteHardLimitConflictBeforeServing",
"remoteCanceledHandlerCleanupBarrier", "remoteExternalServingResourceRetention", "remoteAdmissionCloseRace",
"remoteRuntimeFinishRetainsHTTPDependencies", "remoteHTTP2DisconnectedHandlerDrain",
"remoteNativeEarly413AndBusinessHTTP200", "remoteHTTPSafetyExampleBuild"]
transaction_output = run(["go", "run", MODULE + "/examples/transactions"], starter, env)
assert "commitAcknowledgementLost: stage=commit outcome=unknown stored=true cleanup=false" in transaction_output
assert "workFailure: stage=work outcome=rolledBack stored=false cleanup=true" in transaction_output
checks += ["remoteTransactionStages", "remoteTransactionCauseChains", "remoteCommitOutcomeUnknown",
"remoteCancellationOutcomeUnknown", "remoteTransactionPanicAndConcurrency",
"remoteTransactionPublicErrors", "remoteTransactionExample"]
downloaded = Path(dependency["Dir"])
example = downloaded / "examples/i18n"
resources = ["--config", example / "config.json", "--resource", example / "locales/ui.en.json",
"--resource", example / "locales/ui.zh-Hans.json"]
assert json.loads(run([cli, "i18n", "check", *resources], starter, env)) == []
pack = json.loads(run([cli, "i18n", "export", *resources, "--locale", "zh-Hans", "--namespace", "ui"], starter, env))
assert pack["locale"] == "zh-Hans" and pack["ruleVersion"] == "cldr-46.1"
assert "UNICODE LICENSE" in pack["license"]
assert {doc["namespace"] for doc in pack["documents"]} == {"ui"}
types = run([cli, "i18n", "types", *resources, "--namespace", "ui"], starter, env)
assert "export type Translate" in types and "bigint" in types
extracted = json.loads(run([cli, "i18n", "extract", "--go", example / "main.go"], starter, env))
assert any(ref["namespace"] == "ui" and ref["key"] == "greeting" for ref in extracted["references"])
catalog_path = temp / "catalog.json"
catalog_path.write_text(run(["go", "run", MODULE + "/examples/i18n", "-catalog"], starter, env))
source = json.loads(run([cli, "i18n", "extract", "--catalog", catalog_path, "--locale", "en", "--namespace", "errors"], starter, env))
assert source["messages"]["LIMIT_EXCEEDED"]["params"] == {"max": "integer"}
browser_output = run(["node", "--test", downloaded / "i18n/browser/runtime.test.mjs"], starter, env)
assert "# fail 0" in browser_output
checks += ["remoteI18nRuntimeRace", "remoteI18nGeneratedConsumer", "remoteI18nHTTPBusinessInvariants",
"remoteI18nSourceOnlyFallback", "remoteCLDRSamplesAndBrowserConformance", "remoteTypeScriptConsumer",
"remoteI18nCLIExtractionCheckExportTypes", "remoteI18nNamespaceIsolation",
"remoteBrowserRaceAndFailureRetention", "remoteJSONReaderCausePreservation"]
checks += ["flatFieldComposition", "copyxThreeEndpointConsumer", "explicitNullAndMoneyPolicies", "fieldAdditionWithoutCopyRuleChanges", "middlewareFactoryInjectionAndCleanup", "optionalRepositoryPreservesBusiness"]
checks += ["generatedUnifiedConfig", "jsonYamlXmlAndCustomDecoder", "configValidationBeforeDependencies", "configEnvProvenance", "configExtensionPreserved", "configLoaderDriftCheck"]
sources = ["lifecycle/runtime.go", "lifecycle/phases.go", "config/lifecycle_test.go", "task/task.go", "cmd/go-omni/scaffold/templates/api-runtime.go.tmpl", "cmd/go-omni/apigen/templates.go", "config/project.go", "contract/bind.go", "contract/policy.go", "contract/multipart.go", "contract/file.go", "cmd/go-omni/apigen/business_test.go", "cmd/go-omni/apigen/generate.go", "lifecycle/controller.go", "config/endpoints.go", "health/health.go", "httpx/response.go", "app/app.go", "app/transport.go", "scripts/verifyManagedRelease.py", "scripts/verifyManagedProject.py", "cmd/go-omni/apigen/managed_test.go"]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/managed").glob("*.go")]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/business").glob("*.go")]
sources += ["cmd/go-omni/apigen/responseShape.go", "cmd/go-omni/apigen/documentRefs.go", "cmd/go-omni/apigen/catalogDocument.go", "cmd/go-omni/apigen/workspaceCatalog.go", "config/references.go", "cmd/go-omni/apigen/arrayResponse_test.go"]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/arrays").glob("*.go")]
sources += ["cmd/go-omni/apigen/" + name for name in ("composition.go", "generatorConfig.go", "copyConsumer_test.go", "generatorConfig_test.go", "middlewareFactory.go")]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cmd/go-omni/apigen/testdata/composition").iterdir() if p.is_file()]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "copyx").glob("*.go")]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "config").glob("*.go")]
sources += ["cmd/go-omni/apigen/configLoader_test.go", "cmd/go-omni/apigen/testdata/configuration/consumer_test.go", "cmd/go-omni/scaffold/templates/api-load.go.tmpl", "cmd/go-omni/scaffold/templates/api-loader.go.tmpl"]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "httpc").glob("*.go")]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "examples/httpClient").glob("*.go")]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "logx").glob("*.go")]
sources += ["internal/configdiag/error.go", "database/mysql/mysql.go", "database/connector.go", "database/database.go", "contract/policy.go"]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "examples/developerTools").iterdir() if p.is_file()]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cors").glob("*.go")]
sources += ["app/cors_test.go", "cmd/go-omni/apigen/corsConsumer_test.go", "cmd/go-omni/apigen/testdata/cors/consumer_test.go", "examples/cors/main.go", "examples/cors/config.yaml", "scripts/corsBrowser/main.go"]
sources += ["database/transaction.go", "database/transaction_test.go", "database/transactionResponse_test.go", "database/mysqlConnector_test.go", "examples/transactions/main.go", "examples/transactions/demoDriver.go"]
sources += [str(p.relative_to(ROOT)) for directory in ("i18n", "examples/i18n") for p in (ROOT / directory).rglob("*") if p.is_file()]
sources += ["app/i18n_test.go", "errors/localization.go", "cmd/go-omni/i18n.go", "cmd/go-omni/apigen/i18nVersion.go", "cmd/go-omni/apigen/testdata/i18n/consumer_test.go", "contract/policy_test.go"]
sources += ["app/body.go", "app/router.go", "app/requests.go", "app/bodyPolicy_test.go", "app/requests_test.go", "app/transport_test.go",
"httpx/context.go", "internal/httplimits/limits.go", "lifecycle/runtime_test.go", "transport/http2/server.go", "transport/http2/lifecycle_test.go",
"transport/native/server.go", "transport/native/earlyReject_test.go", "examples/httpSafety/main.go"]
report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
"freshModuleCache": True, "noLocalReplace": True,
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks,
"sourceHashes": {p: hashlib.sha256((ROOT / p).read_bytes()).hexdigest() for p in sources}}
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
print(f"PASS remote managed project release {args.version}; temporary files and processes removed", flush=True)
if __name__ == "__main__":
main()