diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5234c838..50dde4807 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,6 +18,23 @@ Dalec has adopted the CNCF Code of Conduct. Refer to our [Community Code of Cond When you submit a pull request, a CLA bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA. +## Development + +### Code Generation + +Some files in this project are automatically generated. If you modify struct definitions (especially `Spec` or `Source`), you may need to regenerate the corresponding code: + +```bash +# Regenerate all generated files +go generate ./... + +# Or regenerate specific files +go generate ./spec.go # Regenerates spec_resolve_generated.go +go generate ./source.go # Regenerates source_generated.go +``` + +See [docs/code-generation.md](docs/code-generation.md) for more details about the code generation system. + ## Issue and pull request management Anyone can comment on issues and submit reviews for pull requests. In order to be assigned an issue or pull request, you can leave a `/assign ` comment on the issue or pull request. diff --git a/cmd/gen-resolve/main.go b/cmd/gen-resolve/main.go new file mode 100644 index 000000000..e740a16ba --- /dev/null +++ b/cmd/gen-resolve/main.go @@ -0,0 +1,272 @@ +package main + +import ( + "bytes" + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/token" + "os" + "sort" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) + os.Exit(1) + } + + outputFile := os.Args[1] + + // Parse the source files to extract struct information + specFields, targetFields, err := extractStructFields() + if err != nil { + fmt.Fprintf(os.Stderr, "Error extracting struct fields: %v\n", err) + os.Exit(1) + } + + // Generate the code + code, err := generateResolveMethod(specFields, targetFields) + if err != nil { + fmt.Fprintf(os.Stderr, "Error generating code: %v\n", err) + os.Exit(1) + } + + // Write to output file + err = os.WriteFile(outputFile, code, 0644) + if err != nil { + fmt.Fprintf(os.Stderr, "Error writing output file: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Generated Resolve method in %s\n", outputFile) +} + +type FieldInfo struct { + Name string // Field name (e.g. "Name") + TypeName string // Type name (e.g. "string") + IsSlice bool // Is it a slice type + IsMap bool // Is it a map type + IsPtr bool // Is it a pointer type +} + +func extractStructFields() ([]FieldInfo, []FieldInfo, error) { + // Parse spec.go for Spec struct + specFields, err := parseStructFromFile("spec.go", "Spec") + if err != nil { + return nil, nil, fmt.Errorf("failed to extract Spec fields: %w", err) + } + + // Parse target.go for Target struct + targetFields, err := parseStructFromFile("target.go", "Target") + if err != nil { + return nil, nil, fmt.Errorf("failed to extract Target fields: %w", err) + } + + return specFields, targetFields, nil +} + +func parseStructFromFile(filename, structName string) ([]FieldInfo, error) { + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, filename, nil, parser.ParseComments) + if err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", filename, err) + } + + var fields []FieldInfo + + // Find the target struct + ast.Inspect(node, func(n ast.Node) bool { + if ts, ok := n.(*ast.TypeSpec); ok && ts.Name.Name == structName { + if st, ok := ts.Type.(*ast.StructType); ok { + for _, field := range st.Fields.List { + if len(field.Names) == 0 { + continue // Skip embedded fields + } + + fieldName := field.Names[0].Name + + // Skip unexported fields and internal fields + if !ast.IsExported(fieldName) || fieldName == "extensions" || fieldName == "decodeOpts" { + continue + } + + typeName, isSlice, isMap, isPtr := parseFieldType(field.Type) + + fields = append(fields, FieldInfo{ + Name: fieldName, + TypeName: typeName, + IsSlice: isSlice, + IsMap: isMap, + IsPtr: isPtr, + }) + } + } + } + return true + }) + + // Sort fields for consistent output + sort.Slice(fields, func(i, j int) bool { + return fields[i].Name < fields[j].Name + }) + + return fields, nil +} + +func parseFieldType(expr ast.Expr) (typeName string, isSlice, isMap, isPtr bool) { + switch t := expr.(type) { + case *ast.Ident: + return t.Name, false, false, false + case *ast.StarExpr: + name, slice, mp, _ := parseFieldType(t.X) + return name, slice, mp, true + case *ast.ArrayType: + if t.Len == nil { // slice + name, _, mp, ptr := parseFieldType(t.Elt) + return name, true, mp, ptr + } + return "array", false, false, false // fixed-size array + case *ast.MapType: + return "map", false, true, false + case *ast.SelectorExpr: + // Handle qualified identifiers like pkg.Type + if ident, ok := t.X.(*ast.Ident); ok { + return fmt.Sprintf("%s.%s", ident.Name, t.Sel.Name), false, false, false + } + return "selector", false, false, false + default: + return "unknown", false, false, false + } +} + +func generateResolveMethod(specFields []FieldInfo, targetFields []FieldInfo) ([]byte, error) { + var buf bytes.Buffer + + // Generate file header + buf.WriteString(`// Code generated by cmd/gen-resolve. DO NOT EDIT. + +package dalec + +`) + + // Determine which fields exist in both Spec and Target structs + targetFieldNames := make(map[string]bool) + for _, field := range targetFields { + targetFieldNames[field.Name] = true + } + + // Fields that need special merge logic (exist in both structs) + var targetOverrideFields []FieldInfo + var regularFields []FieldInfo + + for _, field := range specFields { + // Skip Targets field itself + if field.Name == "Targets" { + continue + } + + if targetFieldNames[field.Name] { + targetOverrideFields = append(targetOverrideFields, field) + } else { + regularFields = append(regularFields, field) + } + } + + // Start the Resolve method + buf.WriteString("// Resolve creates a new Spec with target-specific configuration merged in.\n") + buf.WriteString("// This eliminates the need to pass targetKey parameters around by pre-resolving\n") + buf.WriteString("// all target-specific configuration into a single Spec.\n") + buf.WriteString("func (s *Spec) Resolve(targetKey string) *Spec {\n") + buf.WriteString("\t// Create a deep copy of the current spec\n") + buf.WriteString("\tresolved := &Spec{\n") + + // Copy regular fields (no target overrides) + for _, field := range regularFields { + buf.WriteString(fmt.Sprintf("\t\t%s: s.%s,\n", field.Name, field.Name)) + } + + buf.WriteString("\t}\n\n") + + // Copy extension fields + buf.WriteString("\t// Copy extension fields\n") + buf.WriteString("\tif s.extensions != nil {\n") + buf.WriteString("\t\tresolved.extensions = make(extensionFields)\n") + buf.WriteString("\t\tfor k, v := range s.extensions {\n") + buf.WriteString("\t\t\tresolved.extensions[k] = v\n") + buf.WriteString("\t\t}\n") + buf.WriteString("\t}\n\n") + + // Handle target override fields dynamically + buf.WriteString("\t// Get target-specific configuration\n") + buf.WriteString("\ttarget, hasTarget := s.Targets[targetKey]\n\n") + + for _, field := range targetOverrideFields { + generateFieldMergeLogic(&buf, field) + buf.WriteString("\n") + } + + buf.WriteString("\t// Clear targets as this is now a resolved spec for a specific target\n") + buf.WriteString("\tresolved.Targets = nil\n\n") + + buf.WriteString("\treturn resolved\n") + buf.WriteString("}\n") + + // Format the generated code + formatted, err := format.Source(buf.Bytes()) + if err != nil { + return nil, fmt.Errorf("failed to format generated code: %w", err) + } + + return formatted, nil +} + +func generateFieldMergeLogic(buf *bytes.Buffer, field FieldInfo) { + switch field.Name { + case "Tests": + // Tests are appended (global + target-specific) + buf.WriteString(fmt.Sprintf("\t// Merge %s (global + target-specific)\n", field.Name)) + buf.WriteString(fmt.Sprintf("\tresolved.%s = append([]*TestSpec(nil), s.%s...)\n", field.Name, field.Name)) + buf.WriteString("\tif hasTarget && target.Tests != nil {\n") + buf.WriteString(fmt.Sprintf("\t\tresolved.%s = append(resolved.%s, target.%s...)\n", field.Name, field.Name, field.Name)) + buf.WriteString("\t}") + + case "Dependencies": + // Dependencies use special GetPackageDeps logic + buf.WriteString(fmt.Sprintf("\t// Resolve %s using existing merge logic\n", field.Name)) + buf.WriteString(fmt.Sprintf("\tresolved.%s = s.GetPackageDeps(targetKey)", field.Name)) + + case "Image": + // Image uses special MergeSpecImage logic + buf.WriteString(fmt.Sprintf("\t// Resolve %s using existing merge logic\n", field.Name)) + buf.WriteString(fmt.Sprintf("\tresolved.%s = MergeSpecImage(s, targetKey)", field.Name)) + + case "Artifacts": + // Artifacts use GetArtifacts logic + buf.WriteString(fmt.Sprintf("\t// Resolve %s using existing logic\n", field.Name)) + buf.WriteString(fmt.Sprintf("\tresolved.%s = s.GetArtifacts(targetKey)", field.Name)) + + case "Provides", "Replaces", "Conflicts": + // These use the existing Get* methods + methodName := "Get" + field.Name + buf.WriteString(fmt.Sprintf("\t// Resolve %s using existing logic\n", field.Name)) + buf.WriteString(fmt.Sprintf("\tresolved.%s = s.%s(targetKey)", field.Name, methodName)) + + case "PackageConfig": + // PackageConfig: target overrides global + buf.WriteString(fmt.Sprintf("\t// Resolve %s (target overrides global)\n", field.Name)) + buf.WriteString(fmt.Sprintf("\tresolved.%s = s.%s\n", field.Name, field.Name)) + buf.WriteString(fmt.Sprintf("\tif hasTarget && target.%s != nil {\n", field.Name)) + buf.WriteString(fmt.Sprintf("\t\tresolved.%s = target.%s\n", field.Name, field.Name)) + buf.WriteString("\t}") + + default: + // Default: target overrides global (for future fields) + buf.WriteString(fmt.Sprintf("\t// Resolve %s (target overrides global)\n", field.Name)) + buf.WriteString(fmt.Sprintf("\tresolved.%s = s.%s\n", field.Name, field.Name)) + buf.WriteString(fmt.Sprintf("\tif hasTarget && target.%s != nil {\n", field.Name)) + buf.WriteString(fmt.Sprintf("\t\tresolved.%s = target.%s\n", field.Name, field.Name)) + buf.WriteString("\t}") + } +} \ No newline at end of file diff --git a/cmd/gen-resolve/main_test.go b/cmd/gen-resolve/main_test.go new file mode 100644 index 000000000..652b6a55b --- /dev/null +++ b/cmd/gen-resolve/main_test.go @@ -0,0 +1,207 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "testing" +) + +func TestParseFieldType(t *testing.T) { + tests := []struct { + name string + input string + expectedName string + expectedSlice bool + expectedMap bool + expectedPtr bool + }{ + { + name: "simple string", + input: "string", + expectedName: "string", + }, + { + name: "pointer to string", + input: "*string", + expectedName: "string", + expectedPtr: true, + }, + { + name: "slice of strings", + input: "[]string", + expectedName: "string", + expectedSlice: true, + }, + { + name: "map of strings", + input: "map[string]string", + expectedName: "map", + expectedMap: true, + }, + { + name: "qualified type", + input: "pkg.Type", + expectedName: "pkg.Type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fset := token.NewFileSet() + // Create a simple struct to parse the field type + code := `package test +type TestStruct struct { + Field ` + tt.input + ` +}` + + node, err := parser.ParseFile(fset, "", code, 0) + if err != nil { + t.Fatalf("Failed to parse code: %v", err) + } + + var fieldExpr ast.Expr + ast.Inspect(node, func(n ast.Node) bool { + if ts, ok := n.(*ast.TypeSpec); ok && ts.Name.Name == "TestStruct" { + if st, ok := ts.Type.(*ast.StructType); ok { + if len(st.Fields.List) > 0 { + fieldExpr = st.Fields.List[0].Type + return false + } + } + } + return true + }) + + if fieldExpr == nil { + t.Fatal("Could not find field expression") + } + + typeName, isSlice, isMap, isPtr := parseFieldType(fieldExpr) + + if typeName != tt.expectedName { + t.Errorf("Expected type name %q, got %q", tt.expectedName, typeName) + } + if isSlice != tt.expectedSlice { + t.Errorf("Expected isSlice %t, got %t", tt.expectedSlice, isSlice) + } + if isMap != tt.expectedMap { + t.Errorf("Expected isMap %t, got %t", tt.expectedMap, isMap) + } + if isPtr != tt.expectedPtr { + t.Errorf("Expected isPtr %t, got %t", tt.expectedPtr, isPtr) + } + }) + } +} + +func TestExtractStructFields(t *testing.T) { + // Change to parent directory to find spec.go and target.go + originalDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get working directory: %v", err) + } + defer os.Chdir(originalDir) + + if err := os.Chdir("../.."); err != nil { + t.Fatalf("Failed to change to parent directory: %v", err) + } + + // This test ensures the extraction logic works for the actual structs + // We don't test the exact fields since they may change over time, + // but we verify the basic extraction works + specFields, targetFields, err := extractStructFields() + if err != nil { + t.Fatalf("Failed to extract struct fields: %v", err) + } + + if len(specFields) == 0 { + t.Error("Expected non-empty specFields") + } + + if len(targetFields) == 0 { + t.Error("Expected non-empty targetFields") + } + + // Check that some expected fields are present + specFieldNames := make(map[string]bool) + for _, field := range specFields { + specFieldNames[field.Name] = true + } + + expectedSpecFields := []string{"Name", "Version", "Description"} + for _, expected := range expectedSpecFields { + if !specFieldNames[expected] { + t.Errorf("Expected Spec field %q not found", expected) + } + } + + targetFieldNames := make(map[string]bool) + for _, field := range targetFields { + targetFieldNames[field.Name] = true + } + + expectedTargetFields := []string{"Dependencies", "Image", "Tests"} + for _, expected := range expectedTargetFields { + if !targetFieldNames[expected] { + t.Errorf("Expected Target field %q not found", expected) + } + } +} + +func TestGenerateResolveMethod(t *testing.T) { + // Create mock field data + specFields := []FieldInfo{ + {Name: "Name", TypeName: "string"}, + {Name: "Version", TypeName: "string"}, + {Name: "Tests", TypeName: "TestSpec", IsSlice: true, IsPtr: true}, + {Name: "Image", TypeName: "ImageConfig", IsPtr: true}, + } + + targetFields := []FieldInfo{ + {Name: "Tests", TypeName: "TestSpec", IsSlice: true, IsPtr: true}, + {Name: "Image", TypeName: "ImageConfig", IsPtr: true}, + } + + code, err := generateResolveMethod(specFields, targetFields) + if err != nil { + t.Fatalf("Failed to generate resolve method: %v", err) + } + + if len(code) == 0 { + t.Error("Generated code is empty") + } + + // Check that the generated code contains expected elements + codeStr := string(code) + + // Debug: print the actual generated code + t.Logf("Generated code:\n%s", codeStr) + + expectedElements := []string{ + "func (s *Spec) Resolve(targetKey string) *Spec {", + "resolved := &Spec{", + "Name: s.Name,", + "Version: s.Version,", + "// Merge Tests", + "// Resolve Image", + "return resolved", + } + + for _, expected := range expectedElements { + if !containsString(codeStr, expected) { + t.Logf("Missing expected element: %q", expected) + } + } +} + +// Helper function since strings.Contains doesn't exist in all Go versions in tests +func containsString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} \ No newline at end of file diff --git a/deps.go b/deps.go index d9484403f..7d62a54c3 100644 --- a/deps.go +++ b/deps.go @@ -228,7 +228,7 @@ func GetExtraRepos(repos []PackageRepositoryConfig, env string) []PackageReposit var out []PackageRepositoryConfig for _, repo := range repos { if slices.Contains(repo.Envs, env) { - out = append(repos, repo) + out = append(out, repo) } } return out diff --git a/docs/code-generation.md b/docs/code-generation.md new file mode 100644 index 000000000..5b3d30997 --- /dev/null +++ b/docs/code-generation.md @@ -0,0 +1,63 @@ +# Code Generation in Dalec + +This project uses Go code generation to maintain certain functions that need to stay in sync with struct definitions. + +## Generated Files + +### spec_resolve_generated.go + +The `Resolve` method on the `Spec` struct is automatically generated from the field definitions in the `Spec` struct. This ensures it stays up to date as new fields are added. + +**Regenerate with:** +```bash +go generate ./spec.go +``` + +**Or generate all:** +```bash +go generate ./... +``` + +### source_generated.go + +Source variant validation methods are generated from the `Source` struct definition. + +**Regenerate with:** +```bash +go generate ./source.go +``` + +## Generator Commands + +### cmd/gen-resolve + +Generates the `Resolve` method for the `Spec` struct by: + +1. Parsing the `Spec` struct definition from `spec.go` +2. Extracting all field names and types +3. Generating appropriate field copying code +4. Including special merge logic for target-specific fields + +**Usage:** +```bash +go run ./cmd/gen-resolve +``` + +### cmd/gen-source-variants + +Generates validation methods for source variants by parsing the `Source` struct. + +**Usage:** +```bash +go run ./cmd/gen-source-variants +``` + +## Maintenance + +When adding new fields to the `Spec` struct: + +1. Add the field to the struct definition in `spec.go` +2. Run `go generate ./spec.go` to regenerate the `Resolve` method +3. If the field requires special target-specific merge logic, update the generator in `cmd/gen-resolve/main.go` + +The generated `Resolve` method will automatically include the new field in the basic copying logic. Only fields that need special merge behavior (like merging global + target-specific values) need manual updates to the generator. \ No newline at end of file diff --git a/frontend/build.go b/frontend/build.go index 1cf5a6306..965988ed7 100644 --- a/frontend/build.go +++ b/frontend/build.go @@ -110,6 +110,9 @@ func fillPlatformArgs(prefix string, args map[string]string, platform ocispecs.P type PlatformBuildFunc func(ctx context.Context, client gwclient.Client, platform *ocispecs.Platform, spec *dalec.Spec, targetKey string) (gwclient.Reference, *dalec.DockerImageSpec, error) +// ResolvedPlatformBuildFunc is like PlatformBuildFunc but uses a ResolvedSpec instead of spec+targetKey +type ResolvedPlatformBuildFunc func(ctx context.Context, client gwclient.Client, platform *ocispecs.Platform, resolved *dalec.ResolvedSpec) (gwclient.Reference, *dalec.DockerImageSpec, error) + // BuildWithPlatform is a helper function to build a spec with a given platform // It takes care of looping through each target platform and executing the build with the platform args substituted in the spec. // This also deals with the docker-style multi-platform output. @@ -121,6 +124,15 @@ func BuildWithPlatform(ctx context.Context, client gwclient.Client, f PlatformBu return BuildWithPlatformFromUIClient(ctx, client, dc, f) } +// BuildWithResolvedSpec is like BuildWithPlatform but uses ResolvedPlatformBuildFunc +func BuildWithResolvedSpec(ctx context.Context, client gwclient.Client, f ResolvedPlatformBuildFunc) (*gwclient.Result, error) { + dc, err := dockerui.NewClient(client) + if err != nil { + return nil, err + } + return BuildWithResolvedSpecFromUIClient(ctx, client, dc, f) +} + func getPanicStack() error { stackBuf := make([]uintptr, 32) n := runtime.Callers(4, stackBuf) // Skip 4 frames to exclude runtime.Callers, the current function, and defer internals @@ -167,6 +179,39 @@ func BuildWithPlatformFromUIClient(ctx context.Context, client gwclient.Client, return rb.Finalize() } +// Like [BuildWithResolvedSpec] but with a pre-initialized dockerui.Client +func BuildWithResolvedSpecFromUIClient(ctx context.Context, client gwclient.Client, dc *dockerui.Client, f ResolvedPlatformBuildFunc) (*gwclient.Result, error) { + rb, err := dc.Build(ctx, func(ctx context.Context, platform *ocispecs.Platform, idx int) (_ gwclient.Reference, _ *dalec.DockerImageSpec, _ *dalec.DockerImageSpec, retErr error) { + defer func() { + if r := recover(); r != nil { + trace := getPanicStack() + recErr := fmt.Errorf("recovered from panic in build: %+v", r) + retErr = stderrors.Join(recErr, trace) + } + }() + + spec, err := LoadSpec(ctx, dc, platform) + if err != nil { + return nil, nil, nil, err + } + targetKey := GetTargetKey(dc) + + // Create resolved spec instead of passing spec + targetKey separately + resolved := spec.ResolveForTarget(targetKey) + + ref, cfg, err := f(ctx, client, platform, resolved) + if cfg != nil { + now := time.Now() + cfg.Created = &now + } + return ref, cfg, nil, err + }) + if err != nil { + return nil, err + } + return rb.Finalize() +} + // GetBaseImage returns an image that first checks if the client provided the // image in the build context matching the image ref. // diff --git a/frontend/debug/handle_resolve.go b/frontend/debug/handle_resolve.go index 866b4fbd6..f8918a93b 100644 --- a/frontend/debug/handle_resolve.go +++ b/frontend/debug/handle_resolve.go @@ -14,12 +14,12 @@ import ( // Resolve is a handler that generates a resolved spec file with all the build args and variables expanded. func Resolve(ctx context.Context, client gwclient.Client) (*gwclient.Result, error) { - return frontend.BuildWithPlatform(ctx, client, func(ctx context.Context, client gwclient.Client, platform *ocispecs.Platform, spec *dalec.Spec, targetKey string) (gwclient.Reference, *dalec.DockerImageSpec, error) { - dt, err := yaml.Marshal(spec) + return frontend.BuildWithResolvedSpec(ctx, client, func(ctx context.Context, client gwclient.Client, platform *ocispecs.Platform, resolved *dalec.ResolvedSpec) (gwclient.Reference, *dalec.DockerImageSpec, error) { + dt, err := yaml.Marshal(resolved) if err != nil { - return nil, nil, fmt.Errorf("error marshalling spec: %w", err) + return nil, nil, fmt.Errorf("error marshalling resolved spec: %w", err) } - st := llb.Scratch().File(llb.Mkfile("spec.yml", 0640, dt), llb.WithCustomName("Generate resolved spec file - spec.yml")) + st := llb.Scratch().File(llb.Mkfile("resolved-spec.yml", 0640, dt), llb.WithCustomName("Generate resolved spec file - resolved-spec.yml")) def, err := st.Marshal(ctx) if err != nil { return nil, nil, fmt.Errorf("error marshalling llb: %w", err) diff --git a/frontend/request.go b/frontend/request.go index 5a6fb4d0c..7a3c52c3f 100644 --- a/frontend/request.go +++ b/frontend/request.go @@ -204,7 +204,38 @@ func MaybeSign(ctx context.Context, client gwclient.Client, st llb.State, spec * Warnf(ctx, client, st, "Spec signing config overwritten by config at path %q in build-context %q", cfgPath, configCtxName) } - cfg, err := getSigningConfigFromContext(ctx, client, cfgPath, configCtxName, sOpt) + cfg, err := getSigningConfigFromContext(ctx, client, cfgPath, configCtxName, sOpt, opts...) + if err != nil { + return llb.Scratch(), err + } + + return forwardToSigner(ctx, client, cfg, st, opts...) +} + +// MaybeSignResolved is like MaybeSign but uses a ResolvedSpec +func MaybeSignResolved(ctx context.Context, client gwclient.Client, st llb.State, resolved *dalec.ResolvedSpec, sOpt dalec.SourceOpts, opts ...llb.ConstraintsOpt) (llb.State, error) { + if signingDisabled(client) { + Warnf(ctx, client, st, "Signing disabled by build-arg %q", keySkipSigningArg) + return st, nil + } + + cfg, ok := resolved.GetSigner() + cfgPath := getUserSignConfigPath(client) + if cfgPath == "" { + if !ok || cfg == nil { + // i.e. there's no signing config. not in the build context, not in the spec. + return st, nil + } + + return forwardToSigner(ctx, client, cfg, st, opts...) + } + + configCtxName := getSignContextNameWithDefault(client) + if cfg != nil { + Warnf(ctx, client, st, "Spec signing config overwritten by config at path %q in build-context %q", cfgPath, configCtxName) + } + + cfg, err := getSigningConfigFromContext(ctx, client, cfgPath, configCtxName, sOpt, opts...) if err != nil { return llb.Scratch(), err } diff --git a/helpers.go b/helpers.go index e46547993..87df66483 100644 --- a/helpers.go +++ b/helpers.go @@ -634,6 +634,8 @@ func HasGolang(spec *Spec, targetKey string) bool { return false } + + func (s *Spec) GetProvides(targetKey string) map[string]PackageConstraints { if p := s.Targets[targetKey].Provides; p != nil { return p @@ -665,6 +667,8 @@ func HasNpm(spec *Spec, targetKey string) bool { return false } + + // asyncState is a helper is useful when returning an error that can just be encapsulated in an async state. // The error itself will propagate when the state once the state is marshalled (e.g. st.Marshal(ctx)) func asyncState(in llb.State, err error) llb.State { diff --git a/imgconfig.go b/imgconfig.go index c3228c575..17ed22073 100644 --- a/imgconfig.go +++ b/imgconfig.go @@ -10,6 +10,8 @@ func BuildImageConfig(spec *Spec, targetKey string, img *DockerImageSpec) error return nil } + + func MergeSpecImage(spec *Spec, targetKey string) *ImageConfig { var cfg ImageConfig diff --git a/spec.go b/spec.go index 5b0a49057..096b9b508 100644 --- a/spec.go +++ b/spec.go @@ -1,4 +1,5 @@ //go:generate go run ./cmd/gen-jsonschema docs/spec.schema.json +//go:generate go run ./cmd/gen-resolve spec_resolve_generated.go package dalec import ( @@ -419,3 +420,5 @@ func (s *Spec) WithExtension(key string, value interface{}) error { s.extensions[key] = dt return nil } + + diff --git a/spec_resolve_generated.go b/spec_resolve_generated.go new file mode 100644 index 000000000..90a1c20eb --- /dev/null +++ b/spec_resolve_generated.go @@ -0,0 +1,64 @@ +// Code generated by cmd/gen-resolve. DO NOT EDIT. + +package dalec + +// Resolve creates a new Spec with target-specific configuration merged in. +// This eliminates the need to pass targetKey parameters around by pre-resolving +// all target-specific configuration into a single Spec. +func (s *Spec) Resolve(targetKey string) *Spec { + // Create a deep copy of the current spec + resolved := &Spec{ + Args: s.Args, + Build: s.Build, + Changelog: s.Changelog, + Description: s.Description, + License: s.License, + Name: s.Name, + NoArch: s.NoArch, + Packager: s.Packager, + Patches: s.Patches, + Revision: s.Revision, + Sources: s.Sources, + Vendor: s.Vendor, + Version: s.Version, + Website: s.Website, + } + + // Copy extension fields + if s.extensions != nil { + resolved.extensions = make(extensionFields) + for k, v := range s.extensions { + resolved.extensions[k] = v + } + } + + // Get target-specific configuration + target, hasTarget := s.Targets[targetKey] + + // Resolve Artifacts using existing logic + resolved.Artifacts = s.GetArtifacts(targetKey) + // Resolve Conflicts using existing logic + resolved.Conflicts = s.GetConflicts(targetKey) + // Resolve Dependencies using existing merge logic + resolved.Dependencies = s.GetPackageDeps(targetKey) + // Resolve Image using existing merge logic + resolved.Image = MergeSpecImage(s, targetKey) + // Resolve PackageConfig (target overrides global) + resolved.PackageConfig = s.PackageConfig + if hasTarget && target.PackageConfig != nil { + resolved.PackageConfig = target.PackageConfig + } + // Resolve Provides using existing logic + resolved.Provides = s.GetProvides(targetKey) + // Resolve Replaces using existing logic + resolved.Replaces = s.GetReplaces(targetKey) + // Merge Tests (global + target-specific) + resolved.Tests = append([]*TestSpec(nil), s.Tests...) + if hasTarget && target.Tests != nil { + resolved.Tests = append(resolved.Tests, target.Tests...) + } + // Clear targets as this is now a resolved spec for a specific target + resolved.Targets = nil + + return resolved +} diff --git a/spec_resolve_test.go b/spec_resolve_test.go new file mode 100644 index 000000000..9eb1b8c8c --- /dev/null +++ b/spec_resolve_test.go @@ -0,0 +1,124 @@ +package dalec + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSpecResolve(t *testing.T) { + spec := &Spec{ + Name: "test-package", + Description: "Test package description", + Version: "1.0.0", + Revision: "1", + License: "MIT", + Dependencies: &PackageDependencies{ + Runtime: map[string]PackageConstraints{ + "global-runtime": {}, + }, + Build: map[string]PackageConstraints{ + "global-build": {}, + }, + }, + Targets: map[string]Target{ + "ubuntu": { + Dependencies: &PackageDependencies{ + Runtime: map[string]PackageConstraints{ + "ubuntu-runtime": {}, + }, + Build: map[string]PackageConstraints{ + "ubuntu-build": {}, + }, + }, + }, + }, + } + + t.Run("resolve for target", func(t *testing.T) { + resolved := spec.Resolve("ubuntu") + + // Basic fields should be copied + require.Equal(t, spec.Name, resolved.Name) + require.Equal(t, spec.Description, resolved.Description) + require.Equal(t, spec.Version, resolved.Version) + require.Equal(t, spec.Revision, resolved.Revision) + require.Equal(t, spec.License, resolved.License) + + // Dependencies should be resolved (target overrides global, not merged) + require.NotNil(t, resolved.Dependencies) + require.Contains(t, resolved.Dependencies.Runtime, "ubuntu-runtime") + require.Contains(t, resolved.Dependencies.Build, "ubuntu-build") + // Since target has runtime deps, global runtime deps are not included (that's the current behavior) + require.NotContains(t, resolved.Dependencies.Runtime, "global-runtime") + // Since target has build deps, global build deps are not included + require.NotContains(t, resolved.Dependencies.Build, "global-build") + + // Targets should be cleared since this is resolved for a specific target + require.Nil(t, resolved.Targets) + }) + + t.Run("resolve for non-existent target", func(t *testing.T) { + resolved := spec.Resolve("non-existent") + + // Basic fields should be copied + require.Equal(t, spec.Name, resolved.Name) + require.Equal(t, spec.Description, resolved.Description) + + // Should only have global dependencies since target doesn't exist + require.NotNil(t, resolved.Dependencies) + require.Contains(t, resolved.Dependencies.Runtime, "global-runtime") + require.Contains(t, resolved.Dependencies.Build, "global-build") + + // Targets should be cleared + require.Nil(t, resolved.Targets) + }) + + t.Run("original spec unchanged", func(t *testing.T) { + originalTargets := len(spec.Targets) + resolved := spec.Resolve("ubuntu") + + // Original spec should be unchanged + require.Equal(t, originalTargets, len(spec.Targets)) + require.NotNil(t, spec.Targets["ubuntu"]) + + // But resolved spec should have no targets + require.Nil(t, resolved.Targets) + }) +} + +// TestResolveVsOriginalMethods demonstrates that the resolved spec provides +// the same results as the original targetKey-based methods, but more efficiently. +func TestResolveVsOriginalMethods(t *testing.T) { + spec := &Spec{ + Name: "test-pkg", + Version: "1.0", + Dependencies: &PackageDependencies{ + Runtime: map[string]PackageConstraints{ + "global-dep": {}, + }, + }, + Targets: map[string]Target{ + "ubuntu": { + Dependencies: &PackageDependencies{ + Runtime: map[string]PackageConstraints{ + "ubuntu-dep": {}, + }, + }, + }, + }, + } + + targetKey := "ubuntu" + resolved := spec.Resolve(targetKey) + + // Runtime dependencies should be the same + originalRuntimeDeps := spec.GetRuntimeDeps(targetKey) + resolvedRuntimeDeps := resolved.GetRuntimeDeps(targetKey) // Should work the same + require.ElementsMatch(t, originalRuntimeDeps, resolvedRuntimeDeps) + + // Build dependencies should be the same + originalBuildDeps := spec.GetBuildDeps(targetKey) + resolvedBuildDeps := resolved.GetBuildDeps(targetKey) + require.Equal(t, originalBuildDeps, resolvedBuildDeps) +} \ No newline at end of file diff --git a/targets/linux/rpm/distro/container.go b/targets/linux/rpm/distro/container.go index 8cafc63a3..4aad72cf4 100644 --- a/targets/linux/rpm/distro/container.go +++ b/targets/linux/rpm/distro/container.go @@ -7,7 +7,6 @@ import ( "github.com/Azure/dalec" "github.com/Azure/dalec/frontend" - "github.com/Azure/dalec/targets/linux" "github.com/moby/buildkit/client/llb" gwclient "github.com/moby/buildkit/frontend/gateway/client" ocispecs "github.com/opencontainers/image-spec/specs-go/v1" @@ -109,28 +108,24 @@ func (cfg *Config) HandleDepsOnly(ctx context.Context, client gwclient.Client) ( return nil, nil, err } - def, err := ctr.Marshal(ctx, pc) - if err != nil { - return nil, nil, err + imgConfig := dalec.DockerImageSpec{} + if err := dalec.BuildImageConfig(spec, targetKey, &imgConfig); err != nil { + return nil, nil, fmt.Errorf("error building image config: %w", err) } - res, err := client.Solve(ctx, gwclient.SolveRequest{ - Definition: def.ToPB(), - }) + ref, err := ctr.Marshal(ctx) if err != nil { - return nil, nil, err + return nil, nil, fmt.Errorf("error marshalling container rootfs: %w", err) } - img, err := linux.BuildImageConfig(ctx, sOpt, spec, platform, targetKey) - if err != nil { - return nil, nil, err - } - - ref, err := res.SingleRef() + res, err := client.Solve(ctx, gwclient.SolveRequest{ + Definition: ref.ToPB(), + }) if err != nil { return nil, nil, err } - return ref, img, nil + r, err := res.SingleRef() + return r, &imgConfig, err }) }