A self-hosted orchestration system crafted for power users. composia.xyz
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655package agent
import ( "archive/tar" "bytes" "compress/gzip" "context" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time"
"connectrpc.com/connect" agentv1 "forgejo.alexma.top/alexma233/composia/gen/go/proto/composia/agent/v1" "forgejo.alexma.top/alexma233/composia/gen/go/proto/composia/agent/v1/agentv1connect" "forgejo.alexma.top/alexma233/composia/internal/core/config" "forgejo.alexma.top/alexma233/composia/internal/core/task" "forgejo.alexma.top/alexma233/composia/internal/platform/rpcutil")
func TestDownloadServiceBundleExtractsIntoRepoDir(t *testing.T) { t.Parallel()
rootDir := t.TempDir() cfg := &config.AgentConfig{RepoDir: filepath.Join(rootDir, "repo"), StateDir: filepath.Join(rootDir, "state")} if err := os.MkdirAll(cfg.RepoDir, 0o750); err != nil { t.Fatalf("create repo dir: %v", err) } if err := os.MkdirAll(cfg.StateDir, 0o750); err != nil { t.Fatalf("create state dir: %v", err) }
bundle := buildBundleArchive(t, map[string]string{"demo/composia-meta.yaml": "name: demo\n"}) mux := http.NewServeMux() path, handler := agentv1connect.NewBundleServiceHandler(bundleTestServer{bundle: bundle}, connect.WithInterceptors(rpcutil.NewServerBearerAuthInterceptor(func(token string) (string, error) { if token != "main-token" { //nolint:goconst return "", errString("unexpected token") } return "main", nil //nolint:goconst }))) mux.Handle(path, handler) httpServer := httptest.NewServer(mux) defer httpServer.Close()
client := agentv1connect.NewBundleServiceClient(httpServer.Client(), httpServer.URL, connect.WithInterceptors(rpcutil.NewStaticBearerAuthInterceptor("main-token"))) result, err := downloadServiceBundle(context.Background(), client, cfg, "task-1", "") if err != nil { t.Fatalf("download service bundle: %v", err) } if result.RelativeRoot != "demo" || result.RootPath != filepath.Join(cfg.RepoDir, "demo") { t.Fatalf("unexpected bundle result: %+v", result) }
content, err := os.ReadFile(filepath.Join(cfg.RepoDir, "demo", "composia-meta.yaml")) if err != nil { t.Fatalf("read extracted file: %v", err) } if string(content) != "name: demo\n" { t.Fatalf("unexpected extracted content %q", string(content)) }}
func TestDownloadServiceBundleReplacesExistingDirectoryAtomically(t *testing.T) { t.Parallel()
rootDir := t.TempDir() cfg := &config.AgentConfig{RepoDir: filepath.Join(rootDir, "repo"), StateDir: filepath.Join(rootDir, "state")} if err := os.MkdirAll(filepath.Join(cfg.RepoDir, "demo"), 0o750); err != nil { t.Fatalf("create repo dir: %v", err) } if err := os.MkdirAll(cfg.StateDir, 0o750); err != nil { t.Fatalf("create state dir: %v", err) } if err := os.WriteFile(filepath.Join(cfg.RepoDir, "demo", "composia-meta.yaml"), []byte("name: old\n"), 0o600); err != nil { t.Fatalf("write old file: %v", err) }
bundle := buildBundleArchive(t, map[string]string{"demo/composia-meta.yaml": "name: new\n", "demo/docker-compose.yaml": "services: {}\n"}) mux := http.NewServeMux() path, handler := agentv1connect.NewBundleServiceHandler(bundleTestServer{bundle: bundle}, connect.WithInterceptors(rpcutil.NewServerBearerAuthInterceptor(func(token string) (string, error) { if token != "main-token" { return "", errString("unexpected token") } return "main", nil }))) mux.Handle(path, handler) httpServer := httptest.NewServer(mux) defer httpServer.Close()
client := agentv1connect.NewBundleServiceClient(httpServer.Client(), httpServer.URL, connect.WithInterceptors(rpcutil.NewStaticBearerAuthInterceptor("main-token"))) if _, err := downloadServiceBundle(context.Background(), client, cfg, "task-1", ""); err != nil { t.Fatalf("download service bundle: %v", err) } content, err := os.ReadFile(filepath.Join(cfg.RepoDir, "demo", "composia-meta.yaml")) if err != nil { t.Fatalf("read replaced file: %v", err) } if string(content) != "name: new\n" { t.Fatalf("unexpected replaced content %q", string(content)) }}
func TestDownloadServiceBundlePreservesExistingDirectoryOnInvalidArchive(t *testing.T) { t.Parallel()
rootDir := t.TempDir() cfg := &config.AgentConfig{RepoDir: filepath.Join(rootDir, "repo"), StateDir: filepath.Join(rootDir, "state")} if err := os.MkdirAll(filepath.Join(cfg.RepoDir, "demo"), 0o750); err != nil { t.Fatalf("create repo dir: %v", err) } if err := os.MkdirAll(cfg.StateDir, 0o750); err != nil { t.Fatalf("create state dir: %v", err) } if err := os.WriteFile(filepath.Join(cfg.RepoDir, "demo", "composia-meta.yaml"), []byte("name: old\n"), 0o600); err != nil { t.Fatalf("write old file: %v", err) }
invalidBundle := buildBundleArchive(t, map[string]string{"other/composia-meta.yaml": "name: other\n"}) mux := http.NewServeMux() path, handler := agentv1connect.NewBundleServiceHandler(bundleTestServer{bundle: invalidBundle}, connect.WithInterceptors(rpcutil.NewServerBearerAuthInterceptor(func(token string) (string, error) { if token != "main-token" { return "", errString("unexpected token") } return "main", nil }))) mux.Handle(path, handler) httpServer := httptest.NewServer(mux) defer httpServer.Close()
client := agentv1connect.NewBundleServiceClient(httpServer.Client(), httpServer.URL, connect.WithInterceptors(rpcutil.NewStaticBearerAuthInterceptor("main-token"))) if _, err := downloadServiceBundle(context.Background(), client, cfg, "task-1", ""); err == nil { t.Fatalf("expected invalid bundle download to fail") } content, err := os.ReadFile(filepath.Join(cfg.RepoDir, "demo", "composia-meta.yaml")) if err != nil { t.Fatalf("read preserved file: %v", err) } if string(content) != "name: old\n" { t.Fatalf("expected old content to remain, got %q", string(content)) }}
func TestDownloadServiceBundleIgnoresPAXHeaders(t *testing.T) { t.Parallel()
rootDir := t.TempDir() cfg := &config.AgentConfig{RepoDir: filepath.Join(rootDir, "repo"), StateDir: filepath.Join(rootDir, "state")} if err := os.MkdirAll(cfg.RepoDir, 0o750); err != nil { t.Fatalf("create repo dir: %v", err) } if err := os.MkdirAll(cfg.StateDir, 0o750); err != nil { t.Fatalf("create state dir: %v", err) }
bundle := buildBundleArchiveWithPAXHeader(t, map[string]string{"demo/composia-meta.yaml": "name: demo\n"}) mux := http.NewServeMux() path, handler := agentv1connect.NewBundleServiceHandler(bundleTestServer{bundle: bundle}, connect.WithInterceptors(rpcutil.NewServerBearerAuthInterceptor(func(token string) (string, error) { if token != "main-token" { return "", errString("unexpected token") } return "main", nil }))) mux.Handle(path, handler) httpServer := httptest.NewServer(mux) defer httpServer.Close()
client := agentv1connect.NewBundleServiceClient(httpServer.Client(), httpServer.URL, connect.WithInterceptors(rpcutil.NewStaticBearerAuthInterceptor("main-token"))) result, err := downloadServiceBundle(context.Background(), client, cfg, "task-1", "") if err != nil { t.Fatalf("download service bundle with PAX header: %v", err) } if result.RootPath != filepath.Join(cfg.RepoDir, "demo") { t.Fatalf("unexpected bundle result: %+v", result) }}
func TestExtractTarStreamRejectsSymlink(t *testing.T) { t.Parallel()
archive := buildTarArchiveWithHeaders(t, []*tar.Header{ {Name: "demo/link", Typeflag: tar.TypeSymlink, Linkname: "../outside", Mode: 0o777}, }) if err := extractTarStream(bytes.NewReader(archive), t.TempDir()); err == nil { t.Fatalf("expected symlink tar entry to fail") }}
func TestExtractTarStreamRejectsHardlink(t *testing.T) { t.Parallel()
archive := buildTarArchiveWithHeaders(t, []*tar.Header{ {Name: "demo/file", Typeflag: tar.TypeReg, Mode: 0o644, Size: int64(len("content"))}, {Name: "demo/hardlink", Typeflag: tar.TypeLink, Linkname: "demo/file", Mode: 0o644}, }) if err := extractTarStream(bytes.NewReader(archive), t.TempDir()); err == nil { t.Fatalf("expected hardlink tar entry to fail") }}
func TestDownloadServiceBundleSendsServiceDirOverride(t *testing.T) { t.Parallel()
rootDir := t.TempDir() cfg := &config.AgentConfig{RepoDir: filepath.Join(rootDir, "repo"), StateDir: filepath.Join(rootDir, "state")} if err := os.MkdirAll(cfg.RepoDir, 0o750); err != nil { t.Fatalf("create repo dir: %v", err) } if err := os.MkdirAll(cfg.StateDir, 0o750); err != nil { t.Fatalf("create state dir: %v", err) }
bundle := buildBundleArchive(t, map[string]string{"bravo/composia-meta.yaml": "name: bravo\n"}) mux := http.NewServeMux() path, handler := agentv1connect.NewBundleServiceHandler(bundleTestServer{bundle: bundle, expectedServiceDir: "bravo", responseServiceName: "bravo", responseRelativeRoot: "bravo"}, connect.WithInterceptors(rpcutil.NewServerBearerAuthInterceptor(func(token string) (string, error) { if token != "main-token" { return "", errString("unexpected token") } return "main", nil }))) mux.Handle(path, handler) httpServer := httptest.NewServer(mux) defer httpServer.Close()
client := agentv1connect.NewBundleServiceClient(httpServer.Client(), httpServer.URL, connect.WithInterceptors(rpcutil.NewStaticBearerAuthInterceptor("main-token"))) result, err := downloadServiceBundle(context.Background(), client, cfg, "task-1", "bravo") if err != nil { t.Fatalf("download overridden service bundle: %v", err) } if result.RelativeRoot != "bravo" || result.RootPath != filepath.Join(cfg.RepoDir, "bravo") { t.Fatalf("unexpected overridden bundle result: %+v", result) }}
func TestDownloadServiceBundleRejectsEscapingRelativeRoot(t *testing.T) { t.Parallel()
rootDir := t.TempDir() cfg := &config.AgentConfig{RepoDir: filepath.Join(rootDir, "repo"), StateDir: filepath.Join(rootDir, "state")} if err := os.MkdirAll(cfg.RepoDir, 0o750); err != nil { t.Fatalf("create repo dir: %v", err) } if err := os.MkdirAll(cfg.StateDir, 0o750); err != nil { t.Fatalf("create state dir: %v", err) }
bundle := buildBundleArchive(t, map[string]string{"outside/composia-meta.yaml": "name: outside\n"}) mux := http.NewServeMux() path, handler := agentv1connect.NewBundleServiceHandler(bundleTestServer{bundle: bundle, responseRelativeRoot: "../outside"}, connect.WithInterceptors(rpcutil.NewServerBearerAuthInterceptor(func(token string) (string, error) { if token != "main-token" { return "", errString("unexpected token") } return "main", nil }))) mux.Handle(path, handler) httpServer := httptest.NewServer(mux) defer httpServer.Close()
client := agentv1connect.NewBundleServiceClient(httpServer.Client(), httpServer.URL, connect.WithInterceptors(rpcutil.NewStaticBearerAuthInterceptor("main-token"))) if _, err := downloadServiceBundle(context.Background(), client, cfg, "task-1", ""); err == nil || !strings.Contains(err.Error(), "escapes repo root") { t.Fatalf("expected escaping relative_root error, got %v", err) } if _, err := os.Stat(filepath.Join(rootDir, "outside")); !os.IsNotExist(err) { t.Fatalf("expected outside path to stay absent, got stat err=%v", err) }}
func TestLocalServiceRootRejectsEscapingServiceDir(t *testing.T) { t.Parallel()
rootDir := t.TempDir() repoDir := filepath.Join(rootDir, "repo") if err := os.MkdirAll(repoDir, 0o750); err != nil { t.Fatalf("create repo dir: %v", err) } if err := os.MkdirAll(filepath.Join(rootDir, "outside"), 0o750); err != nil { t.Fatalf("create outside dir: %v", err) } if err := os.WriteFile(filepath.Join(rootDir, "outside", "composia-meta.yaml"), []byte("name: outside\n"), 0o600); err != nil { t.Fatalf("write outside meta: %v", err) }
pulledTask := &agentv1.AgentTask{TaskId: "task-1", Type: protoAgentTaskType(task.TypeDeploy), ServiceName: "outside", ServiceDir: "../outside"} if _, err := localServiceRoot(repoDir, pulledTask, nil); err == nil || !strings.Contains(err.Error(), "escapes repo root") { t.Fatalf("expected escaping service_dir error, got %v", err) }}
func buildTarArchiveWithHeaders(t *testing.T, headers []*tar.Header) []byte { t.Helper() buffer := bytes.Buffer{} tarWriter := tar.NewWriter(&buffer) for _, header := range headers { if err := tarWriter.WriteHeader(header); err != nil { t.Fatalf("write tar header: %v", err) } if header.Typeflag == tar.TypeReg && header.Size > 0 { if _, err := tarWriter.Write(bytes.Repeat([]byte("x"), int(header.Size))); err != nil { t.Fatalf("write tar content: %v", err) } } } if err := tarWriter.Close(); err != nil { t.Fatalf("close tar writer: %v", err) } return buffer.Bytes()}
type bundleTestServer struct { bundle []byte bundlesByServiceDir map[string]bundleTestResponse expectedTaskID string expectedServiceDir string responseServiceName string responseRelativeRoot string}
type bundleTestResponse struct { bundle []byte serviceName string relativeRoot string}
func (server bundleTestServer) GetServiceBundle(_ context.Context, req *connect.Request[agentv1.GetServiceBundleRequest], stream *connect.ServerStream[agentv1.GetServiceBundleResponse]) error { expectedTaskID := server.expectedTaskID if expectedTaskID == "" { expectedTaskID = "task-1" } if req.Msg.GetTaskId() != expectedTaskID { return errString("unexpected task id") } if server.expectedServiceDir != "" && req.Msg.GetServiceDir() != server.expectedServiceDir { return errString("unexpected service dir") } bundle := server.bundle serviceName := server.responseServiceName relativeRoot := server.responseRelativeRoot if len(server.bundlesByServiceDir) > 0 { response, ok := server.bundlesByServiceDir[req.Msg.GetServiceDir()] if !ok { return errString("unexpected service dir") } bundle = response.bundle serviceName = response.serviceName relativeRoot = response.relativeRoot } if serviceName == "" { serviceName = "demo" } if relativeRoot == "" { relativeRoot = "demo" } firstChunk := &agentv1.GetServiceBundleResponse{ServiceName: serviceName, RepoRevision: "deadbeef", RelativeRoot: relativeRoot, Data: bundle[:len(bundle)/2]} secondChunk := &agentv1.GetServiceBundleResponse{Data: bundle[len(bundle)/2:]} if err := stream.Send(firstChunk); err != nil { return err } return stream.Send(secondChunk)}
func buildBundleArchive(t *testing.T, files map[string]string) []byte { t.Helper() buffer := bytes.Buffer{} gzipWriter := gzip.NewWriter(&buffer) tarWriter := tar.NewWriter(gzipWriter) for name, content := range files { body := []byte(content) header := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))} if err := tarWriter.WriteHeader(header); err != nil { t.Fatalf("write tar header: %v", err) } if _, err := tarWriter.Write(body); err != nil { t.Fatalf("write tar content: %v", err) } } if err := tarWriter.Close(); err != nil { t.Fatalf("close tar writer: %v", err) } if err := gzipWriter.Close(); err != nil { t.Fatalf("close gzip writer: %v", err) } return buffer.Bytes()}
func buildBundleArchiveWithPAXHeader(t *testing.T, files map[string]string) []byte { t.Helper() buffer := bytes.Buffer{} gzipWriter := gzip.NewWriter(&buffer) tarWriter := tar.NewWriter(gzipWriter) paxHeader := &tar.Header{Typeflag: tar.TypeXGlobalHeader, PAXRecords: map[string]string{"comment": "bundle metadata"}} if err := tarWriter.WriteHeader(paxHeader); err != nil { t.Fatalf("write pax tar header: %v", err) } for name, content := range files { body := []byte(content) header := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(body))} if err := tarWriter.WriteHeader(header); err != nil { t.Fatalf("write tar header: %v", err) } if _, err := tarWriter.Write(body); err != nil { t.Fatalf("write tar content: %v", err) } } if err := tarWriter.Close(); err != nil { t.Fatalf("close tar writer: %v", err) } if err := gzipWriter.Close(); err != nil { t.Fatalf("close gzip writer: %v", err) } return buffer.Bytes()}
func TestRunComposeUpUsesProjectNameAndServiceDir(t *testing.T) { rootDir := t.TempDir() binDir := filepath.Join(rootDir, "bin") serviceDir := filepath.Join(rootDir, "service") argsFile := filepath.Join(rootDir, "args.txt") pwdFile := filepath.Join(rootDir, "pwd.txt") if err := os.MkdirAll(binDir, 0o750); err != nil { t.Fatalf("create bin dir: %v", err) } if err := os.MkdirAll(serviceDir, 0o750); err != nil { t.Fatalf("create service dir: %v", err) } dockerPath := filepath.Join(binDir, "docker") script := "#!/bin/sh\npwd > \"$TEST_PWD_FILE\"\nprintf '%s ' \"$@\" > \"$TEST_ARGS_FILE\"\n" //nolint:goconst if err := os.WriteFile(dockerPath, []byte(script), 0o755); err != nil { //nolint:gosec t.Fatalf("write fake docker script: %v", err) } t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("TEST_ARGS_FILE", argsFile) t.Setenv("TEST_PWD_FILE", pwdFile)
compose := composeCommandConfig{ProjectName: "demo-project"} if err := runComposeUp(context.Background(), serviceDir, compose, func(string) error { return nil }); err != nil { t.Fatalf("run compose up: %v", err) }
argsContent, err := os.ReadFile(argsFile) //nolint:gosec if err != nil { t.Fatalf("read args file: %v", err) } if string(argsContent) != "compose --project-name demo-project up -d " { t.Fatalf("unexpected docker args %q", string(argsContent)) } pwdContent, err := os.ReadFile(pwdFile) //nolint:gosec if err != nil { t.Fatalf("read pwd file: %v", err) } if string(bytes.TrimSpace(pwdContent)) != serviceDir { t.Fatalf("expected docker cwd %q, got %q", serviceDir, string(bytes.TrimSpace(pwdContent))) }}
func TestRunComposeDownUsesProjectNameAndServiceDir(t *testing.T) { rootDir := t.TempDir() binDir := filepath.Join(rootDir, "bin") serviceDir := filepath.Join(rootDir, "service") argsFile := filepath.Join(rootDir, "args.txt") pwdFile := filepath.Join(rootDir, "pwd.txt") if err := os.MkdirAll(binDir, 0o750); err != nil { t.Fatalf("create bin dir: %v", err) } if err := os.MkdirAll(serviceDir, 0o750); err != nil { t.Fatalf("create service dir: %v", err) } dockerPath := filepath.Join(binDir, "docker") script := "#!/bin/sh\npwd > \"$TEST_PWD_FILE\"\nprintf '%s ' \"$@\" > \"$TEST_ARGS_FILE\"\n" if err := os.WriteFile(dockerPath, []byte(script), 0o755); err != nil { //nolint:gosec t.Fatalf("write fake docker script: %v", err) } t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("TEST_ARGS_FILE", argsFile) t.Setenv("TEST_PWD_FILE", pwdFile)
compose := composeCommandConfig{ProjectName: "demo-project"} if err := runComposeDown(context.Background(), serviceDir, compose, func(string) error { return nil }); err != nil { t.Fatalf("run compose down: %v", err) }
argsContent, err := os.ReadFile(argsFile) //nolint:gosec if err != nil { t.Fatalf("read args file: %v", err) } if string(argsContent) != "compose --project-name demo-project down " { t.Fatalf("unexpected docker args %q", string(argsContent)) } pwdContent, err := os.ReadFile(pwdFile) //nolint:gosec if err != nil { t.Fatalf("read pwd file: %v", err) } if string(bytes.TrimSpace(pwdContent)) != serviceDir { t.Fatalf("expected docker cwd %q, got %q", serviceDir, string(bytes.TrimSpace(pwdContent))) }}
func TestRunComposePullUsesProjectNameAndServiceDir(t *testing.T) { rootDir := t.TempDir() binDir := filepath.Join(rootDir, "bin") serviceDir := filepath.Join(rootDir, "service") argsFile := filepath.Join(rootDir, "args.txt") envFile := filepath.Join(rootDir, "env.txt") pwdFile := filepath.Join(rootDir, "pwd.txt") if err := os.MkdirAll(binDir, 0o750); err != nil { t.Fatalf("create bin dir: %v", err) } if err := os.MkdirAll(serviceDir, 0o750); err != nil { t.Fatalf("create service dir: %v", err) } dockerPath := filepath.Join(binDir, "docker") script := "#!/bin/sh\npwd > \"$TEST_PWD_FILE\"\nprintf '%s ' \"$@\" > \"$TEST_ARGS_FILE\"\nprintf 'TERM=%s\\nCLICOLOR_FORCE=%s\\nFORCE_COLOR=%s\\nCOMPOSE_ANSI=%s\\nCOMPOSE_STATUS_STDOUT=%s\\nCOMPOSE_PROGRESS=%s\\n' \"$TERM\" \"$CLICOLOR_FORCE\" \"$FORCE_COLOR\" \"$COMPOSE_ANSI\" \"$COMPOSE_STATUS_STDOUT\" \"$COMPOSE_PROGRESS\" > \"$TEST_ENV_FILE\"\n" if err := os.WriteFile(dockerPath, []byte(script), 0o755); err != nil { //nolint:gosec t.Fatalf("write fake docker script: %v", err) } t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("TEST_ARGS_FILE", argsFile) t.Setenv("TEST_ENV_FILE", envFile) t.Setenv("TEST_PWD_FILE", pwdFile)
compose := composeCommandConfig{ProjectName: "demo-project"} if err := runComposePull(context.Background(), serviceDir, compose, func(string) error { return nil }); err != nil { t.Fatalf("run compose pull: %v", err) }
argsContent, err := os.ReadFile(argsFile) //nolint:gosec if err != nil { t.Fatalf("read args file: %v", err) } if string(argsContent) != "compose --project-name demo-project pull " { t.Fatalf("unexpected docker args %q", string(argsContent)) } pwdContent, err := os.ReadFile(pwdFile) //nolint:gosec if err != nil { t.Fatalf("read pwd file: %v", err) } if string(bytes.TrimSpace(pwdContent)) != serviceDir { t.Fatalf("expected docker cwd %q, got %q", serviceDir, string(bytes.TrimSpace(pwdContent))) } envContent, err := os.ReadFile(envFile) //nolint:gosec if err != nil { t.Fatalf("read env file: %v", err) } for _, expected := range []string{ "TERM=xterm-256color", "CLICOLOR_FORCE=1", "FORCE_COLOR=1", "COMPOSE_ANSI=always", "COMPOSE_STATUS_STDOUT=1", "COMPOSE_PROGRESS=tty", } { if !strings.Contains(string(envContent), expected) { t.Fatalf("expected %q in env, got %q", expected, string(envContent)) } }}
func TestRunComposeUpStreamsLogsBeforeCommandExit(t *testing.T) { rootDir := t.TempDir() binDir := filepath.Join(rootDir, "bin") serviceDir := filepath.Join(rootDir, "service") if err := os.MkdirAll(binDir, 0o750); err != nil { t.Fatalf("create bin dir: %v", err) } if err := os.MkdirAll(serviceDir, 0o750); err != nil { t.Fatalf("create service dir: %v", err) }
dockerPath := filepath.Join(binDir, "docker") script := "#!/bin/sh\nprintf 'starting compose up\\n'\nsleep 0.3\nprintf 'compose up finished\\n' >&2\n" if err := os.WriteFile(dockerPath, []byte(script), 0o755); err != nil { //nolint:gosec t.Fatalf("write fake docker script: %v", err) } t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
logsCh := make(chan string, 8) errCh := make(chan error, 1) go func() { errCh <- runComposeUp(context.Background(), serviceDir, composeCommandConfig{ProjectName: "demo-project"}, func(output string) error { logsCh <- output return nil }) }()
select { case output := <-logsCh: if !strings.Contains(output, "starting compose up") { t.Fatalf("expected first streamed chunk, got %q", output) } case err := <-errCh: t.Fatalf("run compose up finished before streaming logs: %v", err) case <-time.After(time.Second): t.Fatal("expected task logs before command exit") }
if err := <-errCh; err != nil { t.Fatalf("run compose up: %v", err) }}
func TestLoadComposeCommandConfigNormalizesFallbackServiceName(t *testing.T) { rootDir := t.TempDir() serviceDir := filepath.Join(rootDir, "service") if err := os.MkdirAll(serviceDir, 0o750); err != nil { t.Fatalf("create service dir: %v", err) } if err := os.WriteFile(filepath.Join(serviceDir, "composia-meta.yaml"), []byte("name: Renovate\nnodes:\n - main\n"), 0o600); err != nil { t.Fatalf("write service meta: %v", err) }
compose, meta, err := loadComposeCommandConfig(serviceDir, "Renovate") if err != nil { t.Fatalf("load compose command config: %v", err) } if compose.ProjectName != "renovate" { t.Fatalf("expected normalized project name, got %q", compose.ProjectName) } if meta.Name != "Renovate" { t.Fatalf("expected loaded meta name Renovate, got %q", meta.Name) } if len(compose.Files) != 0 { t.Fatalf("expected no compose files, got %+v", compose.Files) }}
func TestLoadComposeCommandConfigLoadsComposeFiles(t *testing.T) { rootDir := t.TempDir() serviceDir := filepath.Join(rootDir, "service") if err := os.MkdirAll(serviceDir, 0o750); err != nil { t.Fatalf("create service dir: %v", err) } meta := "name: demo\nproject_name: demo-stack\ncompose_files:\n - compose.yaml\n - compose.prod.yaml\nnodes:\n - main\n" if err := os.WriteFile(filepath.Join(serviceDir, "composia-meta.yaml"), []byte(meta), 0o600); err != nil { t.Fatalf("write service meta: %v", err) }
compose, _, err := loadComposeCommandConfig(serviceDir, "demo") if err != nil { t.Fatalf("load compose command config: %v", err) } if compose.ProjectName != "demo-stack" { t.Fatalf("expected configured project name, got %q", compose.ProjectName) } if got := strings.Join(compose.Files, ","); got != "compose.yaml,compose.prod.yaml" { t.Fatalf("unexpected compose files %q", got) }}
type errString string //nolint:errname
func (value errString) Error() string { return string(value)}