Stan 1 روز پیش
کامیت
ea4d026801

+ 3 - 0
README.md

@@ -0,0 +1,3 @@
+# CBC
+
+Container Build Conveyor

+ 167 - 0
cbc.go

@@ -0,0 +1,167 @@
+package cbc
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"sync"
+	"time"
+
+	fw "git.buran.team/main/fairwind"
+
+	dockerpkg "git.buran.team/main/cbc/docker"
+	executorpkg "git.buran.team/main/cbc/executor"
+	schemepkg "git.buran.team/main/cbc/scheme"
+)
+
+var ErrPlatformOutOfCapacity = errors.New("platform out of capacity")
+var ErrPlatformTicketNotFound = errors.New("platform ticket not found")
+var ErrPlatformTaskNotComplete = errors.New("platform task not complete")
+
+type CBC struct {
+	mutex     sync.Mutex
+	waitGroup sync.WaitGroup
+	ctxLocal  context.Context
+	ctxGlobal context.Context
+	cancel    func()
+	global    *executorpkg.Global
+	tvm       *executorpkg.TVM
+	status    map[string]*executorpkg.TaskResult
+}
+
+func NewCBC(ctxGlobal context.Context, log *fw.Log, capacity int, registry schemepkg.Registry) (*CBC, error) {
+	ctxLocal, cancel := context.WithCancel(context.Background())
+
+	client, err := dockerpkg.NewDocker()
+	if err != nil {
+		cancel()
+		return nil, fmt.Errorf("can't create cbc: %w", err)
+	}
+
+	return &CBC{
+		ctxLocal:  ctxLocal,
+		ctxGlobal: ctxGlobal,
+		cancel:    cancel,
+		global: &executorpkg.Global{
+			Ctx:    ctxGlobal,
+			Log:    log,
+			Docker: client,
+			Registry: dockerpkg.NewRegistry(
+				ctxLocal,
+				log,
+				client,
+				registry.Address,
+				registry.Login,
+				registry.Password,
+			),
+		},
+		tvm:    executorpkg.NewTVM(capacity),
+		status: map[string]*executorpkg.TaskResult{},
+	}, nil
+}
+
+func (this *CBC) Start() error {
+	this.global.Log.Information("starting cbc")
+	defer this.global.Log.Information("cbc started")
+
+	this.waitGroup.Add(1)
+	go this.workerWatch()
+	return nil
+}
+
+func (this *CBC) Stop() error {
+	this.global.Log.Information("stopping cbc")
+	defer this.global.Log.Information("cbc stopped")
+
+	this.cancel()
+	this.waitGroup.Wait()
+	return nil
+}
+
+func (this *CBC) Capacity() int {
+	return this.tvm.Capacity()
+}
+
+func (this *CBC) Schedule(task schemepkg.Task) (string, error) {
+	this.mutex.Lock()
+	defer this.mutex.Unlock()
+
+	if this.tvm.Capacity() == 0 {
+		return "", ErrPlatformOutOfCapacity
+	}
+
+	ticket, err := this.tvm.AcquireTicket()
+	if err != nil {
+		return "", fmt.Errorf("can't schedule task: %w", err)
+	}
+
+	this.waitGroup.Add(1)
+	go this.workerExecute(ticket, task)
+	return ticket.UUID, nil
+}
+
+func (this *CBC) Status(UUID string) (*executorpkg.TaskResult, error) {
+	this.mutex.Lock()
+	defer this.mutex.Unlock()
+
+	if !this.tvm.HasTicket(UUID) {
+		return nil, ErrPlatformTicketNotFound
+	}
+
+	ticket, err := this.tvm.FindTicket(UUID)
+	if err != nil {
+		return nil, fmt.Errorf("can't obtain task status: %w", err)
+	}
+
+	status, ok := this.status[UUID]
+	if !ok {
+		return nil, ErrPlatformTaskNotComplete
+	}
+
+	err = this.tvm.ReleaseTicket(ticket.UUID)
+	if err != nil {
+		return nil, fmt.Errorf("can't obtain task status: %w", err)
+	}
+
+	delete(this.status, ticket.UUID)
+	return status, nil
+}
+
+func (this *CBC) workerExecute(ticket *executorpkg.Ticket, taskScheme schemepkg.Task) {
+	defer this.waitGroup.Done()
+
+	this.global.Log.Information("task execution started", fw.LogValue("uuid", ticket.UUID))
+
+	// Create
+	task, err := executorpkg.NewTask(this.global, ticket, taskScheme)
+	if err != nil {
+		this.global.Log.Information("task execution finished with errors", fw.LogValue("uuid", ticket.UUID), fw.LogError(err))
+		return
+	}
+
+	// Execute
+	result := task.Execute()
+
+	// Store
+	this.mutex.Lock()
+	this.status[ticket.UUID] = result
+	this.mutex.Unlock()
+
+	// Done
+	this.global.Log.Information("task execution finished successfully", fw.LogValue("uuid", ticket.UUID))
+}
+
+func (this *CBC) workerWatch() {
+	defer this.waitGroup.Done()
+
+	for {
+		select {
+		case <-this.ctxGlobal.Done():
+			this.cancel()
+			return
+
+		case <-time.After(10 * time.Microsecond):
+			continue
+		}
+	}
+}

