From 8fa2193cb42e0b9894acdbbbb595109504d03dc6 Mon Sep 17 00:00:00 2001 From: pieh Date: Sun, 28 Jun 2026 17:42:06 +0200 Subject: [PATCH 1/5] feat: update swagger.yml for required_edge_functions --- swagger.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/swagger.yml b/swagger.yml index f5156ca0..73f86b2d 100644 --- a/swagger.yml +++ b/swagger.yml @@ -4743,6 +4743,13 @@ definitions: type: array items: type: string + required_edge_functions: + type: array + items: + type: string + description: | + An array of code_shas for the edge-function ROM bundles that need to be uploaded to + complete the deploy. error_message: type: string branch: @@ -4842,6 +4849,11 @@ definitions: type: boolean functions: type: object + edge_functions: + type: object + description: | + A hash mapping edge-function ROM bundle formats to the code_sha of each bundle. The + response's required_edge_functions lists which of these still need to be uploaded. function_schedules: type: array items: From f529cb24e938f893c514431f88879bd45b2256a3 Mon Sep 17 00:00:00 2001 From: pieh Date: Tue, 30 Jun 2026 10:55:14 +0200 Subject: [PATCH 2/5] chore: update go models --- go/models/deploy.go | 5 +++++ go/models/deploy_files.go | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/go/models/deploy.go b/go/models/deploy.go index b9ad2336..c8ce9ee6 100644 --- a/go/models/deploy.go +++ b/go/models/deploy.go @@ -81,6 +81,11 @@ type Deploy struct { // required Required []string `json:"required"` + // An array of code_shas for the edge-function ROM bundles that need to be uploaded to + // complete the deploy. + // + RequiredEdgeFunctions []string `json:"required_edge_functions"` + // required functions RequiredFunctions []string `json:"required_functions"` diff --git a/go/models/deploy_files.go b/go/models/deploy_files.go index 9afba55f..fd956af2 100644 --- a/go/models/deploy_files.go +++ b/go/models/deploy_files.go @@ -33,6 +33,11 @@ type DeployFiles struct { // draft Draft bool `json:"draft,omitempty"` + // A hash mapping edge-function ROM bundle formats to the code_sha of each bundle. The + // response's required_edge_functions lists which of these still need to be uploaded. + // + EdgeFunctions interface{} `json:"edge_functions,omitempty"` + // A list of deploy-specific environment variable data. Data specified this way applies only // to this specific deploy and is merged into any existing environment variables set on the // account and site. From 7d47d2d212411c4a08f4afe41e98f9fca91fb534 Mon Sep 17 00:00:00 2001 From: pieh Date: Tue, 30 Jun 2026 10:55:49 +0200 Subject: [PATCH 3/5] fix: optimisitc netlify-server ef upload def --- go/plumbing/operations/operations_client.go | 36 +++ .../upload_deploy_edge_function_parameters.go | 252 ++++++++++++++++++ .../upload_deploy_edge_function_responses.go | 107 ++++++++ swagger.yml | 33 +++ 4 files changed, 428 insertions(+) create mode 100644 go/plumbing/operations/upload_deploy_edge_function_parameters.go create mode 100644 go/plumbing/operations/upload_deploy_edge_function_responses.go diff --git a/go/plumbing/operations/operations_client.go b/go/plumbing/operations/operations_client.go index 742a19ff..d985cc5b 100644 --- a/go/plumbing/operations/operations_client.go +++ b/go/plumbing/operations/operations_client.go @@ -381,6 +381,8 @@ type ClientService interface { UpdateSplitTest(params *UpdateSplitTestParams, authInfo runtime.ClientAuthInfoWriter) (*UpdateSplitTestCreated, error) + UploadDeployEdgeFunction(params *UploadDeployEdgeFunctionParams, authInfo runtime.ClientAuthInfoWriter) (*UploadDeployEdgeFunctionOK, error) + UploadDeployFile(params *UploadDeployFileParams, authInfo runtime.ClientAuthInfoWriter) (*UploadDeployFileOK, error) UploadDeployFunction(params *UploadDeployFunctionParams, authInfo runtime.ClientAuthInfoWriter) (*UploadDeployFunctionOK, error) @@ -6434,6 +6436,40 @@ func (a *Client) UpdateSplitTest(params *UpdateSplitTestParams, authInfo runtime return nil, runtime.NewAPIError("unexpected success response: content available as default response in error", unexpectedSuccess, unexpectedSuccess.Code()) } +/* +UploadDeployEdgeFunction upload deploy edge function API +*/ +func (a *Client) UploadDeployEdgeFunction(params *UploadDeployEdgeFunctionParams, authInfo runtime.ClientAuthInfoWriter) (*UploadDeployEdgeFunctionOK, error) { + // TODO: Validate the params before sending + if params == nil { + params = NewUploadDeployEdgeFunctionParams() + } + + result, err := a.transport.Submit(&runtime.ClientOperation{ + ID: "uploadDeployEdgeFunction", + Method: "PUT", + PathPattern: "/deploys/{deploy_id}/edge_functions/{code_sha}", + ProducesMediaTypes: []string{"application/json"}, + ConsumesMediaTypes: []string{"application/octet-stream"}, + Schemes: []string{"https"}, + Params: params, + Reader: &UploadDeployEdgeFunctionReader{formats: a.formats}, + AuthInfo: authInfo, + Context: params.Context, + Client: params.HTTPClient, + }) + if err != nil { + return nil, err + } + success, ok := result.(*UploadDeployEdgeFunctionOK) + if ok { + return success, nil + } + // unexpected success response + unexpectedSuccess := result.(*UploadDeployEdgeFunctionDefault) + return nil, runtime.NewAPIError("unexpected success response: content available as default response in error", unexpectedSuccess, unexpectedSuccess.Code()) +} + /* UploadDeployFile upload deploy file API */ diff --git a/go/plumbing/operations/upload_deploy_edge_function_parameters.go b/go/plumbing/operations/upload_deploy_edge_function_parameters.go new file mode 100644 index 00000000..dc27b97b --- /dev/null +++ b/go/plumbing/operations/upload_deploy_edge_function_parameters.go @@ -0,0 +1,252 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package operations + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "io" + "net/http" + "time" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + cr "github.com/go-openapi/runtime/client" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" +) + +// NewUploadDeployEdgeFunctionParams creates a new UploadDeployEdgeFunctionParams object +// with the default values initialized. +func NewUploadDeployEdgeFunctionParams() *UploadDeployEdgeFunctionParams { + var () + return &UploadDeployEdgeFunctionParams{ + + timeout: cr.DefaultTimeout, + } +} + +// NewUploadDeployEdgeFunctionParamsWithTimeout creates a new UploadDeployEdgeFunctionParams object +// with the default values initialized, and the ability to set a timeout on a request +func NewUploadDeployEdgeFunctionParamsWithTimeout(timeout time.Duration) *UploadDeployEdgeFunctionParams { + var () + return &UploadDeployEdgeFunctionParams{ + + timeout: timeout, + } +} + +// NewUploadDeployEdgeFunctionParamsWithContext creates a new UploadDeployEdgeFunctionParams object +// with the default values initialized, and the ability to set a context for a request +func NewUploadDeployEdgeFunctionParamsWithContext(ctx context.Context) *UploadDeployEdgeFunctionParams { + var () + return &UploadDeployEdgeFunctionParams{ + + Context: ctx, + } +} + +// NewUploadDeployEdgeFunctionParamsWithHTTPClient creates a new UploadDeployEdgeFunctionParams object +// with the default values initialized, and the ability to set a custom HTTPClient for a request +func NewUploadDeployEdgeFunctionParamsWithHTTPClient(client *http.Client) *UploadDeployEdgeFunctionParams { + var () + return &UploadDeployEdgeFunctionParams{ + HTTPClient: client, + } +} + +/* +UploadDeployEdgeFunctionParams contains all the parameters to send to the API endpoint +for the upload deploy edge function operation typically these are written to a http.Request +*/ +type UploadDeployEdgeFunctionParams struct { + + /*XNfRetryCount*/ + XNfRetryCount *int64 + /*CodeSha*/ + CodeSha string + /*DeployID*/ + DeployID string + /*FileBody*/ + FileBody io.ReadCloser + /*Format*/ + Format *string + /*Size*/ + Size *int64 + + timeout time.Duration + Context context.Context + HTTPClient *http.Client +} + +// WithTimeout adds the timeout to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) WithTimeout(timeout time.Duration) *UploadDeployEdgeFunctionParams { + o.SetTimeout(timeout) + return o +} + +// SetTimeout adds the timeout to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) SetTimeout(timeout time.Duration) { + o.timeout = timeout +} + +// WithContext adds the context to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) WithContext(ctx context.Context) *UploadDeployEdgeFunctionParams { + o.SetContext(ctx) + return o +} + +// SetContext adds the context to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) SetContext(ctx context.Context) { + o.Context = ctx +} + +// WithHTTPClient adds the HTTPClient to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) WithHTTPClient(client *http.Client) *UploadDeployEdgeFunctionParams { + o.SetHTTPClient(client) + return o +} + +// SetHTTPClient adds the HTTPClient to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) SetHTTPClient(client *http.Client) { + o.HTTPClient = client +} + +// WithXNfRetryCount adds the xNfRetryCount to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) WithXNfRetryCount(xNfRetryCount *int64) *UploadDeployEdgeFunctionParams { + o.SetXNfRetryCount(xNfRetryCount) + return o +} + +// SetXNfRetryCount adds the xNfRetryCount to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) SetXNfRetryCount(xNfRetryCount *int64) { + o.XNfRetryCount = xNfRetryCount +} + +// WithCodeSha adds the codeSha to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) WithCodeSha(codeSha string) *UploadDeployEdgeFunctionParams { + o.SetCodeSha(codeSha) + return o +} + +// SetCodeSha adds the codeSha to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) SetCodeSha(codeSha string) { + o.CodeSha = codeSha +} + +// WithDeployID adds the deployID to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) WithDeployID(deployID string) *UploadDeployEdgeFunctionParams { + o.SetDeployID(deployID) + return o +} + +// SetDeployID adds the deployId to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) SetDeployID(deployID string) { + o.DeployID = deployID +} + +// WithFileBody adds the fileBody to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) WithFileBody(fileBody io.ReadCloser) *UploadDeployEdgeFunctionParams { + o.SetFileBody(fileBody) + return o +} + +// SetFileBody adds the fileBody to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) SetFileBody(fileBody io.ReadCloser) { + o.FileBody = fileBody +} + +// WithFormat adds the format to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) WithFormat(format *string) *UploadDeployEdgeFunctionParams { + o.SetFormat(format) + return o +} + +// SetFormat adds the format to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) SetFormat(format *string) { + o.Format = format +} + +// WithSize adds the size to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) WithSize(size *int64) *UploadDeployEdgeFunctionParams { + o.SetSize(size) + return o +} + +// SetSize adds the size to the upload deploy edge function params +func (o *UploadDeployEdgeFunctionParams) SetSize(size *int64) { + o.Size = size +} + +// WriteToRequest writes these params to a swagger request +func (o *UploadDeployEdgeFunctionParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { + + if err := r.SetTimeout(o.timeout); err != nil { + return err + } + var res []error + + if o.XNfRetryCount != nil { + + // header param X-Nf-Retry-Count + if err := r.SetHeaderParam("X-Nf-Retry-Count", swag.FormatInt64(*o.XNfRetryCount)); err != nil { + return err + } + + } + + // path param code_sha + if err := r.SetPathParam("code_sha", o.CodeSha); err != nil { + return err + } + + // path param deploy_id + if err := r.SetPathParam("deploy_id", o.DeployID); err != nil { + return err + } + + if o.FileBody != nil { + if err := r.SetBodyParam(o.FileBody); err != nil { + return err + } + } + + if o.Format != nil { + + // query param format + var qrFormat string + if o.Format != nil { + qrFormat = *o.Format + } + qFormat := qrFormat + if qFormat != "" { + if err := r.SetQueryParam("format", qFormat); err != nil { + return err + } + } + + } + + if o.Size != nil { + + // query param size + var qrSize int64 + if o.Size != nil { + qrSize = *o.Size + } + qSize := swag.FormatInt64(qrSize) + if qSize != "" { + if err := r.SetQueryParam("size", qSize); err != nil { + return err + } + } + + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/go/plumbing/operations/upload_deploy_edge_function_responses.go b/go/plumbing/operations/upload_deploy_edge_function_responses.go new file mode 100644 index 00000000..1e23196c --- /dev/null +++ b/go/plumbing/operations/upload_deploy_edge_function_responses.go @@ -0,0 +1,107 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package operations + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "fmt" + "io" + + "github.com/go-openapi/runtime" + "github.com/go-openapi/strfmt" + + "github.com/netlify/open-api/v2/go/models" +) + +// UploadDeployEdgeFunctionReader is a Reader for the UploadDeployEdgeFunction structure. +type UploadDeployEdgeFunctionReader struct { + formats strfmt.Registry +} + +// ReadResponse reads a server response into the received o. +func (o *UploadDeployEdgeFunctionReader) ReadResponse(response runtime.ClientResponse, consumer runtime.Consumer) (interface{}, error) { + switch response.Code() { + case 200: + result := NewUploadDeployEdgeFunctionOK() + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + return result, nil + default: + result := NewUploadDeployEdgeFunctionDefault(response.Code()) + if err := result.readResponse(response, consumer, o.formats); err != nil { + return nil, err + } + if response.Code()/100 == 2 { + return result, nil + } + return nil, result + } +} + +// NewUploadDeployEdgeFunctionOK creates a UploadDeployEdgeFunctionOK with default headers values +func NewUploadDeployEdgeFunctionOK() *UploadDeployEdgeFunctionOK { + return &UploadDeployEdgeFunctionOK{} +} + +/* +UploadDeployEdgeFunctionOK handles this case with default header values. + +OK +*/ +type UploadDeployEdgeFunctionOK struct { +} + +func (o *UploadDeployEdgeFunctionOK) Error() string { + return fmt.Sprintf("[PUT /deploys/{deploy_id}/edge_functions/{code_sha}][%d] uploadDeployEdgeFunctionOK ", 200) +} + +func (o *UploadDeployEdgeFunctionOK) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + return nil +} + +// NewUploadDeployEdgeFunctionDefault creates a UploadDeployEdgeFunctionDefault with default headers values +func NewUploadDeployEdgeFunctionDefault(code int) *UploadDeployEdgeFunctionDefault { + return &UploadDeployEdgeFunctionDefault{ + _statusCode: code, + } +} + +/* +UploadDeployEdgeFunctionDefault handles this case with default header values. + +error +*/ +type UploadDeployEdgeFunctionDefault struct { + _statusCode int + + Payload *models.Error +} + +// Code gets the status code for the upload deploy edge function default response +func (o *UploadDeployEdgeFunctionDefault) Code() int { + return o._statusCode +} + +func (o *UploadDeployEdgeFunctionDefault) Error() string { + return fmt.Sprintf("[PUT /deploys/{deploy_id}/edge_functions/{code_sha}][%d] uploadDeployEdgeFunction default %+v", o._statusCode, o.Payload) +} + +func (o *UploadDeployEdgeFunctionDefault) GetPayload() *models.Error { + return o.Payload +} + +func (o *UploadDeployEdgeFunctionDefault) readResponse(response runtime.ClientResponse, consumer runtime.Consumer, formats strfmt.Registry) error { + + o.Payload = new(models.Error) + + // response payload + if err := consumer.Consume(response.Body(), o.Payload); err != nil && err != io.EOF { + return err + } + + return nil +} diff --git a/swagger.yml b/swagger.yml index 73f86b2d..8c0fb203 100644 --- a/swagger.yml +++ b/swagger.yml @@ -1665,6 +1665,39 @@ paths: $ref: '#/definitions/function' default: $ref: '#/responses/error' + /deploys/{deploy_id}/edge_functions/{code_sha}: + put: + operationId: uploadDeployEdgeFunction + tags: [edgeFunction] + consumes: + - application/octet-stream + parameters: + - name: deploy_id + type: string + in: path + required: true + - name: code_sha + type: string + in: path + required: true + - name: format + type: string + in: query + - name: size + type: integer + in: query + - name: file_body + in: body + schema: + type: string + format: binary + required: true + - $ref: '#/parameters/retryCount' + responses: + '200': + description: OK + default: + $ref: '#/responses/error' /sites/{site_id}/plugins/{package}: put: x-internal: true From 5498cd35af07fea54157a45a6e20a19d0e38bc1c Mon Sep 17 00:00:00 2001 From: pieh Date: Tue, 30 Jun 2026 10:56:36 +0200 Subject: [PATCH 4/5] feat: initial wiring --- go/porcelain/deploy.go | 119 +++++++++++++++++++++++- go/porcelain/deploy_test.go | 86 +++++++++++++++++ go/porcelain/edge_functions_manifest.go | 11 +++ 3 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 go/porcelain/edge_functions_manifest.go diff --git a/go/porcelain/deploy.go b/go/porcelain/deploy.go index f421e501..e94251a7 100644 --- a/go/porcelain/deploy.go +++ b/go/porcelain/deploy.go @@ -11,6 +11,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "hash" "io" "io/ioutil" "os" @@ -40,6 +41,7 @@ const ( fileUpload uploadType = iota functionUpload + edgeFunctionUpload lfsVersionString = "version https://git-lfs.github.com/spec/v1" @@ -104,6 +106,7 @@ type DeployOptions struct { files *deployFiles functions *deployFiles + edgeFunctions *deployFiles functionSchedules []*models.FunctionSchedule functionsConfig map[string]models.FunctionConfig } @@ -274,6 +277,15 @@ func (n *Netlify) DoDeploy(ctx context.Context, options *DeployOptions, deploy * options.functionSchedules = schedules options.functionsConfig = functionsConfig + edgeFunctions, err := bundleEdgeFunctions(ctx, options.EdgeFunctionsDir, options.Observer) + if err != nil { + if options.Observer != nil { + options.Observer.OnFailedWalk() + } + return nil, err + } + options.edgeFunctions = edgeFunctions + deployFiles := &models.DeployFiles{ Files: options.files.Sums, Draft: options.IsDraft, @@ -284,6 +296,9 @@ func (n *Netlify) DoDeploy(ctx context.Context, options *DeployOptions, deploy * if options.functions != nil { deployFiles.Functions = options.functions.Sums } + if options.edgeFunctions != nil { + deployFiles.EdgeFunctions = options.edgeFunctions.Sums + } if len(options.Environment) > 0 { deployFiles.Environment = options.Environment @@ -365,7 +380,7 @@ func (n *Netlify) DoDeploy(ctx context.Context, options *DeployOptions, deploy * } } - if len(deploy.Required) == 0 && len(deploy.RequiredFunctions) == 0 { + if len(deploy.Required) == 0 && len(deploy.RequiredFunctions) == 0 && len(deploy.RequiredEdgeFunctions) == 0 { return deploy, nil } @@ -381,6 +396,12 @@ func (n *Netlify) DoDeploy(ctx context.Context, options *DeployOptions, deploy * } } + if options.edgeFunctions != nil { + if err := n.uploadFiles(ctx, deploy, options.edgeFunctions, options.Observer, edgeFunctionUpload, options.UploadTimeout, skipRetry); err != nil { + return nil, err + } + } + return deploy, nil } @@ -444,6 +465,8 @@ func (n *Netlify) uploadFiles(ctx context.Context, d *models.Deploy, files *depl required = d.Required case functionUpload: required = d.RequiredFunctions + case edgeFunctionUpload: + required = d.RequiredEdgeFunctions } count := 0 @@ -557,6 +580,26 @@ func (n *Netlify) uploadFile(ctx context.Context, d *models.Deploy, f *FileBundl if operationError != nil { f.Buffer.Seek(0, 0) } + case edgeFunctionUpload: + var body io.ReadCloser + body, operationError = os.Open(f.Path) + if operationError == nil { + defer body.Close() + params := operations.NewUploadDeployEdgeFunctionParams().WithDeployID(d.ID).WithCodeSha(f.Sum).WithFileBody(body) + if f.Name != "" { + params = params.WithFormat(&f.Name) + } + if retryCount > 0 { + params = params.WithXNfRetryCount(&retryCount) + } + if f.Size != nil { + params = params.WithSize(f.Size) + } + if timeout != 0 { + params.SetTimeout(timeout) + } + _, operationError = n.Operations.UploadDeployEdgeFunction(params, authInfo) + } } if operationError != nil { @@ -601,6 +644,10 @@ func (n *Netlify) uploadFile(ctx context.Context, d *models.Deploy, f *FileBundl } func createFileBundle(rel, path string) (*FileBundle, error) { + return createFileBundleWithHasher(rel, path, sha1.New()) +} + +func createFileBundleWithHasher(rel, path string, s hash.Hash) (*FileBundle, error) { o, err := os.Open(path) if err != nil { return nil, err @@ -612,7 +659,6 @@ func createFileBundle(rel, path string) (*FileBundle, error) { Path: path, } - s := sha1.New() if _, err := io.Copy(s, o); err != nil { return nil, err } @@ -967,6 +1013,75 @@ func newFunctionFile(filePath string, i os.FileInfo, runtime string, metadata *F return file, nil } +// bundleEdgeFunctions reads the edge-bundler manifest from edgeFunctionsDir and turns each bundle it +// lists into an uploadable FileBundle. The deploy declares these as its edge_functions map +// ({format => code_sha}); the server replies with the subset (required_edge_functions) not already +// stored, and only those are streamed up. A missing manifest means no edge functions to upload. +func bundleEdgeFunctions(ctx context.Context, edgeFunctionsDir string, observer DeployObserver) (*deployFiles, error) { + if edgeFunctionsDir == "" { + return nil, nil + } + + manifestBytes, err := os.ReadFile(filepath.Join(edgeFunctionsDir, "manifest.json")) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + + context.GetLogger(ctx).Debug("Found edge functions manifest file") + + var manifest edgeFunctionsManifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + return nil, fmt.Errorf("malformed edge functions manifest file: %w", err) + } + + if len(manifest.Bundles) == 0 { + return nil, nil + } + + files := newDeployFiles() + for _, bundle := range manifest.Bundles { + file, err := newEdgeFunctionFile(edgeFunctionsDir, bundle) + if err != nil { + return nil, err + } + files.Add(file.Name, file) + + if observer != nil { + if err := observer.OnSuccessfulStep(file); err != nil { + return nil, err + } + } + } + + return files, nil +} + +func newEdgeFunctionFile(edgeFunctionsDir string, bundle edgeFunctionsManifestBundle) (*FileBundle, error) { + path := filepath.Join(edgeFunctionsDir, bundle.Asset) + + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("edge functions manifest specifies a bundle that cannot be found: %s", bundle.Asset) + } + + // code_sha is the dedup key in the deployer<->functions-origin contract, so we compute it from the + // bundle's bytes rather than trusting the edge-bundler's asset filename (which currently also happens + // to be the sha256, but that's a bundler implementation detail). createFileBundleWithHasher streams + // the bytes through the hasher, so the bundle is never held in memory. + file, err := createFileBundleWithHasher(bundle.Format, path, sha256.New()) + if err != nil { + return nil, err + } + + size := info.Size() + file.Size = &size + + return file, nil +} + func zipFile(i os.FileInfo) bool { return filepath.Ext(i.Name()) == ".zip" } diff --git a/go/porcelain/deploy_test.go b/go/porcelain/deploy_test.go index 7bdf62c0..4acfb818 100644 --- a/go/porcelain/deploy_test.go +++ b/go/porcelain/deploy_test.go @@ -3,7 +3,10 @@ package porcelain import ( "bytes" gocontext "context" + "crypto/sha256" + "encoding/hex" "fmt" + "io" "io/ioutil" "net/http" "net/http/httptest" @@ -656,6 +659,89 @@ func TestUploadFunctions_RetryCountHeader(t *testing.T) { require.NoError(t, client.uploadFiles(apiCtx, d, files, nil, functionUpload, time.Minute, false)) } +func TestBundleEdgeFunctions(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "aaa111.eszip"), []byte("eszip-rom"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "bbb222.tar"), []byte("tar-rom"), 0644)) + manifest := `{ + "bundles": [ + { "asset": "aaa111.eszip", "format": "eszip2" }, + { "asset": "bbb222.tar", "format": "tar" } + ] + }` + require.NoError(t, os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0644)) + + files, err := bundleEdgeFunctions(gocontext.Background(), dir, mockObserver{}) + require.NoError(t, err) + + // The declared edge_functions map keys each bundle by its format, with the code_sha computed by the + // deployer from the bundle bytes (not parsed from the asset filename). + eszipSha := sha256.Sum256([]byte("eszip-rom")) + tarSha := sha256.Sum256([]byte("tar-rom")) + require.Equal(t, map[string]string{ + "eszip2": hex.EncodeToString(eszipSha[:]), + "tar": hex.EncodeToString(tarSha[:]), + }, files.Sums) + + // Bundles are tracked by Path and streamed at upload time; they are never buffered into memory. + require.Equal(t, filepath.Join(dir, "aaa111.eszip"), files.Files["eszip2"].Path) + require.Nil(t, files.Files["eszip2"].Buffer) + require.NotNil(t, files.Files["eszip2"].Size) + require.EqualValues(t, len("eszip-rom"), *files.Files["eszip2"].Size) +} + +func TestBundleEdgeFunctions_NoManifest(t *testing.T) { + // No edge functions dir configured. + files, err := bundleEdgeFunctions(gocontext.Background(), "", mockObserver{}) + require.NoError(t, err) + require.Nil(t, files) + + // A dir without a manifest.json yields no edge functions rather than an error. + files, err = bundleEdgeFunctions(gocontext.Background(), t.TempDir(), mockObserver{}) + require.NoError(t, err) + require.Nil(t, files) +} + +func TestUploadEdgeFunctions(t *testing.T) { + ctx, cancel := gocontext.WithCancel(gocontext.Background()) + t.Cleanup(cancel) + + romBody := []byte("baked-eszip-rom-bytes") + + var gotPath, gotFormat string + var gotBody []byte + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + gotPath = req.URL.Path + gotFormat = req.URL.Query().Get("format") + gotBody, _ = io.ReadAll(req.Body) + rw.WriteHeader(http.StatusOK) + })) + defer server.Close() + + hu, _ := url.Parse(server.URL) + tr := apiClient.NewWithClient(hu.Host, "/api/v1", []string{"http"}, http.DefaultClient) + client := NewRetryable(tr, strfmt.Default, 1) + client.uploadLimit = 1 + apiCtx := context.WithAuthInfo(ctx, apiClient.BearerToken("token")) + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "edgecodesha.eszip"), romBody, 0644)) + manifest := `{ "bundles": [ { "asset": "edgecodesha.eszip", "format": "eszip2" } ] }` + require.NoError(t, os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0644)) + + files, err := bundleEdgeFunctions(gocontext.Background(), dir, mockObserver{}) + require.NoError(t, err) + + codeSha := files.Sums["eszip2"] + d := &models.Deploy{ID: "deploy-id", RequiredEdgeFunctions: []string{codeSha}} + + require.NoError(t, client.uploadFiles(apiCtx, d, files, nil, edgeFunctionUpload, time.Minute, false)) + + require.Equal(t, "/api/v1/deploys/deploy-id/edge_functions/"+codeSha, gotPath) + require.Equal(t, "eszip2", gotFormat) + require.Equal(t, romBody, gotBody) +} + func TestBundle(t *testing.T) { functions, schedules, functionsConfig, err := bundle(gocontext.Background(), "../internal/data", mockObserver{}) diff --git a/go/porcelain/edge_functions_manifest.go b/go/porcelain/edge_functions_manifest.go new file mode 100644 index 00000000..0a6c48e4 --- /dev/null +++ b/go/porcelain/edge_functions_manifest.go @@ -0,0 +1,11 @@ +package porcelain + +// https://github.com/netlify/build/blob/main/packages/edge-bundler/node/manifest.ts +type edgeFunctionsManifest struct { + Bundles []edgeFunctionsManifestBundle `json:"bundles"` +} + +type edgeFunctionsManifestBundle struct { + Asset string `json:"asset"` + Format string `json:"format"` +} From c04fdfd08888b1ef9bc103b15909950433062776 Mon Sep 17 00:00:00 2001 From: pieh Date: Tue, 30 Jun 2026 21:22:47 +0200 Subject: [PATCH 5/5] sync with nfsvr and bb --- go/models/deploy.go | 2 +- go/models/deploy_files.go | 2 +- .../upload_deploy_edge_function_parameters.go | 58 ------------------- go/porcelain/deploy.go | 16 +---- go/porcelain/deploy_test.go | 33 +++++------ swagger.yml | 10 +--- 6 files changed, 21 insertions(+), 100 deletions(-) diff --git a/go/models/deploy.go b/go/models/deploy.go index c8ce9ee6..8f82b160 100644 --- a/go/models/deploy.go +++ b/go/models/deploy.go @@ -81,7 +81,7 @@ type Deploy struct { // required Required []string `json:"required"` - // An array of code_shas for the edge-function ROM bundles that need to be uploaded to + // An array of code_shas for the edge-function bundles that need to be uploaded to // complete the deploy. // RequiredEdgeFunctions []string `json:"required_edge_functions"` diff --git a/go/models/deploy_files.go b/go/models/deploy_files.go index fd956af2..d74bb09b 100644 --- a/go/models/deploy_files.go +++ b/go/models/deploy_files.go @@ -33,7 +33,7 @@ type DeployFiles struct { // draft Draft bool `json:"draft,omitempty"` - // A hash mapping edge-function ROM bundle formats to the code_sha of each bundle. The + // A hash mapping edge-function bundle formats to the code_sha of each bundle. The // response's required_edge_functions lists which of these still need to be uploaded. // EdgeFunctions interface{} `json:"edge_functions,omitempty"` diff --git a/go/plumbing/operations/upload_deploy_edge_function_parameters.go b/go/plumbing/operations/upload_deploy_edge_function_parameters.go index dc27b97b..bdcd049f 100644 --- a/go/plumbing/operations/upload_deploy_edge_function_parameters.go +++ b/go/plumbing/operations/upload_deploy_edge_function_parameters.go @@ -71,10 +71,6 @@ type UploadDeployEdgeFunctionParams struct { DeployID string /*FileBody*/ FileBody io.ReadCloser - /*Format*/ - Format *string - /*Size*/ - Size *int64 timeout time.Duration Context context.Context @@ -158,28 +154,6 @@ func (o *UploadDeployEdgeFunctionParams) SetFileBody(fileBody io.ReadCloser) { o.FileBody = fileBody } -// WithFormat adds the format to the upload deploy edge function params -func (o *UploadDeployEdgeFunctionParams) WithFormat(format *string) *UploadDeployEdgeFunctionParams { - o.SetFormat(format) - return o -} - -// SetFormat adds the format to the upload deploy edge function params -func (o *UploadDeployEdgeFunctionParams) SetFormat(format *string) { - o.Format = format -} - -// WithSize adds the size to the upload deploy edge function params -func (o *UploadDeployEdgeFunctionParams) WithSize(size *int64) *UploadDeployEdgeFunctionParams { - o.SetSize(size) - return o -} - -// SetSize adds the size to the upload deploy edge function params -func (o *UploadDeployEdgeFunctionParams) SetSize(size *int64) { - o.Size = size -} - // WriteToRequest writes these params to a swagger request func (o *UploadDeployEdgeFunctionParams) WriteToRequest(r runtime.ClientRequest, reg strfmt.Registry) error { @@ -213,38 +187,6 @@ func (o *UploadDeployEdgeFunctionParams) WriteToRequest(r runtime.ClientRequest, } } - if o.Format != nil { - - // query param format - var qrFormat string - if o.Format != nil { - qrFormat = *o.Format - } - qFormat := qrFormat - if qFormat != "" { - if err := r.SetQueryParam("format", qFormat); err != nil { - return err - } - } - - } - - if o.Size != nil { - - // query param size - var qrSize int64 - if o.Size != nil { - qrSize = *o.Size - } - qSize := swag.FormatInt64(qrSize) - if qSize != "" { - if err := r.SetQueryParam("size", qSize); err != nil { - return err - } - } - - } - if len(res) > 0 { return errors.CompositeValidationError(res...) } diff --git a/go/porcelain/deploy.go b/go/porcelain/deploy.go index e94251a7..eba2738d 100644 --- a/go/porcelain/deploy.go +++ b/go/porcelain/deploy.go @@ -586,15 +586,9 @@ func (n *Netlify) uploadFile(ctx context.Context, d *models.Deploy, f *FileBundl if operationError == nil { defer body.Close() params := operations.NewUploadDeployEdgeFunctionParams().WithDeployID(d.ID).WithCodeSha(f.Sum).WithFileBody(body) - if f.Name != "" { - params = params.WithFormat(&f.Name) - } if retryCount > 0 { params = params.WithXNfRetryCount(&retryCount) } - if f.Size != nil { - params = params.WithSize(f.Size) - } if timeout != 0 { params.SetTimeout(timeout) } @@ -1062,23 +1056,15 @@ func bundleEdgeFunctions(ctx context.Context, edgeFunctionsDir string, observer func newEdgeFunctionFile(edgeFunctionsDir string, bundle edgeFunctionsManifestBundle) (*FileBundle, error) { path := filepath.Join(edgeFunctionsDir, bundle.Asset) - info, err := os.Stat(path) - if err != nil { - return nil, fmt.Errorf("edge functions manifest specifies a bundle that cannot be found: %s", bundle.Asset) - } - // code_sha is the dedup key in the deployer<->functions-origin contract, so we compute it from the // bundle's bytes rather than trusting the edge-bundler's asset filename (which currently also happens // to be the sha256, but that's a bundler implementation detail). createFileBundleWithHasher streams // the bytes through the hasher, so the bundle is never held in memory. file, err := createFileBundleWithHasher(bundle.Format, path, sha256.New()) if err != nil { - return nil, err + return nil, fmt.Errorf("edge functions manifest specifies a bundle that cannot be read: %s: %w", bundle.Asset, err) } - size := info.Size() - file.Size = &size - return file, nil } diff --git a/go/porcelain/deploy_test.go b/go/porcelain/deploy_test.go index 4acfb818..7ff58323 100644 --- a/go/porcelain/deploy_test.go +++ b/go/porcelain/deploy_test.go @@ -661,12 +661,15 @@ func TestUploadFunctions_RetryCountHeader(t *testing.T) { func TestBundleEdgeFunctions(t *testing.T) { dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "aaa111.eszip"), []byte("eszip-rom"), 0644)) - require.NoError(t, os.WriteFile(filepath.Join(dir, "bbb222.tar"), []byte("tar-rom"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "aaa111.eszip"), []byte("eszip-bundle"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "bbb222.tar.gz"), []byte("tar-bundle"), 0644)) + // A realistic edge-bundler manifest: an eszip plus a tar bundle (asset names are "", + // e.g. ".eszip" / ".tar.gz"). porcelain declares every bundle regardless of format; restricting to a + // format (today, tar) is bitballoon's job, not the client's. manifest := `{ "bundles": [ { "asset": "aaa111.eszip", "format": "eszip2" }, - { "asset": "bbb222.tar", "format": "tar" } + { "asset": "bbb222.tar.gz", "format": "tar" } ] }` require.NoError(t, os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0644)) @@ -676,18 +679,16 @@ func TestBundleEdgeFunctions(t *testing.T) { // The declared edge_functions map keys each bundle by its format, with the code_sha computed by the // deployer from the bundle bytes (not parsed from the asset filename). - eszipSha := sha256.Sum256([]byte("eszip-rom")) - tarSha := sha256.Sum256([]byte("tar-rom")) + eszipSha := sha256.Sum256([]byte("eszip-bundle")) + tarSha := sha256.Sum256([]byte("tar-bundle")) require.Equal(t, map[string]string{ "eszip2": hex.EncodeToString(eszipSha[:]), "tar": hex.EncodeToString(tarSha[:]), }, files.Sums) // Bundles are tracked by Path and streamed at upload time; they are never buffered into memory. - require.Equal(t, filepath.Join(dir, "aaa111.eszip"), files.Files["eszip2"].Path) - require.Nil(t, files.Files["eszip2"].Buffer) - require.NotNil(t, files.Files["eszip2"].Size) - require.EqualValues(t, len("eszip-rom"), *files.Files["eszip2"].Size) + require.Equal(t, filepath.Join(dir, "bbb222.tar.gz"), files.Files["tar"].Path) + require.Nil(t, files.Files["tar"].Buffer) } func TestBundleEdgeFunctions_NoManifest(t *testing.T) { @@ -706,13 +707,12 @@ func TestUploadEdgeFunctions(t *testing.T) { ctx, cancel := gocontext.WithCancel(gocontext.Background()) t.Cleanup(cancel) - romBody := []byte("baked-eszip-rom-bytes") + bundleBody := []byte("baked-tar-bundle-bytes") - var gotPath, gotFormat string + var gotPath string var gotBody []byte server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { gotPath = req.URL.Path - gotFormat = req.URL.Query().Get("format") gotBody, _ = io.ReadAll(req.Body) rw.WriteHeader(http.StatusOK) })) @@ -725,21 +725,20 @@ func TestUploadEdgeFunctions(t *testing.T) { apiCtx := context.WithAuthInfo(ctx, apiClient.BearerToken("token")) dir := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(dir, "edgecodesha.eszip"), romBody, 0644)) - manifest := `{ "bundles": [ { "asset": "edgecodesha.eszip", "format": "eszip2" } ] }` + require.NoError(t, os.WriteFile(filepath.Join(dir, "edgecodesha.tar.gz"), bundleBody, 0644)) + manifest := `{ "bundles": [ { "asset": "edgecodesha.tar.gz", "format": "tar" } ] }` require.NoError(t, os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(manifest), 0644)) files, err := bundleEdgeFunctions(gocontext.Background(), dir, mockObserver{}) require.NoError(t, err) - codeSha := files.Sums["eszip2"] + codeSha := files.Sums["tar"] d := &models.Deploy{ID: "deploy-id", RequiredEdgeFunctions: []string{codeSha}} require.NoError(t, client.uploadFiles(apiCtx, d, files, nil, edgeFunctionUpload, time.Minute, false)) require.Equal(t, "/api/v1/deploys/deploy-id/edge_functions/"+codeSha, gotPath) - require.Equal(t, "eszip2", gotFormat) - require.Equal(t, romBody, gotBody) + require.Equal(t, bundleBody, gotBody) } func TestBundle(t *testing.T) { diff --git a/swagger.yml b/swagger.yml index 8c0fb203..bd3d70ce 100644 --- a/swagger.yml +++ b/swagger.yml @@ -1680,12 +1680,6 @@ paths: type: string in: path required: true - - name: format - type: string - in: query - - name: size - type: integer - in: query - name: file_body in: body schema: @@ -4781,7 +4775,7 @@ definitions: items: type: string description: | - An array of code_shas for the edge-function ROM bundles that need to be uploaded to + An array of code_shas for the edge-function bundles that need to be uploaded to complete the deploy. error_message: type: string @@ -4885,7 +4879,7 @@ definitions: edge_functions: type: object description: | - A hash mapping edge-function ROM bundle formats to the code_sha of each bundle. The + A hash mapping edge-function bundle formats to the code_sha of each bundle. The response's required_edge_functions lists which of these still need to be uploaded. function_schedules: type: array