diff --git a/go.mod b/go.mod index 2cb3a24..3ae5b80 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26.3 require ( github.com/GoCodeAlone/workflow v0.27.0 - github.com/GoCodeAlone/workflow-compute v0.0.0-20260521144416-437084114563 + github.com/GoCodeAlone/workflow-compute v0.0.0-20260522025546-cb71be9e8c17 ) require ( diff --git a/go.sum b/go.sum index 27da39c..ee2723d 100644 --- a/go.sum +++ b/go.sum @@ -52,6 +52,8 @@ github.com/GoCodeAlone/workflow-compute v0.0.0-20260521003202-dc0b7c828ec9 h1:EJ github.com/GoCodeAlone/workflow-compute v0.0.0-20260521003202-dc0b7c828ec9/go.mod h1:m1GFY/28DcdOp2ok+tTlvqnJqNYhWk5cwJs/8zUFMh4= github.com/GoCodeAlone/workflow-compute v0.0.0-20260521144416-437084114563 h1:4RvLQWTWT0UaRLF9BqMSnOtzUP/+MxDZcgeJ6N5B93o= github.com/GoCodeAlone/workflow-compute v0.0.0-20260521144416-437084114563/go.mod h1:m1GFY/28DcdOp2ok+tTlvqnJqNYhWk5cwJs/8zUFMh4= +github.com/GoCodeAlone/workflow-compute v0.0.0-20260522025546-cb71be9e8c17 h1:LCf5NwrOQ9uAXbsHYROqDEl1Luu/2St1ZF+EoBfjOdg= +github.com/GoCodeAlone/workflow-compute v0.0.0-20260522025546-cb71be9e8c17/go.mod h1:m1GFY/28DcdOp2ok+tTlvqnJqNYhWk5cwJs/8zUFMh4= github.com/GoCodeAlone/yaegi v0.17.2 h1:WK6Y6e0t1a6U7r+S2dN3CGWW1PizYD3zO0zneToZPxM= github.com/GoCodeAlone/yaegi v0.17.2/go.mod h1:z5Pr6Wse6QJcQvpgxTxzMAevFarH0N37TG88Y9dprx0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.32.0 h1:rIkQfkCOVKc1OiRCNcSDD8ml5RJlZbH/Xsq7lbpynwc= diff --git a/internal/cli.go b/internal/cli.go index 9f81d54..4a82786 100644 --- a/internal/cli.go +++ b/internal/cli.go @@ -301,6 +301,7 @@ func (c *computeCLI) runSubmitProductCapture(ctx context.Context, args []string) providerVersion := fs.String("provider-version", "v1.0.0", "provider contract version") providerConfigRef := fs.String("provider-config-ref", "", "provider config ref") providerOperation := fs.String("provider-operation", "capture_product", "provider operation") + providerImageRef := fs.String("provider-image-ref", "", "digest-pinned provider runtime image ref") fs.Var(&allowedHosts, "allowed-host", "allowed URL host; repeatable or comma-separated") if err := fs.Parse(args); err != nil { return err @@ -314,6 +315,9 @@ func (c *computeCLI) runSubmitProductCapture(ctx context.Context, args []string) if *providerConfigRef == "" { *providerConfigRef = "config://network-products/" + *productID + "/browser" } + if err := validateProviderImageRef(*providerImageRef); err != nil { + return fmt.Errorf("--provider-image-ref: %w", err) + } input := productCaptureProviderInput{ URL: *productURL, AllowedHosts: allowedHosts.values(), @@ -338,6 +342,7 @@ func (c *computeCLI) runSubmitProductCapture(ctx context.Context, args []string) ConfigRef: *providerConfigRef, }, Operation: *providerOperation, + ImageRef: *providerImageRef, Input: inputBytes, }, } diff --git a/internal/cli_test.go b/internal/cli_test.go index ac90d1d..b4063b4 100644 --- a/internal/cli_test.go +++ b/internal/cli_test.go @@ -173,6 +173,7 @@ func TestV739_CLISubmitProductCapture(t *testing.T) { "--org", "org-1", "--pool", "pool-1", "--url", "https://www.amazon.com/Microsoft-Xbox-Gaming-Console-video-game/dp/B08H75RTZ8", + "--provider-image-ref", testProviderImageRef, "--allowed-host", "www.amazon.com", "--capture-mode", "browser", "--capture-timeout", "45", @@ -192,7 +193,8 @@ func TestV739_CLISubmitProductCapture(t *testing.T) { if got.Workload.Provider.ProviderConfig.PluginID != "workflow-plugin-product-capture" || got.Workload.Provider.ProviderConfig.ProviderID != "browser" || got.Workload.Provider.ProviderConfig.ContractID != "product-capture.browser.v1" || - got.Workload.Provider.Operation != "capture_product" { + got.Workload.Provider.Operation != "capture_product" || + got.Workload.Provider.ImageRef != testProviderImageRef { t.Fatalf("provider task: %+v", got.Workload.Provider) } if !bytes.Contains(got.Workload.Provider.Input, []byte(`"allowed_hosts":["www.amazon.com"]`)) { diff --git a/internal/steps.go b/internal/steps.go index d91d1b2..1d48674 100644 --- a/internal/steps.go +++ b/internal/steps.go @@ -297,6 +297,7 @@ type productCaptureStepConfig struct { ProviderConfigRef string `json:"provider_config_ref,omitempty"` ProviderConfigDigest string `json:"provider_config_digest,omitempty"` ProviderOperation string `json:"provider_operation,omitempty"` + ProviderImageRef string `json:"provider_image_ref"` URL string `json:"url,omitempty"` URLField string `json:"url_field,omitempty"` AllowedHosts []string `json:"allowed_hosts"` @@ -332,6 +333,9 @@ func newProductCaptureStep(name string, raw map[string]any) (*productCaptureStep if len(cfg.AllowedHosts) == 0 { return nil, fmt.Errorf("step.compute_product_capture %q: allowed_hosts is required", name) } + if err := validateProviderImageRef(cfg.ProviderImageRef); err != nil { + return nil, fmt.Errorf("step.compute_product_capture %q: provider_image_ref: %w", name, err) + } if cfg.PollInterval != "" { if d, err := time.ParseDuration(cfg.PollInterval); err != nil { return nil, fmt.Errorf("step.compute_product_capture %q: poll_interval must be duration: %w", name, err) @@ -349,6 +353,32 @@ func newProductCaptureStep(name string, raw map[string]any) (*productCaptureStep return &productCaptureStep{name: name, config: cfg}, nil } +func validateProviderImageRef(value string) error { + if strings.TrimSpace(value) == "" { + return errors.New("is required") + } + if strings.TrimSpace(value) != value || strings.ContainsAny(value, "\t\r\n \x00") { + return errors.New("must not contain whitespace or NUL") + } + _, digest, ok := strings.Cut(value, "@") + if !ok || !validSHA256Digest(digest) { + return errors.New("must be digest-pinned with @sha256:<64 hex>") + } + return nil +} + +func validSHA256Digest(value string) bool { + if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") { + return false + } + for _, r := range value[len("sha256:"):] { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') { + return false + } + } + return true +} + func (s *productCaptureStep) Execute(ctx context.Context, _ map[string]any, _ map[string]map[string]any, current map[string]any, metadata map[string]any, runtimeConfig map[string]any) (*sdk.StepResult, error) { url := s.config.URL if url == "" { @@ -383,6 +413,7 @@ func (s *productCaptureStep) Execute(ctx context.Context, _ map[string]any, _ ma Provider: &protocol.ProviderWorkload{ ProviderConfig: s.productCaptureProviderConfig(), Operation: s.productCaptureProviderOperation(), + ImageRef: s.config.ProviderImageRef, Input: inputBytes, }, } diff --git a/internal/steps_test.go b/internal/steps_test.go index 952a416..12c4e15 100644 --- a/internal/steps_test.go +++ b/internal/steps_test.go @@ -13,6 +13,8 @@ import ( "github.com/GoCodeAlone/workflow-compute/pkg/protocol" ) +const testProviderImageRef = "ghcr.io/gocodealone/product-capture-browser@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + func TestStepTypes(t *testing.T) { steps := NewPlugin().(interface{ StepTypes() []string }) got := steps.StepTypes() @@ -135,6 +137,9 @@ func TestDispatchStepAcceptsProviderWorkload(t *testing.T) { if got.Workload.Provider.Operation != "capture_product" { t.Fatalf("operation: %q", got.Workload.Provider.Operation) } + if got.Workload.Provider.ImageRef != testProviderImageRef { + t.Fatalf("image ref: %q", got.Workload.Provider.ImageRef) + } if !strings.Contains(string(got.Workload.Provider.Input), `"url":"https://www.amazon.com/Microsoft-Xbox-Gaming-Console-video-game/dp/B08H75RTZ8"`) { t.Fatalf("provider input: %s", got.Workload.Provider.Input) } @@ -199,6 +204,7 @@ func TestProductCaptureStepDispatchesDynamicURLAndReturnsPreview(t *testing.T) { "timeout_seconds": 90, "url_field": "url", "allowed_hosts": []any{"www.amazon.com", "amazon.com"}, + "provider_image_ref": testProviderImageRef, "capture_timeout_seconds": 45, "max_html_bytes": 1 << 20, "max_image_count": 8, @@ -229,6 +235,9 @@ func TestProductCaptureStepDispatchesDynamicURLAndReturnsPreview(t *testing.T) { if submitted.Workload.Provider.Operation != "capture_product" { t.Fatalf("operation: %q", submitted.Workload.Provider.Operation) } + if submitted.Workload.Provider.ImageRef != testProviderImageRef { + t.Fatalf("image ref: %q", submitted.Workload.Provider.ImageRef) + } if !strings.Contains(string(submitted.Workload.Provider.Input), `"url":"https://www.amazon.com/dp/B0DL7CKRJ5?th=1"`) { t.Fatalf("provider input: %s", submitted.Workload.Provider.Input) } @@ -244,6 +253,7 @@ func TestProductCaptureStepRejectsUnknownConfig(t *testing.T) { cfg := productCaptureConfigMap("https://compute.example.test") cfg["url_field"] = "url" cfg["allowed_hosts"] = []any{"www.amazon.com"} + cfg["provider_image_ref"] = testProviderImageRef cfg["unknown"] = true delete(cfg, "workload") if _, err := newProductCaptureStep("capture", cfg); err == nil { @@ -255,6 +265,7 @@ func TestProductCaptureStepAcceptsWorkflowInternalConfigDir(t *testing.T) { cfg := productCaptureConfigMap("https://compute.example.test") cfg["url_field"] = "url" cfg["allowed_hosts"] = []any{"www.amazon.com"} + cfg["provider_image_ref"] = testProviderImageRef cfg["_config_dir"] = "/app" delete(cfg, "workload") if _, err := newProductCaptureStep("capture", cfg); err != nil { @@ -933,6 +944,7 @@ func productCaptureConfigMap(serverURL string) map[string]any { "config_ref": "config://network-products/bmw-product-capture/browser", }, "operation": "capture_product", + "image_ref": testProviderImageRef, "input": map[string]any{ "url": "https://www.amazon.com/Microsoft-Xbox-Gaming-Console-video-game/dp/B08H75RTZ8", "allowed_hosts": []any{"www.amazon.com"},