+ 22 - 0
docker/docker.go

@@ -0,0 +1,22 @@
+package docker
+
+import (
+	"fmt"
+
+	moby "github.com/moby/moby/client"
+)
+
+type Docker struct {
+	Docker *moby.Client
+}
+
+func NewDocker() (*Docker, error) {
+	client, err := moby.New()
+	if err != nil {
+		return nil, fmt.Errorf("can't create docker: %w", err)
+	}
+
+	return &Docker{
+		Docker: client,
+	}, nil
+}

+ 148 - 0
docker/image.go

@@ -0,0 +1,148 @@
+package docker
+
+import (
+	"bytes"
+	"context"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io"
+	"sync"
+	"time"
+
+	fw "git.buran.team/main/fairwind"
+
+	archive "github.com/moby/go-archive"
+	mobyregistry "github.com/moby/moby/api/types/registry"
+	moby "github.com/moby/moby/client"
+)
+
+type Image struct {
+	ctx      context.Context
+	log      *fw.Log
+	client   *Docker
+	registry *Registry
+	tag      string
+	version  string
+	build    bool
+	push     bool
+}
+
+func NewImage(ctx context.Context, log *fw.Log, client *Docker, registry *Registry, tag string, version string, build bool, push bool) *Image {
+	return &Image{
+		ctx:      ctx,
+		log:      log,
+		client:   client,
+		registry: registry,
+		tag:      tag,
+		version:  version,
+		build:    build,
+		push:     push,
+	}
+}
+
+func (this *Image) Name() string {
+	return fmt.Sprintf("%s/%s:%s", this.registry.address, this.tag, this.version)
+}
+
+func (this *Image) Build(timeout int, path string) ([]byte, error) {
+	if (!this.build) {
+		return []byte{}, nil
+	}
+
+	ctx, cancel := context.WithTimeout(this.ctx, time.Duration(timeout)*time.Millisecond)
+	defer cancel()
+
+	stream, err := archive.TarWithOptions(path, &archive.TarOptions{})
+	if err != nil {
+		return nil, fmt.Errorf("can't build image: %w", err)
+	}
+	defer stream.Close()
+
+	response, err := this.client.Docker.ImageBuild(
+		ctx,
+		stream,
+		moby.ImageBuildOptions{
+			Dockerfile: "Dockerfile",
+			Tags: []string{
+				this.Name(),
+			},
+		},
+	)
+	if err != nil {
+		return nil, fmt.Errorf("can't build image: %w", err)
+	}
+	defer response.Body.Close()
+
+	var stdout bytes.Buffer
+	_, err = io.Copy(&stdout, response.Body)
+	if err != nil {
+		return nil, fmt.Errorf("can't build image: %w", err)
+	}
+
+	return stdout.Bytes(), nil
+}
+
+func (this *Image) Push(timeout int) ([]byte, error) {
+	if (!this.push) {
+		return []byte{}, nil
+	}
+
+	ctx, cancel := context.WithTimeout(this.ctx, time.Duration(timeout)*time.Millisecond)
+	defer cancel()
+
+	credentials, err := credentialsToString(
+		this.registry.login,
+		this.registry.password,
+	)
+	if err != nil {
+		return nil, fmt.Errorf("can't push image: %w", err)
+	}
+
+	response, err := this.client.Docker.ImagePush(
+		ctx,
+		this.Name(),
+		moby.ImagePushOptions{
+			RegistryAuth: credentials,
+		},
+	)
+	if err != nil {
+		return nil, fmt.Errorf("can't push image: %w", err)
+	}
+
+	var stdout bytes.Buffer
+	var waitGroup sync.WaitGroup
+	waitGroup.Add(1)
+
+	go func() {
+		defer waitGroup.Done()
+
+		_, err := io.Copy(&stdout, response)
+		if err != nil {
+			// ...
+		}
+	}()
+
+	err = response.Wait(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("can't push image: %w", err)
+	}
+
+	waitGroup.Wait()
+	return stdout.Bytes(), nil
+}
+
+func credentialsToString(login string, password string) (string, error) {
+	encodedJSON, err := json.Marshal(
+		&mobyregistry.AuthConfig{
+			Username: login,
+			Password: password,
+		},
+	)
+	if err != nil {
+		return "", fmt.Errorf("can't serialize credentials: %w", err)
+	}
+
+	authStr := base64.URLEncoding.EncodeToString(encodedJSON)
+	return authStr, nil
+}

