Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions provider/gitlab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ template "go_base" {
- `template.<id>` and `job.<id>` references in `extends` map to YAML `extends` entries.
- A job or template name that is not a valid HCL identifier is sanitized to make the block referenceable (`build-app` becomes the label `build_app`) and the original name is kept in an `id` attribute, so the name and any `needs` or `extends` pointing at it survive the roundtrip.
- Repeated `include {}` blocks map to YAML `include:` entries.
- Whether a nested map becomes an HCL block or an object attribute follows the schema in `provider/gitlab/config.go`, not the value's shape: `artifacts.reports` is a block, while `cache.key`, `service.variables`, `default.retry` and `include.inputs` are attributes.
- Repeated `service {}` blocks map to YAML `services:` entries under `default` or a `job`.
- Parse schema is defined by typed HCL structs in `provider/gitlab/config.go`; `hcl:",remain"` is used only for intentional pass-through islands.
- Unparse schema validation favors strict typed YAML decode over manual key allowlist tables.
144 changes: 144 additions & 0 deletions provider/gitlab/nested_block_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// Copyright 2026 YLD Limited
// SPDX-License-Identifier: Apache-2.0

package gitlab

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/yldio/cinzel/provider"
)

// A nested map is written as an HCL block only where the schema in config.go
// declares one. Guessing from the value's shape produced HCL the parser then
// rejected, and a template body written by the generic writer turned its rule
// blocks into an attribute.
func TestNestedMapsFollowTheHCLSchema(t *testing.T) {
for _, tc := range []struct {
name string
yaml string
wantHCL []string
wantYAML []string
}{
{
name: "cache key is an attribute",
yaml: `build:
script: [make]
cache:
key:
files: [go.sum]
`,
wantHCL: []string{"key = {"},
wantYAML: []string{"key:", "- go.sum"},
},
{
name: "service variables is an attribute",
yaml: `build:
script: [make]
services:
- name: postgres:16
variables:
POSTGRES_DB: test
`,
wantHCL: []string{"variables = {"},
wantYAML: []string{"POSTGRES_DB: test"},
},
{
name: "default retry is an attribute",
yaml: `default:
retry:
max: 2
build:
script: [make]
`,
wantHCL: []string{"retry = {"},
wantYAML: []string{"max: 2"},
},
{
name: "include inputs is an attribute",
yaml: `include:
- component: gitlab.com/c/t@1
inputs:
stage: test
build:
script: [make]
`,
wantHCL: []string{"inputs = {"},
wantYAML: []string{"stage: test"},
},
{
name: "artifacts reports stays a block",
yaml: `build:
script: [make]
artifacts:
reports:
junit: report.xml
`,
wantHCL: []string{"reports {"},
wantYAML: []string{"junit: report.xml"},
},
{
name: "template rules stay blocks",
yaml: `.base:
rules:
- if: always
cache:
paths: [.cache]
build:
extends: [.base]
script: [make]
`,
wantHCL: []string{"rule {", "cache {"},
wantYAML: []string{"rules:", "- if: always"},
},
} {
t.Run(tc.name, func(t *testing.T) {
tmp := t.TempDir()
in := filepath.Join(tmp, ".gitlab-ci.yml")
outDir := filepath.Join(tmp, "hcl")
backDir := filepath.Join(tmp, "yaml")

if err := os.WriteFile(in, []byte(tc.yaml), 0o644); err != nil {
t.Fatal(err)
}

p := New()

if err := p.Unparse(provider.ProviderOps{File: in, OutputDirectory: outDir}); err != nil {
t.Fatalf("Unparse() error = %v", err)
}

hclPath := filepath.Join(outDir, ".gitlab-ci.hcl")
got, err := os.ReadFile(hclPath)
if err != nil {
t.Fatal(err)
}

for _, want := range tc.wantHCL {
if !strings.Contains(string(got), want) {
t.Errorf("HCL missing %q:\n%s", want, got)
}
}

// The HCL has to be readable by cinzel's own parser, and the
// values have to come back.
if err := p.Parse(provider.ProviderOps{File: hclPath, OutputDirectory: backDir}); err != nil {
t.Fatalf("Parse() error = %v\nHCL:\n%s", err, got)
}

back, err := os.ReadFile(filepath.Join(backDir, ".gitlab-ci.yml"))
if err != nil {
t.Fatal(err)
}

for _, want := range tc.wantYAML {
if !strings.Contains(string(back), want) {
t.Errorf("reparsed YAML missing %q:\n%s", want, back)
}
}
})
}
}
108 changes: 95 additions & 13 deletions provider/gitlab/unparse_pipeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package gitlab
import (
"fmt"
"os"
"reflect"
"regexp"
"sort"
"strconv"
Expand Down Expand Up @@ -169,7 +170,7 @@ func pipelineToHCL(doc map[string]any, filename string) ([]byte, error) {

db := body.AppendNewBlock("default", nil)

if err := writeGenericMap(db.Body(), defaultMap); err != nil {
if err := writeGenericMap(db.Body(), defaultMap, defaultSchema); err != nil {
return nil, err
}
}
Expand Down Expand Up @@ -275,8 +276,11 @@ func pipelineToHCL(doc map[string]any, filename string) ([]byte, error) {

writeBlockKey(tb.Body(), strings.TrimPrefix(key, "."), tplID)

if err := writeGenericMap(tb.Body(), hiddenJobMap); err != nil {
return nil, err
// A template body is a job body: it takes the same rule, cache,
// artifacts and service blocks, which the generic writer would
// write as attributes.
if err := writeJobBlock(tb.Body(), hiddenJobMap, jobIDMap, templateIDMap); err != nil {
return nil, fmt.Errorf("error in template '%s': %w", key, err)
}
continue
}
Expand All @@ -287,7 +291,7 @@ func pipelineToHCL(doc map[string]any, filename string) ([]byte, error) {
}
gb := body.AppendNewBlock(key, nil)

if err := writeGenericMap(gb.Body(), genericMap); err != nil {
if err := writeGenericMap(gb.Body(), genericMap, passthroughSchema); err != nil {
return nil, err
}
} else {
Expand Down Expand Up @@ -393,8 +397,13 @@ func writeJobBlock(body *hclwrite.Body, job map[string]any, jobIDMap map[string]
return fmt.Errorf("%s must be an object", key)
}
b := body.AppendNewBlock(key, nil)
schema := cacheSchema

if key == "artifacts" {
schema = artifactsSchema
}

if err := writeGenericMap(b.Body(), mapVal); err != nil {
if err := writeGenericMap(b.Body(), mapVal, schema); err != nil {
return err
}
case "services":
Expand Down Expand Up @@ -464,7 +473,78 @@ func writeJobBlock(body *hclwrite.Body, job map[string]any, jobIDMap map[string]
return nil
}

func writeGenericMap(body *hclwrite.Body, mapping map[string]any) error {
// bodySchema says which keys of a block body the HCL schema in config.go
// declares as nested blocks. Without it the writer guesses from the value's
// shape and turns every nested map into a block, which the parser then rejects
// for a key the schema declares as an attribute.
type bodySchema struct {
// any is set for a body declared with `hcl:",remain"`, which takes a
// block of any name.
any bool
blocks map[string]bodySchema
}

// child returns the schema for a nested block named key, and whether the body
// takes one at all.
func (s bodySchema) child(key string) (bodySchema, bool) {
if child, ok := s.blocks[key]; ok {
return child, true
}

if s.any {
return s, true
}

return bodySchema{}, false
}

// schemaOf reads a bodySchema off the hcl tags of a config struct.
func schemaOf(v any) bodySchema {
return schemaOfType(reflect.TypeOf(v))
}

func schemaOfType(t reflect.Type) bodySchema {
schema := bodySchema{blocks: map[string]bodySchema{}}

for i := range t.NumField() {
field := t.Field(i)
tag, ok := field.Tag.Lookup("hcl")

if !ok {
continue
}

name, kind, _ := strings.Cut(tag, ",")

switch kind {
case "remain":
schema.any = true
case "block":
elem := field.Type

for elem.Kind() == reflect.Slice || elem.Kind() == reflect.Pointer {
elem = elem.Elem()
}

schema.blocks[name] = schemaOfType(elem)
}
}

return schema
}

var (
defaultSchema = schemaOf(hclDefaultBlock{})
cacheSchema = schemaOf(hclCacheBlock{})
artifactsSchema = schemaOf(hclArtifactsBlock{})
serviceSchema = schemaOf(hclServiceBlock{})
includeSchema = schemaOf(hclIncludeBlock{})
// passthroughSchema is used for a top-level key outside the schema, where
// there is nothing to check against.
passthroughSchema = bodySchema{any: true}
)

func writeGenericMap(body *hclwrite.Body, mapping map[string]any, schema bodySchema) error {
for _, key := range sortedKeys(mapping) {
value := mapping[key]

Expand All @@ -476,12 +556,14 @@ func writeGenericMap(body *hclwrite.Body, mapping map[string]any) error {
}

if nested, ok := toStringAnyMap(value); ok {
b := body.AppendNewBlock(key, nil)
if child, isBlock := schema.child(key); isBlock {
b := body.AppendNewBlock(key, nil)

if err := writeGenericMap(b.Body(), nested); err != nil {
return err
if err := writeGenericMap(b.Body(), nested, child); err != nil {
return err
}
continue
}
continue
}

if err := writeAttributeAny(body, key, escapeGitLabVariables(value)); err != nil {
Expand All @@ -508,7 +590,7 @@ func writeServicesBlocks(body *hclwrite.Body, raw any) error {
return err
}
case map[string]any:
if err := writeGenericMap(sb.Body(), service); err != nil {
if err := writeGenericMap(sb.Body(), service, serviceSchema); err != nil {
return err
}
default:
Expand All @@ -532,7 +614,7 @@ func writeIncludeBlocks(body *hclwrite.Body, raw any) error {
case map[string]any:
ib := body.AppendNewBlock("include", nil)

return writeGenericMap(ib.Body(), include)
return writeGenericMap(ib.Body(), include, includeSchema)
case []any:
for _, item := range include {
switch v := item.(type) {
Expand All @@ -545,7 +627,7 @@ func writeIncludeBlocks(body *hclwrite.Body, raw any) error {
case map[string]any:
ib := body.AppendNewBlock("include", nil)

if err := writeGenericMap(ib.Body(), v); err != nil {
if err := writeGenericMap(ib.Body(), v, includeSchema); err != nil {
return err
}
default:
Expand Down
Loading