diff --git a/builder.go b/builder.go index cf5b69e..2604ba0 100644 --- a/builder.go +++ b/builder.go @@ -9,7 +9,6 @@ import ( "github.com/codefly-dev/core/agents/communicate" "github.com/codefly-dev/core/agents/services" - "github.com/codefly-dev/core/agents/services/sbom" "github.com/codefly-dev/core/agents/services/upgrade" "github.com/codefly-dev/core/companions/proto" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" @@ -215,38 +214,10 @@ func (s *Builder) SBOM(ctx context.Context, req *builderv0.SBOMRequest) (*builde return s.imageSBOM(ctx, req.GetSubjects()) } -// imageSBOM inventories each subject, resolving where the image actually lives. -// -// Neither SBOMRequest nor ImageSubject carries a scanner source, and both -// sources are real for this agent: release paths push, so the registry -// resolves them, while `codefly ci build` builds with --load and leaves an -// image whose only identity is its local image ID. Assuming either one strands -// the other — a registry lookup of a --load image fails with "pull access -// denied" for an image sitting in the daemon, fully scannable. -// -// The registry is tried first so a stale local copy can never shadow what was -// actually published; the daemon answers only for a reference the registry -// cannot serve. When neither resolves, both causes are reported, because the -// registry failure on its own does not explain why the daemon had nothing -// either. Empty subjects are the caller's precondition failure, not a -// resolution failure, so they never reach a scanner. +// imageSBOM preserves each caller-supplied immutable image identity and source. +// Core refuses unpinned subjects and never substitutes another scanner source. func (s *Builder) imageSBOM(ctx context.Context, subjects []*builderv0.ImageSubject) (*builderv0.SBOMResponse, error) { - if len(subjects) == 0 { - return s.Base.Builder.SBOMImageSubjectsRequired() - } - registry, err := s.Base.Builder.SBOMImages(ctx, subjects, sbom.SourceRegistry) - if err != nil || registry.GetState().GetState() != builderv0.SBOMStatus_ERROR { - return registry, err - } - daemon, err := s.Base.Builder.SBOMImages(ctx, subjects, sbom.SourceDockerDaemon) - if err != nil { - return nil, err - } - if daemon.GetState().GetState() == builderv0.SBOMStatus_ERROR { - return s.Base.Builder.SBOMImageError(fmt.Errorf("registry: %s; docker daemon: %s", - registry.GetState().GetMessage(), daemon.GetState().GetMessage())) - } - return daemon, nil + return s.Base.Builder.SBOMImages(ctx, subjects) } // Upgrade bumps Python dependencies in requirements.txt (pip list @@ -269,6 +240,7 @@ func (s *Builder) Upgrade(ctx context.Context, req *builderv0.UpgradeRequest) (* // Parameters is the template parameter set for the k8s deployment. type Parameters struct { + Probes string // GRPCEnabled adds the gRPC containerPort and Service port to the rendered // manifests. GRPCPort is the fixed in-cluster port the grpc.aio listener // binds (the app defaults to it when CODEFLY_GRPC_PORT is unset). @@ -280,6 +252,21 @@ type Parameters struct { func (s *Builder) Deploy(ctx context.Context, req *builderv0.DeploymentRequest) (*builderv0.DeploymentResponse, error) { defer s.Wool.Catch() + // The current Core manifest profile requires all three intents. Require + // an authored startup/restart policy instead of inventing one from readiness. + plan := resources.PlanEndpointProbes(s.FastAPI.RestEndpoint) + if plan.Startup == nil || plan.Liveness == nil { + return nil, fmt.Errorf("the Kubernetes manifest profile requires explicit endpoint health.startup and health.liveness; readiness is not a restart policy") + } + probes, err := deploymentProbes(s.FastAPI.RestEndpoint) + if err != nil { + return nil, err + } + for _, endpoint := range s.Endpoints { + if endpoint.GetName() != s.FastAPI.RestEndpoint.GetName() && endpoint.GetHealth() != nil { + return nil, fmt.Errorf("declared health on additional endpoint %q requires a combined deployment probe; refusing to ignore it", endpoint.GetName()) + } + } return s.Base.Builder.DeployKustomize(ctx, req, services.KustomizeDeployment{ EnvironmentVariables: s.EnvironmentVariables, Templates: deploymentFS, @@ -288,6 +275,7 @@ func (s *Builder) Deploy(ctx context.Context, req *builderv0.DeploymentRequest) DependencyConfigurations: true, }, Parameters: Parameters{ + Probes: probes, GRPCEnabled: s.FastAPI.Settings.GRPCServer.Enabled, GRPCPort: int(standards.Port(standards.GRPC)), }, @@ -373,6 +361,14 @@ func (s *Builder) Create(ctx context.Context, _ *builderv0.CreateRequest) (*buil func (s *Builder) CreateEndpoints(ctx context.Context) error { openapiFile := s.Local("openapi/api.swagger.json") endpoint := s.Base.BaseEndpoint(standards.REST) + // New scaffolds explicitly declare the policy supported by their /version + // route. Existing authored services are loaded, never rewritten by Deploy. + endpoint.Health = &resources.Health{ + Readiness: &resources.Probe{Kind: resources.ProbeKindHTTP, Path: "/version", Statuses: []string{"200"}}, + Startup: &resources.Probe{Kind: resources.ProbeKindTransport, Period: "2s", FailureThreshold: 30}, + Liveness: &resources.Probe{Kind: resources.ProbeKindTransport}, + } + if s.FastAPI.Settings.PublicEndpoint { endpoint.Visibility = resources.VisibilityPublic } @@ -390,6 +386,10 @@ func (s *Builder) CreateEndpoints(ctx context.Context) error { if err != nil { return s.Wool.Wrapf(err, "cannot create openapi api") } + api.Health, err = endpoint.Health.Proto(api.GetApi()) + if err != nil { + return s.Wool.Wrapf(err, "cannot declare scaffold health") + } s.FastAPI.RestEndpoint = api s.Endpoints = []*basev0.Endpoint{s.FastAPI.RestEndpoint} diff --git a/deployment_test.go b/deployment_test.go index 0c39afc..a27d3df 100644 --- a/deployment_test.go +++ b/deployment_test.go @@ -10,7 +10,7 @@ import ( ) func TestDeploymentTemplates(t *testing.T) { - destination := agenttesting.AssertKustomizeTemplates(t, deploymentFS, Parameters{}) + destination := agenttesting.AssertKustomizeTemplates(t, deploymentFS, deploymentTestParameters(t, Parameters{})) rendered, err := os.ReadFile(filepath.Join(destination, "base", "deployment.yaml")) require.NoError(t, err) diff --git a/go.mod b/go.mod index af50da7..50ba58e 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,10 @@ module github.com/codefly-dev/service-python-fastapi go 1.27.0 require ( - github.com/codefly-dev/core v0.3.29 + github.com/codefly-dev/core v0.3.35 github.com/codefly-dev/service-python v0.0.62 github.com/stretchr/testify v1.12.1 - google.golang.org/grpc v1.83.1 + google.golang.org/grpc v1.83.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -119,22 +119,22 @@ require ( github.com/yuin/goldmark v1.8.6 // indirect 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.70.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect go.opentelemetry.io/otel v1.46.0 // indirect go.opentelemetry.io/otel/metric v1.46.0 // indirect go.opentelemetry.io/otel/trace v1.46.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.55.0 // indirect + golang.org/x/crypto v0.57.0 // indirect golang.org/x/exp v0.0.0-20260820142414-ca536658362e // indirect - golang.org/x/mod v0.40.0 // indirect - golang.org/x/net v0.58.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.59.0 // indirect golang.org/x/sys v0.48.0 // indirect - golang.org/x/term v0.45.0 // indirect - golang.org/x/text v0.41.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-20260819154853-08b0e4226688 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect google.golang.org/protobuf v1.36.12 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/go.sum b/go.sum index 42b2bd2..fbb94bd 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,8 @@ 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.3.29 h1:S4jgxOt5cnPX1zfv1xPheskOjfY2UR5gVQeb+AP1DFY= -github.com/codefly-dev/core v0.3.29/go.mod h1:guGVP8tyWEjpUBwdP588jXb/BwADsrzlhx7WtmZSn20= +github.com/codefly-dev/core v0.3.35 h1:yQxXzNKy4XJ+Odv94pxI1N1sa+wnkuYiICMlJmJe1sc= +github.com/codefly-dev/core v0.3.35/go.mod h1:f5LY9gRu5lpBQ0tNKujP0q6MF9K8fUnbYvItuHaUWlY= 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/codefly-dev/service-python v0.0.62 h1:quru4JzJi8v5nhiYTzXjl2SswXIasyeYq/iaM6TvN4k= @@ -307,8 +307,8 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= 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.70.0 h1:oECp5f+hN7nkwjU/8BxQ/q23bGPb8FIrD839owX222E= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0/go.mod h1:DqEFwLumhzMBDQv9PcWbyoDxHI/4lAk6CM4nJBH39sc= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0 h1:B2h3uqicet1CT2N5TOFhS+Gq++9i0/CLmaxvhmhtP5s= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.71.0/go.mod h1:dylvB+ZiiwMvsDij9O84Uy7SijLgHMX4mbkncds+4Sw= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= @@ -321,8 +321,8 @@ go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6 go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= -go.opentelemetry.io/otel/sdk/metric v1.45.0 h1:oVFszMfyj1Am6s24Vtc7wBb8BKLcwepJjNEYILuiE3o= -go.opentelemetry.io/otel/sdk/metric v1.45.0/go.mod h1:vUWUxDZvu1WVRj8JA8S0AdhsPrZoDpA2DdZauIh4mDA= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= @@ -334,15 +334,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.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +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-20260820142414-ca536658362e h1:01Ju2A/fZKkci4zqx0eZxw//DnRYOnBiGJG14hFBhO8= golang.org/x/exp v0.0.0-20260820142414-ca536658362e/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM= -golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= -golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= +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/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= 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= @@ -355,11 +355,11 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +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/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -367,10 +367,10 @@ 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-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= -google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= -google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/grpc_test.go b/grpc_test.go index 3aa92ab..042f9cb 100644 --- a/grpc_test.go +++ b/grpc_test.go @@ -193,7 +193,7 @@ func TestGRPCEndpointReloadsContract(t *testing.T) { // TestDeploymentRendersGRPCPort proves the gRPC container/service ports appear // only when the deployment parameters opt in. func TestDeploymentRendersGRPCPort(t *testing.T) { - enabled := agenttesting.AssertKustomizeTemplates(t, deploymentFS, Parameters{GRPCEnabled: true, GRPCPort: 9090}) + enabled := agenttesting.AssertKustomizeTemplates(t, deploymentFS, deploymentTestParameters(t, Parameters{GRPCEnabled: true, GRPCPort: 9090})) svc, err := os.ReadFile(filepath.Join(enabled, "base", "service.yaml")) require.NoError(t, err) require.Contains(t, string(svc), "grpc-port") @@ -203,7 +203,7 @@ func TestDeploymentRendersGRPCPort(t *testing.T) { require.NoError(t, err) require.Contains(t, string(deployment), "containerPort: 9090") - disabled := agenttesting.AssertKustomizeTemplates(t, deploymentFS, Parameters{}) + disabled := agenttesting.AssertKustomizeTemplates(t, deploymentFS, deploymentTestParameters(t, Parameters{})) svc, err = os.ReadFile(filepath.Join(disabled, "base", "service.yaml")) require.NoError(t, err) require.NotContains(t, string(svc), "grpc-port") diff --git a/image_sbom_test.go b/image_sbom_test.go index 10ed674..b465397 100644 --- a/image_sbom_test.go +++ b/image_sbom_test.go @@ -91,7 +91,7 @@ func TestImageSBOMExpectationsMatchTheEmittedRecipe(t *testing.T) { builder, _ := createdBuilder(t) plan, _ := emitRecipePlan(t, builder) - expected := sbom.ExpectedFromBuildPlan(builder.Identity.Name, plan) + expected := recipeSubjectFixture(t, builder.Identity.Name, plan) require.Len(t, expected, 2) var platforms []string @@ -110,12 +110,12 @@ func TestImageSBOMExpectationsMatchTheEmittedRecipe(t *testing.T) { func TestImageSBOMCoverageRejectsFalseClaims(t *testing.T) { builder, _ := createdBuilder(t) plan, _ := emitRecipePlan(t, builder) - expected := sbom.ExpectedFromBuildPlan(builder.Identity.Name, plan) + expected := recipeSubjectFixture(t, builder.Identity.Name, plan) t.Run("no-image reason contradicting the declared build", func(t *testing.T) { resp, err := builder.Base.Builder.SBOMNoImage(builderv0.NoImageReason_NO_IMAGE_REASON_EXTERNALLY_MANAGED, "vendor image") require.NoError(t, err) - require.Error(t, sbom.ValidateCoverage(expected, resp)) + require.Error(t, sbom.ValidateCoverage(builder.Identity.Name, expected, resp)) }) t.Run("source inventory is not image coverage", func(t *testing.T) { @@ -123,13 +123,13 @@ func TestImageSBOMCoverageRejectsFalseClaims(t *testing.T) { State: &builderv0.SBOMStatus{State: builderv0.SBOMStatus_COMPLETE}, Scope: builderv0.SBOMScope_SBOM_SCOPE_SOURCE, } - require.Error(t, sbom.ValidateCoverage(expected, resp)) + require.Error(t, sbom.ValidateCoverage(builder.Identity.Name, expected, resp)) }) t.Run("precondition failure never reads as coverage", func(t *testing.T) { resp, err := builder.SBOM(t.Context(), imageSBOMRequest()) require.NoError(t, err) - require.Error(t, sbom.ValidateCoverage(expected, resp)) + require.Error(t, sbom.ValidateCoverage(builder.Identity.Name, expected, resp)) }) t.Run("one platform does not cover a multi-architecture image", func(t *testing.T) { @@ -142,7 +142,7 @@ func TestImageSBOMCoverageRejectsFalseClaims(t *testing.T) { Sha256: strings.Repeat("b", 64), }}) require.NoError(t, err) - require.Error(t, sbom.ValidateCoverage(expected, resp)) + require.Error(t, sbom.ValidateCoverage(builder.Identity.Name, expected, resp)) }) } @@ -168,7 +168,7 @@ func TestImageSBOMPropagatesScanFailures(t *testing.T) { require.Equal(t, builderv0.SBOMStatus_ERROR, resp.GetState().GetState()) require.Equal(t, builderv0.SBOMScope_SBOM_SCOPE_IMAGE, resp.GetScope()) require.Empty(t, resp.GetImages()) - require.Error(t, sbom.ValidateCoverage([]*builderv0.ImageSubject{subject}, resp)) + require.Error(t, sbom.ValidateCoverage(builder.Identity.Name, []*builderv0.ImageSubject{subject}, resp)) } // TestImageSBOMInventoriesTheBuiltImage is the end-to-end check: it builds the @@ -221,7 +221,12 @@ func TestImageSBOMInventoriesTheBuiltImage(t *testing.T) { // core's helper. Scope routing and scanner-source resolution are what this // repository owns, and a test calling the helper directly passed with // either of them broken. + inspect := exec.CommandContext(ctx, "docker", "image", "inspect", tag, "--format", "{{.Id}}") + rawID, err := inspect.Output() + require.NoError(t, err) subject := &builderv0.ImageSubject{ + Digest: strings.TrimSpace(string(rawID)), + Source: builderv0.ImageSourceKind_IMAGE_SOURCE_KIND_DOCKER_DAEMON, Reference: tag, Role: plan.GetRecipes()[0].GetName(), Service: builder.Identity.Name, @@ -264,5 +269,22 @@ func TestImageSBOMInventoriesTheBuiltImage(t *testing.T) { require.True(t, names[name], "image inventory is missing application dependency %s", name) } - require.NoError(t, sbom.ValidateCoverage([]*builderv0.ImageSubject{subject}, resp)) + require.NoError(t, sbom.ValidateCoverage(builder.Identity.Name, []*builderv0.ImageSubject{subject}, resp)) +} + +// Synthetic resolved identities exercise the pure coverage contract. The real +// build/scan test above independently uses the image ID observed from Docker. +func recipeSubjectFixture(t *testing.T, service string, plan *builderv0.DockerBuildPlan) []*builderv0.ImageSubject { + t.Helper() + _, err := sbom.ExpectedFromBuildPlan(service, plan, nil) + require.Error(t, err, "unbuilt recipe tags must not count as image evidence") + var resolved []sbom.ResolvedImage + for _, recipe := range plan.GetRecipes() { + for _, platform := range recipe.GetPlatforms() { + resolved = append(resolved, sbom.ResolvedImage{Recipe: recipe.GetName(), Platform: platform, Digest: "sha256:" + strings.Repeat("a", 64), Source: sbom.SourceRegistry}) + } + } + subjects, err := sbom.ExpectedFromBuildPlan(service, plan, resolved) + require.NoError(t, err) + return subjects } diff --git a/probe.py b/probe.py new file mode 100644 index 0000000..1122a82 --- /dev/null +++ b/probe.py @@ -0,0 +1,73 @@ +"""Container-local probe. No application imports, credentials, redirects or retries.""" +import http.client +import json +import os +import socket +import signal +import ssl +import sys +import time + + +def check(config): + deadline = time.monotonic() + config["timeout"] + connection = socket.create_connection(("127.0.0.1", config["port"]), config["timeout"]) + try: + if config["secured"]: + # This is a probe of this container's own listener. Authenticate it + # against the exact mounted serving certificate before any HTTP + # bytes are sent; a CA/hostname chosen for external callers is not + # a loopback identity. Never accept an arbitrary TLS certificate. + with open(os.environ["UVICORN_SSL_CERTFILE"], encoding="ascii") as source: + pem = source.read(1048576) + end = "-----END CERTIFICATE-----" + leaf = pem[:pem.index(end) + len(end)] + expected = ssl.PEM_cert_to_DER_cert(leaf) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + connection.settimeout(max(0.001, deadline - time.monotonic())) + connection = context.wrap_socket(connection, server_hostname=None) + if connection.getpeercert(binary_form=True) != expected: + raise ValueError("serving certificate does not match mounted certificate") + if config["kind"] == "transport": + return + connection.settimeout(max(0.001, deadline - time.monotonic())) + request = "GET " + config["path"] + " HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" + connection.sendall(request.encode("ascii")) + response = http.client.HTTPResponse(connection) + response.begin() + if not any(low <= response.status <= high for low, high in config["statuses"]): + raise ValueError("status predicate failed") + if config["body"]: + data = bytearray() + expected = config["body"].encode("utf-8") + while len(data) <= 1048576: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("probe deadline") + connection.settimeout(remaining) + chunk = response.read1(min(65536, 1048577 - len(data))) + if not chunk: + break + data.extend(chunk) + if expected in data: + return + raise ValueError("body predicate failed or exceeded probe bound") + finally: + connection.close() + + +if __name__ == "__main__": + try: + config = json.loads(sys.argv[1]) + def deadline_expired(_signum, _frame): + raise TimeoutError("probe deadline") + signal.signal(signal.SIGALRM, deadline_expired) + signal.setitimer(signal.ITIMER_REAL, config["timeout"]) + check(config) + signal.setitimer(signal.ITIMER_REAL, 0) + except Exception: + # Paths, response contents and TLS material are deliberately not logged. + sys.exit(1) diff --git a/probes.go b/probes.go new file mode 100644 index 0000000..4157604 --- /dev/null +++ b/probes.go @@ -0,0 +1,120 @@ +package main + +import ( + _ "embed" + "encoding/json" + "fmt" + "strings" + "time" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/resources" + "gopkg.in/yaml.v3" +) + +// probeScript is self-contained and executes with Python's isolated mode. +// +//go:embed probe.py +var probeScript string + +type localProbe struct { + Kind string `json:"kind"` + Port int `json:"port"` + Secured bool `json:"secured"` + Timeout int64 `json:"timeout"` + Path string `json:"path"` + Statuses [][2]uint32 `json:"statuses"` + Body string `json:"body"` +} + +// deploymentProbes preserves the producing endpoint's three separate intents. +// Kubernetes has only one probe per intent; predicates for additional listeners +// need an explicit combined implementation rather than being silently ignored. +func deploymentProbes(endpoint *basev0.Endpoint) (string, error) { + if endpoint == nil { + return "", fmt.Errorf("HTTP endpoint is required for deployment probes") + } + if err := resources.ValidateEndpointHealth(endpoint); err != nil { + return "", err + } + plan := resources.PlanEndpointProbes(endpoint) + rendered := make(map[string]any) + for _, intent := range []struct { + name string + probe *basev0.Probe + }{ + {"readinessProbe", plan.Readiness}, {"startupProbe", plan.Startup}, {"livenessProbe", plan.Liveness}, + } { + if intent.probe == nil { + continue + } + value, err := kubernetesProbe(endpoint, intent.probe, intent.name) + if err != nil { + return "", fmt.Errorf("%s: %w", intent.name, err) + } + rendered[intent.name] = value + } + raw, err := yaml.Marshal(rendered) + if err != nil { + return "", err + } + return " " + strings.ReplaceAll(strings.TrimSuffix(string(raw), "\n"), "\n", "\n "), nil +} + +func kubernetesProbe(endpoint *basev0.Endpoint, probe *basev0.Probe, intent string) (map[string]any, error) { + config := localProbe{Port: 8080, Secured: resources.EndpointSecured(endpoint), Timeout: 1} + switch predicate := probe.GetPredicate().(type) { + case *basev0.Probe_Transport: + config.Kind = "transport" + case *basev0.Probe_Http: + config.Kind, config.Path, config.Body = "http", predicate.Http.GetPath(), predicate.Http.GetBodyContains() + // HTTP request lines cannot contain controls, spaces or non-ASCII bytes. + for _, c := range config.Path { + if c <= 32 || c >= 127 { + return nil, fmt.Errorf("HTTP path is not a bounded ASCII request target") + } + } + for _, status := range resources.AcceptedHTTPStatuses(predicate.Http) { + config.Statuses = append(config.Statuses, [2]uint32{status.Min, status.Max}) + } + default: + return nil, fmt.Errorf("predicate %s cannot be represented by this HTTP listener's deployment probe", resources.ProbeKindOf(probe)) + } + result := map[string]any{"failureThreshold": 1, "successThreshold": 1} + // Kubernetes accepts whole seconds only. Do not round an authored duration. + for _, item := range []struct { + name string + duration time.Duration + }{ + {"initialDelaySeconds", probe.GetTiming().GetInitialDelay().AsDuration()}, + {"periodSeconds", probe.GetTiming().GetPeriod().AsDuration()}, + {"timeoutSeconds", probe.GetTiming().GetTimeout().AsDuration()}, + } { + if item.duration == 0 { + continue + } + if item.duration%time.Second != 0 { + return nil, fmt.Errorf("%s must be whole seconds for Kubernetes", item.name) + } + seconds := int64(item.duration / time.Second) + result[item.name] = seconds + if item.name == "timeoutSeconds" { + config.Timeout = seconds + } + } + if n := probe.GetTiming().GetFailureThreshold(); n > 0 { + result["failureThreshold"] = n + } + if n := probe.GetTiming().GetSuccessThreshold(); n > 0 { + if intent != "readinessProbe" && n != 1 { + return nil, fmt.Errorf("Kubernetes requires startup/liveness success-threshold 1") + } + result["successThreshold"] = n + } + raw, err := json.Marshal(config) + if err != nil { + return nil, err + } + result["exec"] = map[string]any{"command": []string{"python", "-I", "-c", probeScript, string(raw)}} + return result, nil +} diff --git a/probes_test.go b/probes_test.go new file mode 100644 index 0000000..b6d2a7e --- /dev/null +++ b/probes_test.go @@ -0,0 +1,193 @@ +package main + +import ( + "encoding/json" + "encoding/pem" + builderv0 "github.com/codefly-dev/core/generated/go/codefly/services/builder/v0" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "sync/atomic" + "testing" + "time" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/resources" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func healthEndpoint(t *testing.T, secured bool, health *resources.Health) *basev0.Endpoint { + t.Helper() + endpoint, err := (&resources.Endpoint{Name: "http", Service: "worker", Module: "example", Visibility: "private", API: "rest", Secured: secured, Health: health}).Proto() + require.NoError(t, err) + return endpoint +} + +func renderedProbe(t *testing.T, endpoint *basev0.Endpoint) map[string]any { + t.Helper() + probes, err := deploymentProbes(endpoint) + require.NoError(t, err) + var result map[string]any + require.NoError(t, yaml.Unmarshal([]byte(probes), &result)) + return result +} + +func TestDeploymentUsesDeclaredHTTPHealthAndIndependentIntents(t *testing.T) { + endpoint := healthEndpoint(t, true, &resources.Health{Readiness: &resources.Probe{ + Kind: "http", Path: "/readyz", Statuses: []string{"200"}, BodyContains: "ready", + InitialDelay: "2s", Period: "3s", Timeout: "4s", FailureThreshold: 5, SuccessThreshold: 2, + }}) + container := renderedProbe(t, endpoint) + require.NotContains(t, container, "startupProbe") + require.NotContains(t, container, "livenessProbe") + probe := container["readinessProbe"].(map[string]any) + require.EqualValues(t, 2, probe["initialDelaySeconds"]) + require.EqualValues(t, 3, probe["periodSeconds"]) + require.EqualValues(t, 4, probe["timeoutSeconds"]) + require.EqualValues(t, 5, probe["failureThreshold"]) + require.EqualValues(t, 2, probe["successThreshold"]) + command := probe["exec"].(map[string]any)["command"].([]any) + var config localProbe + require.NoError(t, json.Unmarshal([]byte(command[4].(string)), &config)) + require.True(t, config.Secured) + require.Equal(t, "/readyz", config.Path) + require.Equal(t, [][2]uint32{{200, 200}}, config.Statuses) + require.Equal(t, "ready", config.Body) +} + +func TestDeploymentLegacyReadinessOnlyConnects(t *testing.T) { + container := renderedProbe(t, healthEndpoint(t, false, nil)) + require.NotContains(t, container, "startupProbe") + require.NotContains(t, container, "livenessProbe") + command := container["readinessProbe"].(map[string]any)["exec"].(map[string]any)["command"].([]any) + var config localProbe + require.NoError(t, json.Unmarshal([]byte(command[4].(string)), &config)) + require.Equal(t, "transport", config.Kind) + require.Empty(t, config.Path) +} + +func TestDeploymentRefusesUnrepresentableTimingAndPredicates(t *testing.T) { + for _, probe := range []*resources.Probe{ + {Kind: "http", Path: "/readyz", Period: "1500ms"}, + {Kind: "agent"}, + } { + _, err := deploymentProbes(healthEndpoint(t, false, &resources.Health{Readiness: probe})) + require.Error(t, err) + } + _, err := deploymentProbes(healthEndpoint(t, false, &resources.Health{Liveness: &resources.Probe{Kind: "transport", SuccessThreshold: 2}})) + require.Error(t, err) +} + +func runLocalProbe(t *testing.T, config localProbe, target string, certFile string) error { + t.Helper() + endpoint, err := url.Parse(target) + require.NoError(t, err) + config.Port, err = strconv.Atoi(endpoint.Port()) + require.NoError(t, err) + raw, err := json.Marshal(config) + require.NoError(t, err) + python, err := exec.LookPath("python3") + require.NoError(t, err, "probe conformance requires Python") + command := exec.Command(python, "-I", "-c", probeScript, string(raw)) + command.Env = append(os.Environ(), "UVICORN_SSL_CERTFILE="+certFile) + return command.Run() +} + +func TestActualProbePreservesHTTPPredicateAndDoesNotFollowRedirects(t *testing.T) { + var status atomic.Int32 + status.Store(200) + var redirects atomic.Int32 + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { redirects.Add(1); w.WriteHeader(200) })) + defer target.Close() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/readyz", r.URL.Path) + w.Header().Set("Location", target.URL) + w.WriteHeader(int(status.Load())) + _, _ = w.Write([]byte("ready")) + })) + defer server.Close() + config := localProbe{Kind: "http", Path: "/readyz", Statuses: [][2]uint32{{200, 200}}, Body: "ready", Timeout: 1} + require.NoError(t, runLocalProbe(t, config, server.URL, "")) + for _, code := range []int{201, 302, 503} { + status.Store(int32(code)) + require.Error(t, runLocalProbe(t, config, server.URL, "")) + } + require.Zero(t, redirects.Load()) + status.Store(200) + config.Body = "missing" + require.Error(t, runLocalProbe(t, config, server.URL, "")) +} + +func TestActualTLSProbePinsTheMountedServingCertificate(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1); w.WriteHeader(200) })) + defer server.Close() + certFile := filepath.Join(t.TempDir(), "serving.crt") + require.NoError(t, os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600)) + config := localProbe{Kind: "http", Secured: true, Path: "/readyz", Statuses: [][2]uint32{{200, 200}}, Timeout: 1} + require.NoError(t, runLocalProbe(t, config, server.URL, certFile)) + require.EqualValues(t, 1, requests.Load()) + require.Error(t, runLocalProbe(t, config, server.URL, "/missing/certificate")) + different := append([]byte(nil), server.Certificate().Raw...) + different[len(different)-1] ^= 1 + require.NoError(t, os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: different}), 0600)) + require.Error(t, runLocalProbe(t, config, server.URL, certFile)) + require.EqualValues(t, 1, requests.Load()) + + // A malformed or mismatched mounted certificate cannot send application data. + require.NoError(t, os.WriteFile(certFile, []byte("not a certificate"), 0600)) + require.Error(t, runLocalProbe(t, config, server.URL, certFile)) + require.EqualValues(t, 1, requests.Load()) +} + +func TestActualProbeHasBoundedResponseTime(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { time.Sleep(2 * time.Second) })) + defer server.Close() + start := time.Now() + require.Error(t, runLocalProbe(t, localProbe{Kind: "http", Path: "/readyz", Statuses: [][2]uint32{{200, 200}}, Timeout: 1}, server.URL, "")) + require.Less(t, time.Since(start), 1900*time.Millisecond) +} + +func deploymentTestParameters(t *testing.T, parameters Parameters) Parameters { + t.Helper() + health := &resources.Health{ + Readiness: &resources.Probe{Kind: "http", Path: "/version", Statuses: []string{"200"}}, + Startup: &resources.Probe{Kind: "transport", FailureThreshold: 30, Period: "2s"}, + Liveness: &resources.Probe{Kind: "transport"}, + } + var err error + parameters.Probes, err = deploymentProbes(healthEndpoint(t, false, health)) + require.NoError(t, err) + return parameters +} + +func TestBuilderLoadsAuthoredTLSAndHealthBeforeProjection(t *testing.T) { + original, _ := createdBuilder(t) + service := *original.Base.Service + service.Endpoints = []*resources.Endpoint{{ + Name: "http", API: "rest", Secured: true, Visibility: "private", + Health: &resources.Health{ + Readiness: &resources.Probe{Kind: "http", Path: "/readyz", Statuses: []string{"200"}}, + Startup: &resources.Probe{Kind: "transport"}, + Liveness: &resources.Probe{Kind: "transport"}, + }, + }} + require.NoError(t, service.SaveAtDir(t.Context(), original.Location)) + identity, err := original.Identity.Proto() + require.NoError(t, err) + loaded := NewBuilder(NewService()) + _, err = loaded.Load(t.Context(), &builderv0.LoadRequest{Identity: identity}) + require.NoError(t, err) + endpoint := loaded.FastAPI.RestEndpoint + require.True(t, resources.EndpointSecured(endpoint)) + require.Equal(t, "/readyz", endpoint.GetHealth().GetReadiness().GetHttp().GetPath()) + probes, err := deploymentProbes(endpoint) + require.NoError(t, err) + require.Contains(t, probes, "/readyz") + require.NotContains(t, probes, "/version") +} diff --git a/runtime.go b/runtime.go index f9a9c4a..6c8a12a 100644 --- a/runtime.go +++ b/runtime.go @@ -113,6 +113,8 @@ type Runtime struct { // startInputs renders the StartRequest fields the running process was // launched with, so a later Start can tell whether they still match. startInputs string + // initFixture is the authoritative selection for the current invocation. + initFixture string // launchInputs renders everything that shapes the launched process: the // StartRequest fields above plus the agent-owned argv state Init publishes. @@ -158,6 +160,9 @@ func (s *Runtime) Load(ctx context.Context, req *runtimev0.LoadRequest) (*runtim return s.Base.Runtime.LoadError(err) } + s.runnerMu.Lock() + s.initFixture = "" + s.runnerMu.Unlock() s.Base.Runtime.SetEnvironment(req.Environment) // FastAPI layout: Python source lives under /code. Push onto @@ -382,6 +387,8 @@ func (s *Runtime) Init(ctx context.Context, req *runtimev0.InitRequest) (*runtim // again — otherwise Init would succeed and every later Start refuse. s.runnerMu.Lock() s.destroyed = false + s.initFixture = req.GetFixture() + s.EnvironmentVariables.ResetFixture(s.initFixture) s.runnerMu.Unlock() if err := s.SetRuntimeContext(ctx, req.RuntimeContext); err != nil { @@ -517,7 +524,7 @@ func (s *Runtime) Start(ctx context.Context, req *runtimev0.StartRequest) (*runt } inputs := startInputs(req) - launch := launchInputs(inputs, s.port, s.grpcPort, s.FastAPI.Settings.HotReload) + launch := launchInputs(inputs, s.port, s.grpcPort, s.FastAPI.Settings.HotReload) + fmt.Sprintf("\ninit-fixture %q", s.initFixture) if s.runner != nil { switch { @@ -813,10 +820,6 @@ func (s *Runtime) applyStartInputs(ctx context.Context, req *runtimev0.StartRequ s.appliedOverrides[key]++ } - // Unconditional: the manager falls back to the environment-level fixture - // when this one is empty, so passing "" is how a withdrawn fixture reverts. - s.EnvironmentVariables.SetFixture(req.GetFixture()) - networkAccess := resources.NetworkAccessFromRuntimeContext(s.Base.Runtime.RuntimeContext) if unresolved := unresolvedDependencies(req.GetDependenciesNetworkMappings(), networkAccess); len(unresolved) > 0 { // AddEndpoints drops these silently, which surfaces much later as a @@ -863,6 +866,14 @@ func unresolvedDependencies(mappings []*basev0.NetworkMapping, networkAccess *ba // carries the secret configuration values alongside the plain ones, so this is // the complete set and the override resolution below covers secrets too. func (s *Runtime) processEnvironment(req *runtimev0.StartRequest) ([]*resources.EnvironmentVariable, error) { + // Init remains authoritative. Without an Init selection, the legacy + // Start-owned selector can be withdrawn instead of latching into a new process. + fixture := s.initFixture + if fixture == "" { + fixture = req.GetFixture() + } + s.EnvironmentVariables.ResetFixture(fixture) + envs, err := s.EnvironmentVariables.All() if err != nil { return nil, err diff --git a/templates/agent/README.md.tmpl b/templates/agent/README.md.tmpl index b969bba..e82d45b 100644 --- a/templates/agent/README.md.tmpl +++ b/templates/agent/README.md.tmpl @@ -13,3 +13,11 @@ Some of the things this agent provides for you: ## Production ready - Dockerfile and verifiable build plan generation (requires `output_directory`); the CLI owns image builds, builder selection, caching, and publishing - Kubernetes deployment + +## Declared container health + +Kubernetes probes follow the authored REST endpoint's health predicates and transport. HTTP checks preserve accepted statuses and bounded body containment; redirects are not followed. Secured container-local checks authenticate the listener against the exact certificate mounted at `UVICORN_SSL_CERTFILE` before sending HTTP data. The isolated Python probe imports no application code and emits no response content or certificate material. + +Startup, readiness and liveness are separate policies. The current Core Kubernetes manifest profile requires all three, so deployment refuses absent startup/liveness declarations instead of copying readiness into a restart policy. New scaffolds explicitly declare `/version` readiness and transport startup/liveness. Existing services must author their own policies. Whole-second timing is required by Kubernetes; predicates or additional listener health that this agent cannot preserve are refused rather than silently omitted. + +Image SBOM requests must provide immutable subjects and their registry/daemon source. The Core scanner preserves each requested identity; there is no registry-to-daemon fallback for a different image. diff --git a/templates/deployment/kustomize/base/deployment.yaml.tmpl b/templates/deployment/kustomize/base/deployment.yaml.tmpl index b2f3d60..185a7dc 100644 --- a/templates/deployment/kustomize/base/deployment.yaml.tmpl +++ b/templates/deployment/kustomize/base/deployment.yaml.tmpl @@ -47,20 +47,7 @@ spec: limits: cpu: 500m memory: 256Mi - startupProbe: - httpGet: - path: /version - port: 8080 - failureThreshold: 30 - periodSeconds: 2 - readinessProbe: - httpGet: - path: /version - port: 8080 - livenessProbe: - httpGet: - path: /version - port: 8080 +{{ .Deployment.Parameters.Probes }} volumeMounts: - name: tmp mountPath: /tmp diff --git a/uvicorn_lifecycle_test.go b/uvicorn_lifecycle_test.go index c288ec2..d2c38e2 100644 --- a/uvicorn_lifecycle_test.go +++ b/uvicorn_lifecycle_test.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "io" "net" "net/http" "os" @@ -203,3 +204,47 @@ func TestHotReloadParentOutlivesBrokenApp(t *testing.T) { "a successful reload must restore a serving app") require.Same(t, supervisor, currentRunner(runtime), "the reload happens inside the process uvicorn already owns") } + +func TestRealProcessFixtureWithdrawalAndInitAuthority(t *testing.T) { + source := uvProject(t, `import os +async def app(scope, receive, send): + value = os.environ.get("CODEFLY__FIXTURE", "none").encode() + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": value}) +`) + runtime := uvicornRuntime(t, source, false) + ctx := context.Background() + assertBody := func(want string) { + t.Helper() + require.Eventually(t, func() bool { + client := http.Client{Timeout: time.Second} + response, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/", runtime.port)) + if err != nil { + return false + } + defer response.Body.Close() + body, err := io.ReadAll(io.LimitReader(response.Body, 1024)) + return err == nil && string(body) == want + }, 10*time.Second, 100*time.Millisecond) + } + _, err := runtime.Start(ctx, &runtimev0.StartRequest{Fixture: "seed"}) + require.NoError(t, err) + assertBody("seed") + _, err = runtime.Start(ctx, &runtimev0.StartRequest{}) + require.NoError(t, err) + assertBody("none") + // uvicornRuntime establishes the state after Init. Preserve that invocation + // selection even if a later Start carries a different legacy selector. + runtime.runnerMu.Lock() + runtime.initFixture = "installed" + runtime.runnerMu.Unlock() + _, err = runtime.Start(ctx, &runtimev0.StartRequest{Fixture: "other"}) + require.NoError(t, err) + assertBody("installed") + runtime.runnerMu.Lock() + runtime.initFixture = "replacement" + runtime.runnerMu.Unlock() + _, err = runtime.Start(ctx, &runtimev0.StartRequest{Fixture: "other"}) + require.NoError(t, err) + assertBody("replacement") +}