+ 50 - 0
docker/registry.go

@@ -0,0 +1,50 @@
+package docker
+
+import (
+	"context"
+	"fmt"
+
+	fw "git.buran.team/main/fairwind"
+
+	moby "github.com/moby/moby/client"
+)
+
+type Registry struct {
+	ctx      context.Context
+	log      *fw.Log
+	client   *Docker
+	address  string
+	login    string
+	password string
+}
+
+func NewRegistry(ctx context.Context, log *fw.Log, client *Docker, address string, login string, password string) *Registry {
+	return &Registry{
+		ctx:      ctx,
+		log:      log,
+		client:   client,
+		address:  address,
+		login:    login,
+		password: password,
+	}
+}
+
+func (this *Registry) Address() string {
+	return this.address
+}
+
+func (this *Registry) Authorize() error {
+	_, err := this.client.Docker.RegistryLogin(
+		this.ctx,
+		moby.RegistryLoginOptions{
+			ServerAddress: this.address,
+			Username:      this.login,
+			Password:      this.password,
+		},
+	)
+	if err != nil {
+		return fmt.Errorf("can't authorize in registry: %w", err)
+	}
+
+	return nil
+}

+ 16 - 0
executor/global.go

@@ -0,0 +1,16 @@
+package executor
+
+import (
+	"context"
+
+	fw "git.buran.team/main/fairwind"
+
+	dockerpkg "git.buran.team/main/cbc/docker"
+)
+
+type Global struct {
+	Ctx      context.Context
+	Log      *fw.Log
+	Docker   *dockerpkg.Docker
+	Registry *dockerpkg.Registry
+}

+ 129 - 0
executor/task.go

