From a76b69c63f8b08cc6cd693257d16541882437ade Mon Sep 17 00:00:00 2001 From: Grant Mulitz Date: Mon, 1 Jun 2026 16:18:57 -0400 Subject: [PATCH 1/2] refactor archive package streaming --- cli/module_build.go | 82 ++++++++++++++++-------------------------- cli/module_registry.go | 48 +++++++++++++++++++++++++ go.mod | 2 ++ go.sum | 4 +-- 4 files changed, 83 insertions(+), 53 deletions(-) diff --git a/cli/module_build.go b/cli/module_build.go index 40aec474fba..6f9ca275a3e 100644 --- a/cli/module_build.go +++ b/cli/module_build.go @@ -921,17 +921,6 @@ func (c *viamClient) triggerCloudReloadBuild( archivePath, partID string, reloadUnixTS int64, ) (string, error) { - stream, err := c.buildClient.StartReloadBuild(ctx) - if err != nil { - return "", err - } - - //nolint:gosec - file, err := os.Open(archivePath) - if err != nil { - return "", err - } - part, err := c.getRobotPart(ctx, partID) if err != nil { return "", err @@ -939,21 +928,23 @@ func (c *viamClient) triggerCloudReloadBuild( if part.Part == nil { return "", fmt.Errorf("part with id=%s not found", partID) } - if part.Part.UserSuppliedInfo == nil { return "", errors.New("unable to determine platform for part") } - // use the primary org id for the machine as the reload - // module org + // use the primary org id for the machine as the reload module org orgID, err := c.getOrgIDForPart(ctx, part.Part) if err != nil { return "", err } - // App expects `BuildInfo` as the first request + moduleID, err := parseModuleID(manifest.ModuleID) + if err != nil { + return "", err + } + platform := part.Part.UserSuppliedInfo.Fields["platform"].GetStringValue() - req := &buildpb.StartReloadBuildRequest{ + buildInfoReq := &buildpb.StartReloadBuildRequest{ CloudBuild: &buildpb.StartReloadBuildRequest_BuildInfo{ BuildInfo: &buildpb.ReloadBuildInfo{ Platform: platform, @@ -964,53 +955,42 @@ func (c *viamClient) triggerCloudReloadBuild( }, } if args.Builder != "" && args.Builder != "default" { - req.Builder = &args.Builder - } - if err := stream.Send(req); err != nil { - return "", err + buildInfoReq.Builder = &args.Builder } - moduleID, err := parseModuleID(manifest.ModuleID) - if err != nil { - return "", err - } - - pkgInfo := v1.PackageInfo{ - OrganizationId: orgID, - Name: moduleID.name, - Version: getReloadVersion(reloadSourceVersionPrefix, partID, reloadUnixTS), - Type: v1.PackageType_PACKAGE_TYPE_MODULE, - } - reqInner := &v1.CreatePackageRequest{ - Package: &v1.CreatePackageRequest_Info{ - Info: &pkgInfo, - }, - } - req = &buildpb.StartReloadBuildRequest{ + pkgInfoReq := &buildpb.StartReloadBuildRequest{ CloudBuild: &buildpb.StartReloadBuildRequest_Package{ - Package: reqInner, + Package: &v1.CreatePackageRequest{ + Package: &v1.CreatePackageRequest_Info{ + Info: &v1.PackageInfo{ + OrganizationId: orgID, + Name: moduleID.name, + Version: getReloadVersion(reloadSourceVersionPrefix, partID, reloadUnixTS), + Type: v1.PackageType_PACKAGE_TYPE_MODULE, + }, + }, + }, }, } - if err := stream.Send(req); err != nil { + stream, err := c.buildClient.StartReloadBuild(ctx) + if err != nil { return "", err } - - var errs error - // Suppress the "Uploading... X%" progress bar output since we have our own spinner - if err := sendUploadRequests( - ctx, stream, file, io.Discard, getNextReloadBuildUploadRequest); err != nil && !errors.Is(err, io.EOF) { - errs = multierr.Combine(errs, errors.Wrapf(err, "could not upload %s", file.Name())) - } - - resp, closeErr := stream.CloseAndRecv() - if closeErr != nil && !errors.Is(closeErr, io.EOF) { - errs = multierr.Combine(errs, closeErr) + resp, err := streamArchiveBuild( + ctx, + stream, + archivePath, + []*buildpb.StartReloadBuildRequest{buildInfoReq, pkgInfoReq}, + getNextReloadBuildUploadRequest, + ) + if err != nil { + return "", err } if msg := resp.GetBuilderFallbackMessage(); msg != "" { printf(cmd.Root().ErrWriter, "Warning: %s", msg) } - return resp.GetBuildId(), errs + return resp.GetBuildId(), nil } func getNextReloadBuildUploadRequest(file *os.File) (*buildpb.StartReloadBuildRequest, int, error) { diff --git a/cli/module_registry.go b/cli/module_registry.go index 40d7d01230b..43ce67c8e15 100644 --- a/cli/module_registry.go +++ b/cli/module_registry.go @@ -1037,6 +1037,54 @@ type sender[RequestT any] interface { CloseSend() error } +// archiveBuildStream is a client-streaming RPC stream that sends N header +// requests followed by chunked archive bytes and returns a single response on +// close. Both StartReloadBuild and StartSourceUploadBuild satisfy this shape. +type archiveBuildStream[ReqT any, RespT any] interface { + Send(*ReqT) error + CloseSend() error + CloseAndRecv() (*RespT, error) +} + +// streamArchiveBuild opens archivePath, sends each header request in order, +// streams the file contents through chunkReq, and returns the typed response. +// Used by both reload (StartReloadBuild) and source-upload (StartSourceUploadBuild) +// flows; their differences are confined to the header/chunk constructors the +// callers pass in. +func streamArchiveBuild[ReqT any, RespT any, StreamT archiveBuildStream[ReqT, RespT]]( + ctx context.Context, + stream StreamT, + archivePath string, + headers []*ReqT, + chunkReq func(*os.File) (*ReqT, int, error), +) (*RespT, error) { + //nolint:gosec // archivePath is constructed by createGitArchive / createTarballForUpload + file, err := os.Open(archivePath) + if err != nil { + return nil, err + } + defer vutils.UncheckedErrorFunc(file.Close) + + for _, header := range headers { + if err := stream.Send(header); err != nil { + return nil, err + } + } + + var errs error + // Suppress the "Uploading... X%" progress bar from sendUploadRequests; callers + // drive their own progress UI (e.g. ProgressManager) around this helper. + if err := sendUploadRequests(ctx, stream, file, io.Discard, chunkReq); err != nil && !errors.Is(err, io.EOF) { + errs = multierr.Combine(errs, errors.Wrapf(err, "could not upload %s", file.Name())) + } + + resp, closeErr := stream.CloseAndRecv() + if closeErr != nil && !errors.Is(closeErr, io.EOF) { + errs = multierr.Combine(errs, closeErr) + } + return resp, errs +} + func sendUploadRequests[RequestT any, StreamT sender[RequestT]]( ctx context.Context, stream StreamT, diff --git a/go.mod b/go.mod index 45b9c5d02f9..31d1585850c 100644 --- a/go.mod +++ b/go.mod @@ -351,3 +351,5 @@ require ( github.com/ziutek/mymysql v1.5.4 // indirect golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 ) + +replace go.viam.com/api => github.com/viamrobotics/api v0.1.557-0.20260601174852-e7919fb0d0a9 // gmulitz-start-source-upload-build diff --git a/go.sum b/go.sum index 4a50ad7f762..df33fef9e29 100644 --- a/go.sum +++ b/go.sum @@ -1052,6 +1052,8 @@ github.com/viam-labs/go-getter v0.0.0-20251022162721-98d73b852c8a h1:qk5MX36oytF github.com/viam-labs/go-getter v0.0.0-20251022162721-98d73b852c8a/go.mod h1:MwPm+Hpd8PZRx+jkOVLGxqHl/bN+Hcsw0dD7ntSpLy0= github.com/viam-labs/motion-tools v1.9.0 h1:oUcxVMdrwVS7PXkWVYsUJOXkh0aTydmFlK/rciFcGxw= github.com/viam-labs/motion-tools v1.9.0/go.mod h1:unpRaVV4ETuJVoHa/3pzknoTnxqjSpfUMewE1vU27BA= +github.com/viamrobotics/api v0.1.557-0.20260601174852-e7919fb0d0a9 h1:nHeaVd/rqQLI3cUE6WcUmsf9qpITZJneee67qULqxH8= +github.com/viamrobotics/api v0.1.557-0.20260601174852-e7919fb0d0a9/go.mod h1:nVe4WXrtc8aupJ8OWXSYx6KhCiOkr3VCbkwxD4D41xQ= github.com/viamrobotics/evdev v0.1.3 h1:mR4HFafvbc5Wx4Vp1AUJp6/aITfVx9AKyXWx+rWjpfc= github.com/viamrobotics/evdev v0.1.3/go.mod h1:N6nuZmPz7HEIpM7esNWwLxbYzqWqLSZkfI/1Sccckqk= github.com/viamrobotics/ice/v2 v2.3.40 h1:H9r4ztsKkxWSn42R4fLvYlaPtpBys1Yj7/MERBtZY0k= @@ -1158,8 +1160,6 @@ go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= go.uber.org/zap v1.18.1/go.mod h1:xg/QME4nWcxGxrpdeYfq7UvYrLh66cuVKdrbD1XF/NI= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.viam.com/api v0.1.555 h1:ncBlAwDpEk658aouQLoeq1RLDqdi3spWVFN0y6nsvXM= -go.viam.com/api v0.1.555/go.mod h1:nVe4WXrtc8aupJ8OWXSYx6KhCiOkr3VCbkwxD4D41xQ= go.viam.com/test v1.2.4 h1:JYgZhsuGAQ8sL9jWkziAXN9VJJiKbjoi9BsO33TW3ug= go.viam.com/test v1.2.4/go.mod h1:zI2xzosHdqXAJ/kFqcN+OIF78kQuTV2nIhGZ8EzvaJI= go.viam.com/utils v0.6.1 h1:xJhq+S2ADMTDj9538blmhI0otZCRAZUonRBkb+zHIHo= From 39543bff1e2b5ea9329a2b5175fa33569a700d44 Mon Sep 17 00:00:00 2001 From: Grant Mulitz Date: Mon, 1 Jun 2026 17:04:03 -0400 Subject: [PATCH 2/2] add module source build handlers and cli command --- cli/app.go | 56 +++++ cli/module_build.go | 262 +++++++++++++++++++++++ cli/module_build_test.go | 154 +++++++++++++ testutils/inject/build_service_client.go | 14 ++ 4 files changed, 486 insertions(+) diff --git a/cli/app.go b/cli/app.go index 331e296402a..f6a384943a5 100644 --- a/cli/app.go +++ b/cli/app.go @@ -3645,6 +3645,62 @@ Example: }, Action: createActionCommandWithT[moduleBuildLocalArgs](ModuleBuildLocalAction), }, + { + Name: "cloud", + Usage: "upload your local source and publish a new version via cloud build", + Description: `Upload the contents of a local directory to the cloud builder and publish +a new registry version of your module from it, without requiring a git push. + +Example: +viam module build cloud --version 0.5.0 +viam module build cloud --version 0.5.0 --platforms linux/amd64,linux/arm64 --wait + +Honors .gitignore when packaging the source directory. Requires a "build" section +in meta.json (same as 'viam module build start').`, + UsageText: createUsageText("module build cloud", []string{generalFlagVersion}, true, false), + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: moduleFlagPath, + Usage: "path to meta.json", + Value: "./meta.json", + TakesFile: true, + }, + &cli.StringFlag{ + Name: generalFlagVersion, + Usage: "version of the module to publish (semver2.0) ex: \"0.1.0\"", + Required: true, + }, + &cli.StringFlag{ + Name: generalFlagPath, + Usage: "path to the local source directory to upload", + Value: ".", + TakesFile: true, + }, + &cli.StringSliceFlag{ + Name: moduleBuildFlagPlatforms, + Usage: "list of platforms to build, e.g. linux/amd64,linux/arm64 (default: build.arch in meta.json)", + }, + &cli.StringFlag{ + Name: moduleBuildFlagWorkdir, + Usage: "use this to indicate that your meta.json is in a subdirectory of the uploaded source", + Value: ".", + }, + &cli.StringFlag{ + Name: moduleBuildFlagBuilder, + Usage: formatAcceptedValues("target build service", "default", "viam-cloudbuild-test"), + Value: "default", + }, + &cli.BoolFlag{ + Name: moduleBuildFlagWait, + Usage: "wait for the build to finish; surface failed-platform logs and a non-zero exit code on failure", + }, + &cli.BoolFlag{ + Name: generalFlagNoProgress, + Usage: "hide the progress spinner", + }, + }, + Action: createActionCommandWithT[moduleBuildCloudArgs](ModuleBuildCloudAction), + }, { Name: "start", Usage: "start a remote build", diff --git a/cli/module_build.go b/cli/module_build.go index 6f9ca275a3e..ed49f0e5e80 100644 --- a/cli/module_build.go +++ b/cli/module_build.go @@ -1006,6 +1006,268 @@ func getNextReloadBuildUploadRequest(file *os.File) (*buildpb.StartReloadBuildRe }, byteLen, nil } +type moduleBuildCloudArgs struct { + Module string + Version string + Path string + Platforms []string + Workdir string + Builder string + Wait bool + NoProgress bool +} + +// ModuleBuildCloudAction uploads the local source directory and starts a cloud +// build that publishes a new registry version of the module. +func ModuleBuildCloudAction(ctx context.Context, cmd *cli.Command, args moduleBuildCloudArgs) error { + c, err := newViamClient(ctx, cmd) + if err != nil { + return err + } + _, err = c.moduleBuildCloudAction(ctx, cmd, args) + return err +} + +func (c *viamClient) moduleBuildCloudAction( + ctx context.Context, cmd *cli.Command, args moduleBuildCloudArgs, +) (string, error) { + manifest, err := loadManifest(args.Module) + if err != nil { + return "", err + } + if manifest.Build == nil || manifest.Build.Build == "" { + return "", errors.New( + "your meta.json cannot have an empty build step. See 'viam module build --help' for more information") + } + + moduleID, err := parseModuleID(manifest.ModuleID) + if err != nil { + return "", err + } + org, err := getOrgByModuleIDPrefix(ctx, c, moduleID.prefix) + if err != nil { + return "", err + } + + var platforms []string + switch { + case len(args.Platforms) > 0: + platforms = args.Platforms + case len(manifest.Build.Arch) > 0: + platforms = manifest.Build.Arch + default: + platforms = defaultBuildInfo.Arch + } + + // Cloud build cannot currently build Python modules for Windows targets. + // Check the resolved target platforms — a Windows developer + // is free to cloud-build a Python module for linux/darwin, and a macOS/Linux + // developer requesting a windows/* target should be caught here regardless. + if targetsWindowsPython(args.Module, platforms) { + return "", errors.New("cloud build is not currently supported for Windows Python modules.\n" + + "Build locally with 'viam module build local' and upload with 'viam module upload'") + } + + // Clean the version argument to match github tag conventions, mirroring `module build start`. + version := strings.TrimPrefix(args.Version, "v") + + sourcePath := args.Path + if sourcePath == "" { + sourcePath = "." + } + + pm := newModuleBuildCloudProgressManager(args) + defer pm.Stop() + + if err := pm.Start("prepare"); err != nil { + return "", err + } + if err := pm.Start("archive"); err != nil { + return "", err + } + archivePath, err := c.createGitArchive(sourcePath) + if err != nil { + _ = pm.FailWithMessage("archive", "Archive creation failed") //nolint:errcheck + _ = pm.FailWithMessage("prepare", "Preparing for build...") //nolint:errcheck + return "", err + } + defer func() { + if removeErr := os.Remove(archivePath); removeErr != nil && !os.IsNotExist(removeErr) { + warningf(cmd.Root().ErrWriter, "failed to delete archive at %s", archivePath) + } + }() + if err := pm.Complete("archive"); err != nil { + return "", err + } + + if err := pm.Start("upload-source"); err != nil { + return "", err + } + buildID, err := c.uploadModuleSourceBuild( + ctx, cmd, args, manifest, archivePath, org.GetId(), moduleID, version, platforms, + ) + if err != nil { + _ = pm.FailWithMessage("upload-source", "Upload failed") //nolint:errcheck + _ = pm.FailWithMessage("prepare", "Preparing for build...") //nolint:errcheck + return "", err + } + if err := pm.Complete("upload-source"); err != nil { + return buildID, err + } + if err := pm.Complete("prepare"); err != nil { + return buildID, err + } + + // Match `module build start`: buildID alone on stdout (for scripts/build-action), + // follow-up instructions on stderr. + printf(cmd.Root().ErrWriter, "Build started, follow the logs with:") + printf(cmd.Root().ErrWriter, " viam module build logs --id %s", buildID) + printf(cmd.Root().Writer, buildID) + + if !args.Wait { + return buildID, nil + } + + if err := pm.Start("build"); err != nil { + return buildID, err + } + if err := pm.Start("build-start"); err != nil { + return buildID, err + } + statuses, err := c.waitForBuildToFinish(ctx, buildID, "", pm) + if err != nil { + _ = pm.FailWithMessage("build", "Building...") //nolint:errcheck + return buildID, err + } + if buildErr := buildError(statuses); buildErr != nil { + _ = pm.FailWithMessage("build", "Building...") //nolint:errcheck + // Surface logs for any failed platform so the user can debug without a separate command. + var logErrs error + for platform, status := range statuses { + if status != jobStatusFailed { + continue + } + errorf(cmd.Root().ErrWriter, "Build %q failed on %s. Logs:", buildID, platform) + if logErr := c.printModuleBuildLogs(ctx, buildID, platform); logErr != nil { + logErrs = multierr.Append(logErrs, logErr) + } + } + return buildID, multierr.Combine(buildErr, logErrs) + } + if err := pm.Complete("build"); err != nil { + return buildID, err + } + infof(cmd.Root().ErrWriter, "Module version %s published successfully", version) + return buildID, nil +} + +// targetsWindowsPython returns true when the module appears to be a Python +// module (src/main.py exists next to meta.json) AND any of the requested target +// platforms is windows. The src/main.py marker matches the layout produced by +// `viam module generate` for Python modules. +func targetsWindowsPython(manifestPath string, platforms []string) bool { + hasWindowsTarget := false + for _, p := range platforms { + if p == osWindows || strings.HasPrefix(p, osWindows+"/") { + hasWindowsTarget = true + break + } + } + if !hasWindowsTarget { + return false + } + mainPyPath := filepath.Join(filepath.Dir(manifestPath), "src", "main.py") + _, err := os.Stat(mainPyPath) + return err == nil +} + +func newModuleBuildCloudProgressManager(args moduleBuildCloudArgs) *ProgressManager { + steps := []*Step{ + {ID: "prepare", Message: "Preparing for build...", CompletedMsg: "Prepared for build", IndentLevel: 0}, + {ID: "archive", Message: "Creating source code archive...", CompletedMsg: "Source code archive created", IndentLevel: 1}, + {ID: "upload-source", Message: "Uploading source code...", CompletedMsg: "Source code uploaded", IndentLevel: 1}, + } + if args.Wait { + steps = append(steps, + &Step{ID: "build", Message: "Building...", CompletedMsg: "Built", IndentLevel: 0}, + &Step{ID: "build-start", Message: "Starting build...", IndentLevel: 1}, + ) + } + return NewProgressManager(steps, WithProgressOutput(!args.NoProgress)) +} + +func (c *viamClient) uploadModuleSourceBuild( + ctx context.Context, + cmd *cli.Command, + args moduleBuildCloudArgs, + manifest ModuleManifest, + archivePath, orgID string, + moduleID moduleID, + version string, + platforms []string, +) (string, error) { + buildInfoReq := &buildpb.StartSourceUploadBuildRequest{ + CloudBuild: &buildpb.StartSourceUploadBuildRequest_BuildInfo{ + BuildInfo: &buildpb.SourceUploadBuildInfo{ + Platforms: platforms, + Workdir: &args.Workdir, + ModuleId: manifest.ModuleID, + Distro: &manifest.Build.Distro, + }, + }, + ModuleVersion: version, + } + if args.Builder != "" && args.Builder != "default" { + buildInfoReq.Builder = &args.Builder + } + + pkgInfoReq := &buildpb.StartSourceUploadBuildRequest{ + CloudBuild: &buildpb.StartSourceUploadBuildRequest_Package{ + Package: &v1.CreatePackageRequest{ + Package: &v1.CreatePackageRequest_Info{ + Info: &v1.PackageInfo{ + OrganizationId: orgID, + Name: moduleID.name, + Version: version, + Type: v1.PackageType_PACKAGE_TYPE_MODULE, + }, + }, + }, + }, + } + + stream, err := c.buildClient.StartSourceUploadBuild(ctx) + if err != nil { + return "", err + } + resp, err := streamArchiveBuild( + ctx, + stream, + archivePath, + []*buildpb.StartSourceUploadBuildRequest{buildInfoReq, pkgInfoReq}, + getNextSourceUploadBuildRequest, + ) + if err != nil { + return "", err + } + if msg := resp.GetBuilderFallbackMessage(); msg != "" { + printf(cmd.Root().ErrWriter, "Warning: %s", msg) + } + return resp.GetBuildId(), nil +} + +func getNextSourceUploadBuildRequest(file *os.File) (*buildpb.StartSourceUploadBuildRequest, int, error) { + packagesRequest, byteLen, err := getNextPackageUploadRequest(file) + if err != nil { + return nil, 0, err + } + return &buildpb.StartSourceUploadBuildRequest{ + CloudBuild: &buildpb.StartSourceUploadBuildRequest_Package{ + Package: packagesRequest, + }, + }, byteLen, nil +} + // moduleCloudBuildInfo contains information needed to download a cloud build artifact. type moduleCloudBuildInfo struct { ModuleID string diff --git a/cli/module_build_test.go b/cli/module_build_test.go index 62299f95be3..746678d23a4 100644 --- a/cli/module_build_test.go +++ b/cli/module_build_test.go @@ -15,6 +15,7 @@ import ( "time" v1 "go.viam.com/api/app/build/v1" + packagespb "go.viam.com/api/app/packages/v1" apppb "go.viam.com/api/app/v1" "go.viam.com/test" "google.golang.org/grpc" @@ -155,6 +156,159 @@ func TestStartBuild(t *testing.T) { test.That(t, errOut.messages, test.ShouldHaveLength, 0) } +// fakeSourceUploadBuildStream is an in-memory test double for the +// buildpb.BuildService_StartSourceUploadBuildClient streaming RPC. It records +// every Send call so tests can assert on the request shape, and returns a +// configurable CloseAndRecv response. +type fakeSourceUploadBuildStream struct { + v1.BuildService_StartSourceUploadBuildClient // embedded for unused methods + sends []*v1.StartSourceUploadBuildRequest + resp *v1.StartSourceUploadBuildResponse +} + +func (s *fakeSourceUploadBuildStream) Send(req *v1.StartSourceUploadBuildRequest) error { + s.sends = append(s.sends, req) + return nil +} + +func (s *fakeSourceUploadBuildStream) CloseSend() error { return nil } + +func (s *fakeSourceUploadBuildStream) CloseAndRecv() (*v1.StartSourceUploadBuildResponse, error) { + return s.resp, nil +} + +func TestModuleBuildCloud(t *testing.T) { + // Lay out a source directory with a meta.json and a small "source" file so + // createGitArchive has something non-trivial to package up. + sourceDir := t.TempDir() + manifestPath := filepath.Join(sourceDir, "meta.json") + createTestManifest(t, manifestPath, map[string]any{ + "build": map[string]any{ + "setup": "./setup.sh", + "build": "make build", + "path": "module", + "arch": []any{"linux/amd64", "linux/arm64"}, + "distro": "bookworm", + }, + }) + err := os.WriteFile(filepath.Join(sourceDir, "hello.go"), []byte("package main\n"), 0o600) + test.That(t, err, test.ShouldBeNil) + + // Stub ListOrganizations so getOrgByModuleIDPrefix resolves the "test" + // public namespace from the manifest's module_id ("test:test"). + asc := &inject.AppServiceClient{ + ListOrganizationsFunc: func( + ctx context.Context, in *apppb.ListOrganizationsRequest, opts ...grpc.CallOption, + ) (*apppb.ListOrganizationsResponse, error) { + return &apppb.ListOrganizationsResponse{Organizations: []*apppb.Organization{ + {Id: "test-org-id", PublicNamespace: "test"}, + }}, nil + }, + } + + stream := &fakeSourceUploadBuildStream{ + resp: &v1.StartSourceUploadBuildResponse{BuildId: "build-xyz"}, + } + bsc := &inject.BuildServiceClient{ + StartSourceUploadBuildFunc: func( + ctx context.Context, opts ...grpc.CallOption, + ) (v1.BuildService_StartSourceUploadBuildClient, error) { + return stream, nil + }, + } + + cCtx, ac, out, errOut := setup(asc, nil, bsc, map[string]any{ + moduleFlagPath: manifestPath, + generalFlagVersion: "v1.2.3", // leading "v" should be stripped + generalFlagPath: sourceDir, + generalFlagNoProgress: true, + moduleBuildFlagWorkdir: ".", + }, "token") + + buildID, err := ac.moduleBuildCloudAction( + context.Background(), cCtx, parseStructFromCtx[moduleBuildCloudArgs](cCtx), + ) + test.That(t, err, test.ShouldBeNil) + test.That(t, buildID, test.ShouldEqual, "build-xyz") + + // At minimum we expect: 1 BuildInfo header + 1 Package_Info header + at + // least 1 chunk send (hello.go + meta.json should fit in a single chunk). + test.That(t, len(stream.sends), test.ShouldBeGreaterThanOrEqualTo, 3) + + // First send: BuildInfo with platforms / workdir / module_id / distro plus + // the top-level ModuleVersion (semver, no leading "v"). + first := stream.sends[0] + test.That(t, first.GetBuildInfo(), test.ShouldNotBeNil) + test.That(t, first.GetBuildInfo().GetPlatforms(), test.ShouldResemble, []string{"linux/amd64", "linux/arm64"}) + test.That(t, first.GetBuildInfo().GetModuleId(), test.ShouldEqual, "test:test") + test.That(t, first.GetBuildInfo().GetWorkdir(), test.ShouldEqual, ".") + test.That(t, first.GetBuildInfo().GetDistro(), test.ShouldEqual, "bookworm") + test.That(t, first.GetModuleVersion(), test.ShouldEqual, "1.2.3") + + // Second send: Package_Info with org/name/version/type. The version on the + // package mirrors the top-level ModuleVersion. + second := stream.sends[1] + test.That(t, second.GetPackage(), test.ShouldNotBeNil) + info := second.GetPackage().GetInfo() + test.That(t, info, test.ShouldNotBeNil) + test.That(t, info.GetOrganizationId(), test.ShouldEqual, "test-org-id") + test.That(t, info.GetName(), test.ShouldEqual, "test") + test.That(t, info.GetVersion(), test.ShouldEqual, "1.2.3") + test.That(t, info.GetType(), test.ShouldEqual, packagespb.PackageType_PACKAGE_TYPE_MODULE) + + // Remaining sends are chunked tarball contents. + for _, req := range stream.sends[2:] { + pkg := req.GetPackage() + test.That(t, pkg, test.ShouldNotBeNil) + test.That(t, pkg.GetContents(), test.ShouldNotBeNil) + } + + // Stdout: just the buildID (machine-readable). Stderr: human-readable + // follow-up instructions matching `module build start`. + test.That(t, out.messages, test.ShouldResemble, []string{"build-xyz\n"}) + test.That(t, errOut.messages, test.ShouldHaveLength, 2) + test.That(t, errOut.messages[0], test.ShouldEqual, "Build started, follow the logs with:\n") + test.That(t, errOut.messages[1], test.ShouldEqual, "\tviam module build logs --id build-xyz\n") +} + +func TestTargetsWindowsPython(t *testing.T) { + withPyDir := func(t *testing.T) string { + t.Helper() + dir := t.TempDir() + test.That(t, os.MkdirAll(filepath.Join(dir, "src"), 0o700), test.ShouldBeNil) + test.That(t, + os.WriteFile(filepath.Join(dir, "src", "main.py"), []byte("pass\n"), 0o600), + test.ShouldBeNil, + ) + return filepath.Join(dir, "meta.json") + } + withoutPyDir := func(t *testing.T) string { + t.Helper() + return filepath.Join(t.TempDir(), "meta.json") + } + + cases := []struct { + name string + manifest func(*testing.T) string + platforms []string + want bool + }{ + {"windows target + python module → block", withPyDir, []string{"windows/amd64"}, true}, + {"any-arch windows target + python module → block", withPyDir, []string{"windows"}, true}, + {"windows in mixed targets + python → block", withPyDir, []string{"linux/amd64", "windows/arm64"}, true}, + {"non-windows targets + python → allow", withPyDir, []string{"linux/amd64", "darwin/arm64"}, false}, + {"windows target without python module → allow", withoutPyDir, []string{"windows/amd64"}, false}, + {"non-windows + no python → allow", withoutPyDir, []string{"linux/amd64"}, false}, + {"linux-prefixed (not 'windows/' prefix) → allow", withPyDir, []string{"linux/any"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := targetsWindowsPython(tc.manifest(t), tc.platforms) + test.That(t, got, test.ShouldEqual, tc.want) + }) + } +} + func TestListBuild(t *testing.T) { manifest := filepath.Join(t.TempDir(), "meta.json") createTestManifest(t, manifest, nil) diff --git a/testutils/inject/build_service_client.go b/testutils/inject/build_service_client.go index e23d83f0dc7..a5796ea7d99 100644 --- a/testutils/inject/build_service_client.go +++ b/testutils/inject/build_service_client.go @@ -12,6 +12,9 @@ type BuildServiceClient struct { buildpb.BuildServiceClient ListJobsFunc func(ctx context.Context, in *buildpb.ListJobsRequest, opts ...grpc.CallOption) (*buildpb.ListJobsResponse, error) StartBuildFunc func(ctx context.Context, in *buildpb.StartBuildRequest, opts ...grpc.CallOption) (*buildpb.StartBuildResponse, error) + StartSourceUploadBuildFunc func( + ctx context.Context, opts ...grpc.CallOption, + ) (buildpb.BuildService_StartSourceUploadBuildClient, error) } // ListJobs calls the injected ListJobsFunc or the real version. @@ -33,3 +36,14 @@ func (bsc *BuildServiceClient) StartBuild(ctx context.Context, in *buildpb.Start } return bsc.StartBuildFunc(ctx, in, opts...) } + +// StartSourceUploadBuild calls the injected StartSourceUploadBuildFunc or the +// real version. Tests can return a fake stream that captures Send calls. +func (bsc *BuildServiceClient) StartSourceUploadBuild( + ctx context.Context, opts ...grpc.CallOption, +) (buildpb.BuildService_StartSourceUploadBuildClient, error) { + if bsc.StartSourceUploadBuildFunc == nil { + return bsc.BuildServiceClient.StartSourceUploadBuild(ctx, opts...) + } + return bsc.StartSourceUploadBuildFunc(ctx, opts...) +}