diff --git a/.claude/skills/verify-changes/SKILL.md b/.claude/skills/verify-changes/SKILL.md index a1a27be..0952eb2 100644 --- a/.claude/skills/verify-changes/SKILL.md +++ b/.claude/skills/verify-changes/SKILL.md @@ -86,6 +86,54 @@ source is the hack this repo's rules forbid. ## A skip is not a pass +### Actual native agent ownership + +Core v0.19.10 supplies the authenticated-v2 native writer and identity-bound +stop/recovery APIs. This agent consumes them through its existing native runner; +already-installed binaries retain the Core they were built with. + +After changing Core or the native lifecycle, build this checkout through +`codefly agent build --native-only` with a disposable OS `HOME` and +`CODEFLY_HOME`. Select the intended Core graph with `agent deps --pin` for a +published version; a source-adjacent `go.work` proves only that local source +graph and requires a packager that honours the CLI's prepared workfile. +Use an isolated source packager installation, never overwrite a shared agent. +Then qualify those exact installed bytes: + +```bash +SERVICE_NEXTJS_NATIVE_AGENT_HOME=/absolute/disposable/codefly-home \ +SERVICE_NEXTJS_QUALIFICATION_CLI=/absolute/reviewed/codefly \ + go test -run '^TestNativeAgentOwnershipLifecycle$' -count=1 -v . +``` + +This opt-in gate loads the installed agent through Core's authenticated manager +and drives real Next.js applications through gRPC Load/Init/Start/Stop/restart. +It checks that Start's RPC lifetime does not own the workload, ordinary Stop +removes the registered group, and the actual writer records authenticated-v2 +ownership. It kills only the exact disposable agent owner, recovers its orphan +through CLI preview/expect, and verifies an independently launched sibling's +process births remain unchanged. Core owns authentication, process inspection, +signalling and registry removal; this agent adds no recovery mechanism. +The test retains the manager's authenticated original launch identity before +workload actions, compares the current full identity before signaling, and binds +the later recovery receipt to that retained birth. Its stale-owner regression +uses a real disposable process and requires refusal without SIGKILL delivery. +That standalone fixture waits for Core's launch gate to exec the target while +retaining the original birth; missing/replaced ownership or cancellation fails +readiness, and the bounded wait cannot turn a timeout into qualification. + +The test isolates OS HOME (Core's registry) from the supplied installation home. +It needs npm, Node, network access for the fixture's pinned dependencies, and a +CLI implementing the reviewed recovery contract. It bypasses sandbox/principal +policy explicitly. It does not qualify Linux when run on macOS, Docker, Nix, +full CLI composition, or a published release. Record binary/SBOM/CLI hashes and +embedded Core metadata with the result; a local manifest version is not proof +of publication. Missing opt-in inputs produce a skip, never qualification. +The ownership proof covers the tracked npm process group. Next.js 16.4.1 can +briefly start a detached telemetry flush helper even with telemetry disabled; +that helper is outside this group contract. Fixture-home deletion retries handle +its filesystem race without claiming or signalling an unrelated process. + These exclude themselves and report success: | Test | Runs only when | diff --git a/go.mod b/go.mod index 303b576..eaeeffd 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.27.3 require ( github.com/Masterminds/semver v1.5.0 - github.com/codefly-dev/core v0.19.4 + github.com/codefly-dev/core v0.19.10 github.com/stretchr/testify v1.12.1 google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 @@ -19,8 +19,8 @@ require ( dario.cat/mergo v1.0.2 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProtonMail/go-crypto v1.4.1 // indirect + github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 // indirect + github.com/ProtonMail/go-crypto v1.5.2 // indirect github.com/VividCortex/ewma v1.2.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect @@ -32,6 +32,7 @@ require ( github.com/codefly-dev/gortk v0.2.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.2.0 // indirect github.com/cyphar/filepath-securejoin v0.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dekobon/tree-sitter-groovy v0.3.0 // indirect @@ -56,7 +57,7 @@ require ( github.com/go-openapi/jsonreference v1.0.2 // indirect github.com/go-openapi/loads v0.25.3 // indirect github.com/go-openapi/spec v1.0.1 // indirect - github.com/go-openapi/strfmt v0.27.2 // indirect + github.com/go-openapi/strfmt v0.27.3 // indirect github.com/go-openapi/swag v0.29.2 // indirect github.com/go-openapi/swag/cmdutils v0.29.2 // indirect github.com/go-openapi/swag/conv v0.29.2 // indirect @@ -85,10 +86,11 @@ require ( github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-pointer v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.28 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/patternmatcher v0.6.1 // indirect + github.com/moby/sys/sequential v0.7.0 // indirect github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/morikuni/aec v1.1.0 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect @@ -119,22 +121,23 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 // indirect go.opentelemetry.io/otel v1.47.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 // indirect go.opentelemetry.io/otel/log v1.47.0 // indirect go.opentelemetry.io/otel/metric v1.47.0 // indirect go.opentelemetry.io/otel/trace v1.47.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.57.0 // indirect - golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect - golang.org/x/mod v0.41.0 // indirect - golang.org/x/net v0.60.0 // indirect + golang.org/x/crypto v0.58.0 // indirect + golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e // indirect + golang.org/x/mod v0.42.0 // indirect + golang.org/x/net v0.61.0 // indirect golang.org/x/sys v0.49.0 // indirect - golang.org/x/term v0.46.0 // indirect - golang.org/x/text v0.42.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect + golang.org/x/term v0.47.0 // indirect + golang.org/x/text v0.43.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect diff --git a/go.sum b/go.sum index 5bc496e..8a0ab7f 100644 --- a/go.sum +++ b/go.sum @@ -15,10 +15,10 @@ github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF0 github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= -github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29 h1:0kQAzHq8vLs7Pptv+7TxjdETLf/nIqJpIB4oC6Ba4vY= +github.com/Microsoft/go-winio v0.6.3-0.20251027160822-ad3df93bed29/go.mod h1:ZWa7ssZJT30CCDGJ7fk/2SBTq9BIQrrVjrcss0UW2s0= +github.com/ProtonMail/go-crypto v1.5.2 h1:cucYnvqcY7UOXVD//mSyjeaPY0SSN3v5cDkYPxumINk= +github.com/ProtonMail/go-crypto v1.5.2/go.mod h1:/RaSu30DaKO4RY+XdV/ACcCcZkGr7AhUIduq5sjzzCo= github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow= github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -43,16 +43,16 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= -github.com/codefly-dev/core v0.19.4 h1:vUvaFrWkB8B3ksQY/qlgBTVj3jMNhdEpJca8aofaals= -github.com/codefly-dev/core v0.19.4/go.mod h1:0uX/7lTBVaGV0RI2Mb3mpEr/PPdx/jZqXHym/U8FVDI= +github.com/codefly-dev/core v0.19.10 h1:rTeh1vph0xGtpiCCt2Dl1AH2Rz9hzIYt1YGcbv6bLYE= +github.com/codefly-dev/core v0.19.10/go.mod h1:aTJY3Rw0OSVkbWRThgDzSkqieoF9mW4MiPRXM8Vzxww= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/log v0.2.0 h1:BewD/umNgVnoczglOpX8eRMyEy5t5iPlu5AIpnWDONc= +github.com/containerd/log v0.2.0/go.mod h1:/M7L7CXKcPTfNC74XzaK+5H5KbO5+4lJVpuVI6vRLoM= github.com/cyphar/filepath-securejoin v0.7.0 h1:s0Y3ITPy6sQn5xt54DuYvTF8hu134ooYLUb58DX/HjE= github.com/cyphar/filepath-securejoin v0.7.0/go.mod h1:ymLGms/u3BYaviIiuKFnUx8EkQEZeK6cInNoAPJA3o4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -113,8 +113,8 @@ github.com/go-openapi/loads v0.25.3 h1:V+jKy/thXWdLJUuYC8sZX2dICyAa8M3DokF70jj34 github.com/go-openapi/loads v0.25.3/go.mod h1:LgLyCSOLBL2Qnj0Ps1oo2YH8jZoEKsMeVZCc+ALIxFo= github.com/go-openapi/spec v1.0.1 h1:lj2vdGpNDcVgwRc6qXdw6qt/KQpCtSa9tnUH6vpDPDk= github.com/go-openapi/spec v1.0.1/go.mod h1:M//GWQGtDUAjnP37gE6fInLgaczB+FatoipV3H1fYw8= -github.com/go-openapi/strfmt v0.27.2 h1:SG32SlbwNy92s0KJiVxt2joJeFdqIYHvwrA0OU6HqzQ= -github.com/go-openapi/strfmt v0.27.2/go.mod h1:M4CKsMO0Fb8qR10+1Ra75wCKNNquy+Vj+4LWZrhTo2E= +github.com/go-openapi/strfmt v0.27.3 h1:cyf4J5Wjpd6bFpAs44MF8PsbWF/mWXhn82Ck/hgQZgw= +github.com/go-openapi/strfmt v0.27.3/go.mod h1:Bl+xadPLa3nhBiaCAxNND1vrQfzyEjF0vBrLx96n/4E= github.com/go-openapi/swag v0.29.2 h1:9n8frkcsuQRA0INU31VbxNxt1zBaHxrHYGEf5BUjFi4= github.com/go-openapi/swag v0.29.2/go.mod h1:RkCiX1gCVXWgLOavDXE3ALJrQ/VO2ziiIzOUs3z10xo= github.com/go-openapi/swag/cmdutils v0.29.2 h1:cXEzX/nWCODon251f+1HgL75/R4XXML5IZxgLCWMoro= @@ -164,8 +164,8 @@ github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfh github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0 h1:Bd7KaOxzULLxtZ/K5s1aLbWhR0+5RToO65TXHsf3bqQ= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.31.0/go.mod h1:nN7ts3dFXKtCZWc//yfkpcQNKJABg16/uDVAZpLDalo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0 h1:QMILzF8BA7xftE4zVORkRrlv08m12St3csK4RsyRgRs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.32.0/go.mod h1:+ZQ6iUL8+ATyxGDQNuRKpNwJFdGDUi0e7E1QL9MvI7U= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -192,8 +192,6 @@ github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc= github.com/mattn/go-runewidth v0.0.28 h1:rPyg2ybwEKPebvpzVWe1gKBkH8EQFkxO4Y0hjBeLaBU= github.com/mattn/go-runewidth v0.0.28/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8= -github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= -github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= @@ -241,8 +239,8 @@ github.com/shoenig/go-m1cpu v0.2.2/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+l github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk= github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -303,14 +301,14 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0 h1:Tq+E65HaNbZTeqcLw6QUhsV+/EIWdkfUH2CQzcrlE5M= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.72.0/go.mod h1:A+EVhDakAj1waJBPhmTszZaSr08ww8MRRKy1nB1rqck= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0 h1:3g7B90UzBltIDKq1/5mrTGxTnOFDV0ICOhLoxiZ8jlg= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.71.0/go.mod h1:Ef8SuTh59BT7+ofpDxN9z+yOlc4t2GjLmKDgYNJL/NU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0 h1:LxwW/9ctSCv+QkE/cLR7M91ZIkXNMqJtEMi1vCw9U8s= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0/go.mod h1:tOsftB4SslBwwErVEPaenU2RpThXWPIU8DoJHEC4dyw= go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0 h1:julhjPeUH/q/7hinbSdDdqt5h7Zw9YWmRlWRhI0jd54= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.47.0/go.mod h1:Ao2mz688LH/tFf0yMAenidq6k2YNSx6SIY2q6jDACck= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0 h1:QBajQ2SrwQijzHyZbQlPsuIzpl/ll8DY6wPWsajeGcI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.45.0/go.mod h1:08ZQLjrPLQ6R4kAXvuOvODEer5Yh4CoFvll5qB2BCI8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0 h1:aXZXsZ012wOgVkqaiQv+Z/d8V0xKjmg70F6m4CE94lc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.47.0/go.mod h1:ziu1gUIJhAaxM3EV1e3Ralk5AAyL1UOYz3lJJ4VSI38= go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= @@ -330,15 +328,15 @@ go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= -golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU= -golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= -golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= -golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= +golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e h1:HgI6W1OolKK6tcV74is+rl9SMT0njsHlp0TbQiGXI/A= +golang.org/x/exp v0.0.0-20261014143641-f0251eb4aa3e/go.mod h1:DBoxwWhjaW8DJJw/YeM4zpeOKMC1FMXSblsYy+1mFSw= +golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= +golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= -golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -352,20 +350,20 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= -golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= -google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941 h1:DS/w3N8rsfrwxwgGSj6WhMsPo7aNQGmDx74J81zd0NQ= +google.golang.org/genproto/googleapis/api v0.0.0-20261012220534-4341edf3f941/go.mod h1:AuGAdi9KnXss7xbLxIKhCvk+Ro/SxpET/iS45DNYN+M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 h1:0SXBqli5dnuCKMjI/X3FM226zEaEa0GUcSCJtN1vL2o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= diff --git a/native_ownership_test.go b/native_ownership_test.go new file mode 100644 index 0000000..c6aabf2 --- /dev/null +++ b/native_ownership_test.go @@ -0,0 +1,356 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/codefly-dev/core/agents/manager" + runtimev0 "github.com/codefly-dev/core/generated/go/codefly/services/runtime/v0" + "github.com/codefly-dev/core/network" + "github.com/codefly-dev/core/resources" + runners "github.com/codefly-dev/core/runners/base" + "github.com/stretchr/testify/require" +) + +// This opt-in gate consumes the actual `agent build --native-only` output. +// It never builds a substitute agent, touches the user's registry, or selects +// processes by name. Both services and their agent owners are disposable. +func TestNativeAgentOwnershipLifecycle(t *testing.T) { + agentHome := os.Getenv("SERVICE_NEXTJS_NATIVE_AGENT_HOME") + cli := os.Getenv("SERVICE_NEXTJS_QUALIFICATION_CLI") + if agentHome == "" && cli == "" { + t.Skip("set SERVICE_NEXTJS_NATIVE_AGENT_HOME and SERVICE_NEXTJS_QUALIFICATION_CLI to qualify built artifacts") + } + if !filepath.IsAbs(agentHome) || !filepath.IsAbs(cli) { + t.Fatal("qualification inputs must be absolute paths") + } + // Next's disabled telemetry still starts a short-lived detached flush + // helper at shutdown. It can race filesystem cleanup after the tracked + // npm group is dead. Retry deletion of our fixture home only; this is not + // permission to discover or signal processes outside that group. + home, err := os.MkdirTemp("", "nextjs-native-home-") + require.NoError(t, err) + t.Cleanup(func() { + require.Eventually(t, func() bool { return os.RemoveAll(home) == nil }, 5*time.Second, 25*time.Millisecond) + }) + t.Setenv("HOME", home) // Core's process registry is OS-home scoped. + t.Setenv(resources.CodeflyHomeEnv, agentHome) + t.Setenv("GOWORK", "off") + installed, err := agent.Path(t.Context()) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{installed, installed + ".cdx.json", cli} { + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + t.Logf("artifact %s sha256=%x", path, sha256.Sum256(body)) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) + defer cancel() + + a := startOwnedNativeService(t, ctx) + b := startOwnedNativeService(t, ctx) + require.NotEqual(t, a.conn.ProcessInfo().PID, b.conn.ProcessInfo().PID, "sibling must have an independent agent owner") + siblingBefore, err := b.group.InspectOwnership(ctx) + require.NoError(t, err) + assertNativeOwnership(t, ctx, a.group, a.workspace, true) + assertNativeOwnership(t, ctx, b.group, b.workspace, true) + + // Ordinary Runtime.Stop must retire A without affecting B's exact birth. + stopped, err := a.client.Stop(ctx, &runtimev0.StopRequest{}) + if err != nil || stopped.GetStatus().GetState() != runtimev0.StopStatus_SUCCESS { + t.Fatalf("ordinary stop: %v, %v", stopped, err) + } + waitNativeCondition(t, func() bool { return !a.group.Alive() }) + if _, err := runners.LookupProcessGroup(a.group.PGID()); !errors.Is(err, runners.ErrProcessGroupNotRegistered) { + t.Fatalf("ordinary stop retained the workload registration: %v", err) + } + assertNativeOwnership(t, ctx, b.group, b.workspace, true) + t.Log("ordinary Stop removed first workload; sibling remains authenticated and supervised") + + // Restart through the same Runtime, then kill only its disposable owner + // birth so no agent Stop/finalizer can mask recovery of an orphan workload. + a.group = startNativeWorkload(t, ctx, a.client, a.workspace, a.address) + if err := killNativeOwner(ctx, a.owner); err != nil { + t.Fatal(err) + } + waitNativeCondition(t, func() bool { + ownership, err := a.group.InspectOwnership(ctx) + return err == nil && ownership.Authenticated && !ownership.OwnerAlive + }) + assertNativeOwnership(t, ctx, a.group, a.workspace, false) + assertNativeOwnership(t, ctx, b.group, b.workspace, true) + + // Use the maintained CLI consumer and Core's strict receipt parser; do not + // invent a recovery selection from registry JSON or a list of descendants. + pgid := strconv.Itoa(a.group.PGID()) + preview, err := exec.CommandContext(ctx, cli, "recover", "process-group", pgid, "--preview").Output() + if err != nil { + t.Fatalf("preview: %v", err) + } + expected, err := runners.ParseProcessGroupRecoveryExpectation(preview) + if err != nil || expected.Namespace != "authenticated-v2" || expected.PGID != a.group.PGID() || expected.Owner.PID != a.owner.PID { + t.Fatalf("actual agent did not write the expected v2 registration: %+v, %v", expected, err) + } + // Recovery represents the recorded owner with PGID=0; compare its birth, + // not the full struct, to the launch identity retained before any workload. + require.Equal(t, a.owner.BootID, expected.Owner.BootID) + require.Equal(t, a.owner.StartID, expected.Owner.StartID) + t.Logf("orphan preview: %s", preview) + assertNativeOwnership(t, ctx, a.group, a.workspace, false) + receipt := filepath.Join(t.TempDir(), "recovery.json") + if err := os.WriteFile(receipt, preview, 0600); err != nil { + t.Fatal(err) + } + result, err := exec.CommandContext(ctx, cli, "recover", "process-group", pgid, "--expect", receipt).CombinedOutput() + if err != nil { + t.Fatalf("recover: %v\n%s", err, result) + } + if a.group.Alive() { + t.Fatal("recovery left the selected workload alive") + } + if _, err := runners.LookupProcessGroup(a.group.PGID()); !errors.Is(err, runners.ErrProcessGroupNotRegistered) { + t.Fatalf("recovery did not remove the exact registration: %v", err) + } + assertNativeOwnership(t, ctx, b.group, b.workspace, true) + siblingAfter, err := b.group.InspectOwnership(ctx) + require.NoError(t, err) + require.Equal(t, siblingBefore.Members, siblingAfter.Members, "sibling process births changed during recovery") + siblingOwner, err := runners.InspectProcess(b.owner.PID) + require.NoError(t, err) + require.Equal(t, b.owner, siblingOwner, "sibling's original agent birth changed") + t.Logf("recovery result: %s; sibling preserved", result) + stopped, err = b.client.Stop(ctx, &runtimev0.StopRequest{}) + if err != nil || stopped.GetStatus().GetState() != runtimev0.StopStatus_SUCCESS { + t.Fatalf("sibling ordinary stop: %v, %v", stopped, err) + } + waitNativeCondition(t, func() bool { return !b.group.Alive() }) + _, err = runners.LookupProcessGroup(b.group.PGID()) + require.ErrorIs(t, err, runners.ErrProcessGroupNotRegistered) +} + +type ownedNativeService struct { + workspace string + address string + conn *manager.AgentConn + owner runners.ProcessIdentity + client runtimev0.RuntimeClient + group *runners.TrackedProcessGroup +} + +func startOwnedNativeService(t *testing.T, ctx context.Context) *ownedNativeService { + t.Helper() + workspace, err := filepath.EvalSymlinks(t.TempDir()) + require.NoError(t, err) + identity, environment := testIdentity(t, workspace) + serviceDir := filepath.Join(workspace, identity.RelativeToWorkspace) + writeProductionTestFile(t, serviceDir, "service.codefly.yaml", "name: frontend\nversion: 0.0.0\nendpoints:\n - name: http\n api: http\n") + // The shipped application owns these exact framework pins. Exercise real + // Next.js and npm (including their child processes), not a stand-in runner. + manifest, err := os.ReadFile("base/code/package.json") + require.NoError(t, err) + var base struct { + Dependencies map[string]string `json:"dependencies"` + } + require.NoError(t, json.Unmarshal(manifest, &base)) + dependencies := map[string]string{} + for _, name := range []string{"next", "react", "react-dom"} { + dependencies[name] = base.Dependencies[name] + } + fixture, err := json.Marshal(map[string]any{ + "name": "native-ownership", "private": true, + "scripts": map[string]string{"dev": "next dev --hostname 127.0.0.1"}, + "dependencies": dependencies, + }) + require.NoError(t, err) + writeProductionTestFile(t, serviceDir, "code/package.json", string(fixture)) + writeProductionTestFile(t, serviceDir, "code/pages/index.js", "export default function Home() { return 'native ownership'; }\n") + writeProductionTestFile(t, serviceDir, "code/pages/api/pid.js", "export default function handler(req, res) { res.json({pid: process.pid}); }\n") + t.Setenv(runners.LaunchWorkspaceEnv, workspace) + conn, err := manager.Load(ctx, agent, manager.WithoutSandbox(), manager.WithoutPrincipal(), manager.WithWorkDir(workspace)) + require.NoError(t, err) + t.Cleanup(conn.Close) + // Retain the manager's authenticated launch record before Load/Init/Start. + // A later PID lookup alone cannot establish which birth we launched. + ownerGroup, err := runners.LookupProcessGroup(conn.ProcessInfo().PID) + require.NoError(t, err) + owner := ownerGroup.Leader() + require.Equal(t, conn.ProcessInfo().PID, owner.PID) + ownerObservation, err := ownerGroup.InspectOwnership(ctx) + require.NoError(t, err) + require.True(t, ownerObservation.Authenticated) + require.True(t, ownerObservation.OwnerAlive) + require.Equal(t, workspace, ownerObservation.LaunchWorkspace) + require.Contains(t, ownerObservation.Members, owner) + t.Logf("authenticated original agent launch %+v", owner) + client := runtimev0.NewRuntimeClient(conn.GRPCConn()) + t.Cleanup(func() { + cleanup, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, _ = client.Stop(cleanup, &runtimev0.StopRequest{}) + }) + environmentProto, err := environment.Proto() + require.NoError(t, err) + loaded, err := client.Load(ctx, &runtimev0.LoadRequest{Identity: identity, Environment: environmentProto, DisableCatch: true}) + if err != nil || loaded.GetStatus().GetState() != runtimev0.LoadStatus_READY { + t.Fatalf("load: %v, %v\n%s", loaded, err, conn.StderrTail()) + } + composition, err := resources.LoadWorkspaceFromDir(ctx, workspace) + require.NoError(t, err) + ports, err := network.NewRuntimeManager(ctx, nil) + require.NoError(t, err) + ports.WithTemporaryPorts() + mappings, err := ports.GenerateNetworkMappings(ctx, environment, composition, resources.ServiceIdentityFromProto(identity), loaded.GetEndpoints(), resources.NewRuntimeContextNative()) + require.NoError(t, err) + endpoint, err := resources.FindHTTPEndpoint(ctx, loaded.GetEndpoints()) + require.NoError(t, err) + instance, err := resources.FindNetworkInstanceInNetworkMappings(ctx, mappings, endpoint, resources.NewNativeNetworkAccess()) + require.NoError(t, err) + initialized, err := client.Init(ctx, &runtimev0.InitRequest{RuntimeContext: resources.NewRuntimeContextNative(), ProposedNetworkMappings: mappings}) + if err != nil || initialized.GetStatus().GetState() != runtimev0.InitStatus_READY { + t.Fatalf("init: %v, %v\n%s", initialized, err, conn.StderrTail()) + } + return &ownedNativeService{workspace: workspace, address: instance.Address, conn: conn, owner: owner, client: client, + group: startNativeWorkload(t, ctx, client, workspace, instance.Address)} +} + +// This fixture never execs or changes process groups. Require its complete +// retained launch identity before asking Core to bind delivery to that birth. +func killNativeOwner(ctx context.Context, original runners.ProcessIdentity) error { + current, err := runners.InspectProcess(original.PID) + if err != nil { + return err + } + if current != original { + return fmt.Errorf("original native owner changed: %w", runners.ErrProcessNotFound) + } + return runners.SignalProcess(ctx, current, syscall.SIGKILL) +} + +// Core publishes the original birth before releasing its shell launch gate; +// returning from StartTrackedProcessGroup does not mean the target has execed. +// Wait only for that retained birth's expected execution state, never adopt a +// newly observed identity. The manager-loaded agent already has its handshake. +func nativeOwnerReady(ctx context.Context, group *runners.TrackedProcessGroup, original runners.ProcessIdentity) (bool, error) { + observation, err := group.InspectOwnership(ctx) + if err != nil { + return false, err + } + if !observation.Authenticated || !observation.OwnerAlive { + return false, runners.ErrProcessNotFound + } + for _, member := range observation.Members { + if member.PID != original.PID { + continue + } + if member.PGID != original.PGID || member.BootID != original.BootID || member.StartID != original.StartID { + return false, runners.ErrProcessNotFound + } + return member == original, nil + } + return false, runners.ErrProcessNotFound +} + +func TestNativeOwnerSignalRejectsStaleBirth(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + cmd := exec.Command("sleep", "30") + group, err := runners.StartTrackedProcessGroup(cmd) + require.NoError(t, err) + t.Cleanup(func() { + cleanup, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = group.Terminate(cleanup) + _ = cmd.Wait() + _ = group.RemoveIfDeadContext(cleanup) + }) + original := group.Leader() + waitNativeCondition(t, func() bool { + ready, err := nativeOwnerReady(t.Context(), group, original) + require.NoError(t, err) + return ready + }) + stale := original + stale.StartID++ + ready, err := nativeOwnerReady(t.Context(), group, stale) + require.ErrorIs(t, err, runners.ErrProcessNotFound) + require.False(t, ready, "readiness must not adopt a replacement birth") + cancelled, cancel := context.WithCancel(t.Context()) + cancel() + ready, err = nativeOwnerReady(cancelled, group, original) + require.ErrorIs(t, err, context.Canceled) + require.False(t, ready, "cancelled readiness must not pass") + require.ErrorIs(t, killNativeOwner(t.Context(), stale), runners.ErrProcessNotFound) + current, err := runners.InspectProcess(original.PID) + require.NoError(t, err, "stale owner selection delivered SIGKILL") + require.Equal(t, original, current, "original process must survive stale-owner refusal unchanged") +} + +func startNativeWorkload(t *testing.T, ctx context.Context, client runtimev0.RuntimeClient, workspace, address string) *runners.TrackedProcessGroup { + t.Helper() + // Returning from (and cancelling) Start's RPC must not end the workload. + startCtx, cancel := context.WithCancel(ctx) + started, err := client.Start(startCtx, &runtimev0.StartRequest{}) + cancel() + if err != nil || started.GetStatus().GetState() != runtimev0.StartStatus_STARTED { + t.Fatalf("start: %v, %v", started, err) + } + httpClient := &http.Client{Timeout: 10 * time.Second} + response, err := httpClient.Get(strings.TrimRight(address, "/") + "/api/pid") + require.NoError(t, err) + defer response.Body.Close() + require.Equal(t, http.StatusOK, response.StatusCode) + var workload struct { + PID int `json:"pid"` + } + require.NoError(t, json.NewDecoder(response.Body).Decode(&workload)) + identity, err := runners.InspectProcess(workload.PID) + require.NoError(t, err) + group, err := runners.LookupProcessGroup(identity.PGID) + require.NoError(t, err) + t.Logf("registered leader %+v", group.Leader()) + assertNativeOwnership(t, ctx, group, workspace, true) + ownership, err := group.InspectOwnership(ctx) + require.NoError(t, err) + require.Contains(t, ownership.Members, identity) + t.Logf("Next.js HTTP process %+v in authenticated group %d", identity, group.PGID()) + t.Cleanup(func() { + cleanup, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = group.Terminate(cleanup) + _ = group.RemoveIfDeadContext(cleanup) + }) + return group +} + +func assertNativeOwnership(t *testing.T, ctx context.Context, group *runners.TrackedProcessGroup, workspace string, ownerAlive bool) { + t.Helper() + ownership, err := group.InspectOwnership(ctx) + if err != nil || !ownership.Authenticated || ownership.OwnerAlive != ownerAlive || ownership.LaunchWorkspace != workspace || len(ownership.Members) == 0 { + t.Fatalf("ownership for %d (registered leader %+v): %+v, %v", group.PGID(), group.Leader(), ownership, err) + } +} + +func waitNativeCondition(t *testing.T, check func() bool) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for !check() { + if time.Now().After(deadline) { + t.Fatal("native lifecycle postcondition did not arrive") + } + time.Sleep(20 * time.Millisecond) + } +}