@@ -0,0 +1,129 @@
+package executor
+
+import (
+	"path/filepath"
+	"strings"
+
+	fw "git.buran.team/main/fairwind"
+
+	cbcdocker "git.buran.team/main/cbc/docker"
+	cbcpreprocessor "git.buran.team/main/cbc/preprocessor"
+	cbcscheme "git.buran.team/main/cbc/scheme"
+	cbctemplate "git.buran.team/main/cbc/template"
+)
+
+type Task struct {
+	global *Global
+	ticket *Ticket
+	scheme cbcscheme.Task
+}
+
+func NewTask(global *Global, ticket *Ticket, taskScheme cbcscheme.Task) (*Task, error) {
+	return &Task{
+		global: global,
+		ticket: ticket,
+		scheme: taskScheme,
+	}, nil
+}
+
+func (this *Task) Execute() *TaskResult {
+	result := &TaskResult{
+		Success:       false,
+		Template:      false,
+		Build:         Result{},
+		Preprocessors: map[string]*Result{},
+	}
+	for _, preprocessorScheme := range this.scheme.Preprocessors {
+		result.Preprocessors[preprocessorScheme.Kind] = &Result{
+			Success: false,
+			Stdout:  []string{},
+			Stderr:  []string{},
+		}
+	}
+
+	// Template
+	files := cbctemplate.Files{}
+	for _, fileSpecification := range this.scheme.Files {
+		files[fileSpecification.Path] = fileSpecification.Content
+	}
+
+	values := cbctemplate.Values{}
+	for _, valueSpecification := range this.scheme.Variables {
+		values[valueSpecification.Key] = valueSpecification.Value
+	}
+
+	path, err := cbctemplate.Copy(
+		filepath.Join("template", this.scheme.Template),
+		files,
+		values,
+	)
+	if err != nil {
+		return result
+	} else {
+		result.Template = true
+	}
+	defer cbctemplate.Delete(path)
+
+	// Preproces
+	for _, preprocessorScheme := range this.scheme.Preprocessors {
+		preprocessor, err := cbcpreprocessor.NewPreprocessor(preprocessorScheme)
+		if err != nil {
+			this.global.Log.Error("can't preprocess task", fw.LogError(err))
+			return result
+		}
+
+		stdout, stderr, err := preprocessor.Process(path)
+		result.Preprocessors[preprocessorScheme.Kind].Stdout = strings.Split(string(stdout), "\n")
+		result.Preprocessors[preprocessorScheme.Kind].Stderr = strings.Split(string(stderr), "\n")
+
+		if err != nil {
+			this.global.Log.Error("can't preprocess task", fw.LogError(err))
+			return result
+		} else {
+			result.Preprocessors[preprocessorScheme.Kind].Success = true
+		}
+	}
+
+	// Image
+	image := cbcdocker.NewImage(
+		this.global.Ctx,
+		this.global.Log,
+		this.global.Docker,
+		this.global.Registry,
+		this.scheme.Image.Tag,
+		this.scheme.Image.Version,
+		this.scheme.Image.Build,
+		this.scheme.Image.Push,
+	)
+
+	// Vendor
+	// TODO: ...
+
+	// Build
+	stdout, err := image.Build(this.scheme.Timeout.Build, path)
+	result.Build.Stdout = strings.Split(string(stdout), "\n")
+	result.Build.Stderr = []string{}
+
+	if err != nil {
+		this.global.Log.Error("can't build image", fw.LogError(err))
+		return result
+	} else {
+		result.Build.Success = true
+	}
+
+	// Push
+	stdout, err = image.Push(this.scheme.Timeout.Push)
+	result.Push.Stdout = strings.Split(string(stdout), "\n")
+	result.Push.Stderr = []string{}
+
+	if err != nil {
+		this.global.Log.Error("can't push image", fw.LogError(err))
+		return result
+	} else {
+		result.Push.Success = true
+	}
+
+	// Done
+	result.Success = true
+	return result
+}

+ 15 - 0
executor/task_result.go

@@ -0,0 +1,15 @@
+package executor
+
+type Result struct {
+	Success bool     `json:"success"`
+	Stdout  []string `json:"stdout"`
+	Stderr  []string `json:"stderr"`
+}
+
+type TaskResult struct {
+	Success       bool               `json:"success"`
+	Template      bool               `json:"template"`
+	Preprocessors map[string]*Result `json:"preprocessors"`
+	Build         Result             `json:"build"`
+	Push          Result             `json:"push"`
+}

+ 126 - 0
executor/tvm.go

@@ -0,0 +1,126 @@
+package executor
+
+import (
+	"errors"
+	"sync"
+
+	"github.com/google/uuid"
+)
+
+var ErrTicketPoolFull = errors.New("ticket pool full")
+var ErrTicketNotTaken = errors.New("ticket not taken")
+var ErrTicketNotFound = errors.New("ticket not found")
+
+type Ticket struct {
+	Index int
+	UUID  string
+	Taken bool
+}
+
+type TVM struct {
+	mutex   sync.Mutex
+	tickets map[int]*Ticket
+	size    int
+}
+
+func NewTVM(size int) *TVM {
+	tickets := map[int]*Ticket{}
+	for i := 0; i < size; i++ {
+		tickets[i] = &Ticket{
+			Index: i,
+			UUID:  "",
+			Taken: false,
+		}
+	}
+
+	return &TVM{
+		tickets: tickets,
+		size:    size,
+	}
+}
+
+func (this *TVM) AcquireTicket() (*Ticket, error) {
+	this.mutex.Lock()
+	defer this.mutex.Unlock()
+
+	for i := range len(this.tickets) {
+		if this.tickets[i].Taken {
+			continue
+		}
+
+		this.tickets[i].UUID = uuid.New().String()
+		this.tickets[i].Taken = true
+		return this.tickets[i], nil
+	}
+
+	return nil, ErrTicketPoolFull
+}
+
+func (this *TVM) ReleaseTicket(UUID string) error {
+	this.mutex.Lock()
+	defer this.mutex.Unlock()
+
+	for index, ticket := range this.tickets {
+		if ticket.UUID != UUID {
+			continue
+		}
+
+		if !ticket.Taken {
+			return ErrTicketNotTaken
+		}
+
+		this.tickets[index].UUID = ""
+		this.tickets[index].Taken = false
+		return nil
+	}
+
+	return ErrTicketNotFound
+}
+
+func (this *TVM) FindTicket(UUID string) (*Ticket, error) {
+	this.mutex.Lock()
+	defer this.mutex.Unlock()
+
+	for _, ticket := range this.tickets {
+		if ticket.UUID != UUID {
+			continue
+		}
+
+		if !ticket.Taken {
+			return nil, ErrTicketNotTaken
+		}
+
+		return ticket, nil
+	}
+
+	return nil, ErrTicketNotFound
+}
+
+func (this *TVM) Capacity() int {
+	this.mutex.Lock()
+	defer this.mutex.Unlock()
+
+	counter := 0
+	for i := range len(this.tickets) {
+		if !this.tickets[i].Taken {
+			counter += 1
+		}
+	}
+
+	return counter
+}
+
+func (this *TVM) HasTicket(UUID string) bool {
+	this.mutex.Lock()
+	defer this.mutex.Unlock()
+
+	for _, ticket := range this.tickets {
+		if ticket.UUID != UUID {
+			continue
+		}
+
+		return ticket.Taken
+	}
+
+	return false
+}

+ 39 - 0
go.mod

@@ -0,0 +1,39 @@
+module git.buran.team/main/cbc
+
+go 1.26.3
+
+require (
+	git.buran.team/main/fairwind v0.0.0-20260606033541-7899270ab8ca
+	github.com/google/uuid v1.6.0
+	github.com/moby/go-archive v0.2.0
+	github.com/moby/moby/api v1.54.2
+	github.com/moby/moby/client v0.4.1
+)
+
+require (
+	github.com/Microsoft/go-winio v0.6.2 // indirect
+	github.com/containerd/errdefs v1.0.0 // indirect
+	github.com/containerd/errdefs/pkg v0.3.0 // indirect
+	github.com/containerd/log v0.1.0 // indirect
+	github.com/distribution/reference v0.6.0 // indirect
+	github.com/docker/go-connections v0.7.0 // indirect
+	github.com/docker/go-units v0.5.0 // indirect
+	github.com/felixge/httpsnoop v1.0.4 // indirect
+	github.com/go-logr/logr v1.4.2 // indirect
+	github.com/go-logr/stdr v1.2.2 // indirect
+	github.com/klauspost/compress v1.18.2 // indirect
+	github.com/moby/docker-image-spec v1.3.1 // indirect
+	github.com/moby/patternmatcher v0.6.0 // indirect
+	github.com/moby/sys/sequential v0.6.0 // indirect
+	github.com/moby/sys/user v0.4.0 // indirect
+	github.com/moby/sys/userns v0.1.0 // indirect
+	github.com/opencontainers/go-digest v1.0.0 // indirect
+	github.com/opencontainers/image-spec v1.1.1 // indirect
+	github.com/sirupsen/logrus v1.9.3 // indirect
+	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
+	go.opentelemetry.io/otel v1.35.0 // indirect
+	go.opentelemetry.io/otel/metric v1.35.0 // indirect
+	go.opentelemetry.io/otel/trace v1.35.0 // indirect
+	golang.org/x/sys v0.33.0 // indirect
+)

+ 87 - 0
go.sum

@@ -0,0 +1,87 @@
+git.buran.team/main/fairwind v0.0.0-20260606033541-7899270ab8ca h1:z7QeP6STsIXXqKkwxBrhQh/p+akb1x34OE07En4NT9M=
+git.buran.team/main/fairwind v0.0.0-20260606033541-7899270ab8ca/go.mod h1:1w12qzBrANoxepbCn3vmQpxksWbtbmMK53/xAct7Biw=
+github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
+github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
+github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
+github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
+github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
+github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
+github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
+github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
+github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
+github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
+github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
+github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
+github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
+github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
+github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
+github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
+github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
+github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
+github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
+github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
+github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
+github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
+github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
+github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
+github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
+github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
+github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
+github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
+github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
+github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
+github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
+go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
+go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
+go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
+go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
+go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
+go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
+go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
+go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
+go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
+go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
+go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
+golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
+gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
+pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
+pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=

+ 37 - 0
helper/execute.go

@@ -0,0 +1,37 @@
+package helper
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"os/exec"
+	"strings"
+)
+
+var ErrCommandEmpty = errors.New("command empty")
+
+func Execute(command string) ([]byte, []byte, error) {
+	parts := strings.Split(command, " ")
+	if len(parts) == 0 {
+		return nil, nil, ErrCommandEmpty
+	}
+
+	var cmd *exec.Cmd
+	if len(parts) == 1 {
+		cmd = exec.Command(parts[0])
+	} else {
+		cmd = exec.Command(parts[0], parts[1:]...)
+	}
+
+	var stdout bytes.Buffer
+	var stderr bytes.Buffer
+	cmd.Stdout = &stdout
+	cmd.Stderr = &stderr
+
+	err := cmd.Run()
+	if err != nil {
+		return nil, nil, fmt.Errorf("execute failed: %w", err)
+	}
+
+	return stdout.Bytes(), stderr.Bytes(), err
+}

+ 40 - 0
helper/path.go

@@ -0,0 +1,40 @@
+package helper
+
+import (
+	"fmt"
+	"os"
+	"os/user"
+	"strings"
+)
+
+func PathNormalize(path string) string {
+	path = strings.Trim(path, " ")
+
+	if len(path) == 0 {
+		return path
+	}
+
+	if path[0] == '/' {
+		return path
+	}
+
+	// Expand home path
+	if path[0] == '~' {
+		u, err := user.Current()
+		if err != nil {
+			// TODO: log
+			return path
+		}
+
+		return fmt.Sprintf("%s%s", u.HomeDir, path[1:])
+	}
+
+	// Expand relative path
+	d, err := os.Getwd()
+	if err != nil {
+		// TODO: log
+		return path
+	}
+
+	return fmt.Sprintf("%s/%s", d, path)
+}

+ 32 - 0
preprocessor/preprocessor.go

@@ -0,0 +1,32 @@
+package preprocessor
+
+import (
+	"errors"
+
+	cbcscheme "git.buran.team/main/cbc/scheme"
+)
+
+const PREPROCESSOR_KIND_GO_MOD_TIDY = "go_mod_tidy"
+const PREPROCESSOR_KIND_GO_MOD_VENDOR = "go_mod_vendor"
+const PREPROCESSOR_KIND_GO_FMT = "go_fmt"
+
+var ErrKindUnknown = errors.New("kind unknown")
+
+type Preprocessor interface {
+	Process(path string) ([]byte, []byte, error)
+}
+
+func NewPreprocessor(preprocessorScheme cbcscheme.TaskPreprocessor) (Preprocessor, error) {
+	switch preprocessorScheme.Kind {
+	case PREPROCESSOR_KIND_GO_MOD_TIDY:
+		return NewGoModTidyPreprocessor(preprocessorScheme)
+
+	case PREPROCESSOR_KIND_GO_MOD_VENDOR:
+		return NewGoModVendorPreprocessor(preprocessorScheme)
+
+	case PREPROCESSOR_KIND_GO_FMT:
+		return NewGoFmtPreprocessor(preprocessorScheme)
+	}
+
+	return nil, ErrKindUnknown
+}

+ 29 - 0
preprocessor/preprocessor_go_fmt.go

@@ -0,0 +1,29 @@
+package preprocessor
+
+import (
+	"fmt"
+
+	cbchelper "git.buran.team/main/cbc/helper"
+	cbcscheme "git.buran.team/main/cbc/scheme"
+)
+
+type GoFmtPreprocessor struct {
+}
+
+func NewGoFmtPreprocessor(preprocessorScheme cbcscheme.TaskPreprocessor) (*GoFmtPreprocessor, error) {
+	return &GoFmtPreprocessor{}, nil
+}
+
+func (this *GoFmtPreprocessor) Process(path string) ([]byte, []byte, error) {
+	stdout, stderr, err := cbchelper.Execute(
+		fmt.Sprintf(
+			"gofmt -e %s",
+			path,
+		),
+	)
+	if err != nil {
+		return nil, nil, fmt.Errorf("can't gofmt over program: %w", err)
+	}
+
+	return stdout, stderr, nil
+}

+ 29 - 0
preprocessor/preprocessor_go_mod_tidy.go

@@ -0,0 +1,29 @@
+package preprocessor
+
+import (
+	"fmt"
+
+	cbchelper "git.buran.team/main/cbc/helper"
+	cbcscheme "git.buran.team/main/cbc/scheme"
+)
+
+type GoModTidyPreprocessor struct {
+}
+
+func NewGoModTidyPreprocessor(preprocessorScheme cbcscheme.TaskPreprocessor) (*GoModTidyPreprocessor, error) {
+	return &GoModTidyPreprocessor{}, nil
+}
+
+func (this *GoModTidyPreprocessor) Process(path string) ([]byte, []byte, error) {
+	stdout, stderr, err := cbchelper.Execute(
+		fmt.Sprintf(
+			"go -C %s mod tidy",
+			path,
+		),
+	)
+	if err != nil {
+		return nil, nil, fmt.Errorf("can't go mod tidy over program: %w", err)
+	}
+
+	return stdout, stderr, nil
+}

+ 29 - 0
preprocessor/preprocessor_go_mod_vendor.go

@@ -0,0 +1,29 @@
+package preprocessor
+
+import (
+	"fmt"
+
+	cbchelper "git.buran.team/main/cbc/helper"
+	cbcscheme "git.buran.team/main/cbc/scheme"
+)
+
+type GoModVendorPreprocessor struct {
+}
+
+func NewGoModVendorPreprocessor(preprocessorScheme cbcscheme.TaskPreprocessor) (*GoModVendorPreprocessor, error) {
+	return &GoModVendorPreprocessor{}, nil
+}
+
+func (this *GoModVendorPreprocessor) Process(path string) ([]byte, []byte, error) {
+	stdout, stderr, err := cbchelper.Execute(
+		fmt.Sprintf(
+			"go -C %s mod vendor",
+			path,
+		),
+	)
+	if err != nil {
+		return nil, nil, fmt.Errorf("can't go mod vendor over program: %w", err)
+	}
+
+	return stdout, stderr, nil
+}

+ 7 - 0
scheme/registry.go

@@ -0,0 +1,7 @@
+package scheme
+
+type Registry struct {
+	Address  string `json:"address" yaml:"address"`
+	Login    string `json:"login" yaml:"login"`
+	Password string `json:"password" yaml:"password"`
+}

+ 36 - 0
scheme/task.go

@@ -0,0 +1,36 @@
+package scheme
+
+type TaskVariable struct {
+	Key   string `json:"key" yaml:"key"`
+	Value string `json:"value" yaml:"value"`
+}
+
+type TaskFile struct {
+	Path    string `json:"path" yaml:"path"`
+	Content string `json:"content" yaml:"content"`
+}
+
+type TaskPreprocessor struct {
+	Kind string `json:"kind" yaml:"kind"`
+}
+
+type TaskImage struct {
+	Tag     string `json:"tag" yaml:"tag"`
+	Version string `json:"version" yaml:"version"`
+	Build   bool   `json:"build" yaml:"build"`
+	Push    bool   `json:"push" yam:"push"`
+}
+
+type TaskTimeout struct {
+	Build int `json:"build" yaml:"build"`
+	Push  int `json:"push" yaml:"push"`
+}
+
+type Task struct {
+	Template      string             `json:"template" yaml:"template"`
+	Variables     []TaskVariable     `json:"variables" yaml:"variables"`
+	Files         []TaskFile         `json:"files" yaml:"files"`
+	Preprocessors []TaskPreprocessor `json:"preprocessors" yaml:"preprocessors"`
+	Image         TaskImage          `json:"image" yaml:"image"`
+	Timeout       TaskTimeout        `json:"timeout" yaml:"timeout"`
+}

+ 148 - 0
template/template.go

@@ -0,0 +1,148 @@
+package template
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"strings"
+	"text/template"
+
+	helperpkg "git.buran.team/main/cbc/helper"
+)
+
+var ErrDockerfileNotExists = errors.New("dockerfile not exists")
+
+type Files map[string]string
+type Values map[string]any
+
+func Copy(path string, files Files, values Values) (string, error) {
+	// Create temporary directoy
+	sourceDirectory := helperpkg.PathNormalize(path)
+	targetDirectory, err := os.MkdirTemp("/tmp", "cbc-*")
+	if err != nil {
+		return "", fmt.Errorf("can't create directory: %w", err)
+	}
+
+	// Template dockerfile
+	sourceDockerfilePath := filepath.Join(sourceDirectory, "Dockerfile")
+	targetDockerfilePath := filepath.Join(targetDirectory, "Dockerfile")
+	if !fileExists(sourceDockerfilePath) {
+		return "", ErrDockerfileNotExists
+	}
+
+	err = fileTemplate(sourceDockerfilePath, targetDockerfilePath, values)
+	if err != nil {
+		return "", fmt.Errorf("can't template file: %w", err)
+	}
+
+	// Template content
+	sourceCodeDirectory := filepath.Join(sourceDirectory, "content")
+	filepath.WalkDir(
+		sourceCodeDirectory,
+		func(path string, entry fs.DirEntry, err error) error {
+			if !entry.Type().IsRegular() {
+				return nil
+			}
+
+			err = fileTemplate(
+				path,
+				filepath.Join(
+					targetDirectory,
+					strings.Replace(path, sourceCodeDirectory, "", 1),
+				),
+				values,
+			)
+			if err != nil {
+				return fmt.Errorf("can't template file: %w", err)
+			}
+
+			return nil
+		},
+	)
+
+	// Create files
+	for filePath, fileContent := range files {
+		err := fileCreate(
+			filepath.Join(
+				targetDirectory,
+				filePath,
+			),
+			[]byte(fileContent),
+		)
+		if err != nil {
+			return "", fmt.Errorf("can't create file: %w", err)
+		}
+	}
+
+	return targetDirectory, nil
+}
+
+func Delete(path string) error {
+	err := os.RemoveAll(path)
+	if err != nil {
+		return fmt.Errorf("can't delete temporary directory: %w", err)
+	}
+
+	return nil
+}
+
+func fileTemplate(sourcePath string, targetPath string, values Values) error {
+	content, err := templateRender(sourcePath, values)
+	if err != nil {
+		return fmt.Errorf("can't render file: %w", err)
+	}
+
+	err = fileCreate(targetPath, content)
+	if err != nil {
+		return fmt.Errorf("can't create file: %w", err)
+	}
+
+	return nil
+}
+
+func fileExists(path string) bool {
+	_, err := os.Stat(path)
+	if err == os.ErrNotExist {
+		return false
+	}
+
+	return true
+}
+
+func fileCreate(path string, content []byte) error {
+	directory := filepath.Dir(path)
+	err := os.MkdirAll(directory, 0o700)
+	if err != nil {
+		return fmt.Errorf("can't create destination directory: %w", err)
+	}
+
+	err = os.WriteFile(path, content, 0o600)
+	if err != nil {
+		return fmt.Errorf("can't write destination file: %w", err)
+	}
+
+	return nil
+}
+
+func templateRender(path string, values Values) ([]byte, error) {
+	file, err := os.ReadFile(path)
+	if err != nil {
+		return nil, fmt.Errorf("can't read template: %w", err)
+	}
+
+	var buffer bytes.Buffer
+	template, err := template.New("").Parse(string(file))
+	if err != nil {
+		return nil, fmt.Errorf("can't create template: %w", err)
+	}
+
+	err = template.Execute(&buffer, values)
+	if err != nil {
+		return nil, fmt.Errorf("can't render template: %w", err)
+	}
+
+	return buffer.Bytes(), err
+}