mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
* feat(templates): git compose import, six stack seeds, empty-default vars
- POST /templates/import/git fetches a compose file from any git remote
(clone URL, ssh, owner/repo via connected providers — private repos
included), ref selects branch or commit. Path defaults through
compose.yaml/yml then docker-compose.yml/yaml. Reuses the build-on-node
checkout machinery; repo-relative paths are containment-checked.
- CLI `templates import-git <repo> [path] [--ref]`, MCP
containr_templates_import_git, OpenAPI entry, and a paste/git toggle
in the template import dialog.
- {{VAR:-}} with an empty default now resolves instead of reporting the
var missing — matches shell :- semantics.
- SeedTemplates gains six official graph stacks — n8n (pg+redis+worker),
Gitea, Nextcloud, Vaultwarden, Umami, Immich — wired with
{{service.*}} refs and {{secret}} credentials; no hardcoded passwords.
- TestSeedTemplatesResolveCleanly parses, topo-sorts, validates, and
resolves every seeded graph — catalog drift can't merge silently.
- templates/n8n.md compose block had invalid YAML (mixed depends_on
list/map) — fixed so the documented stack actually parses.
Verified live: import/git on a dumb-HTTP repo returns the converted
graph (db image → managed postgresql), path/ref/traversal/unauth edges
return clean errors, tpl-n8n plan resolves with zero unresolved vars.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* ci: pre-pull compose-smoke images via gcr mirror
Anonymous Docker Hub pulls rate-limit shared CI egress IPs — the compose
boot smoke flaked on traefik:pull. postgres/traefik are library/* images
served by mirror.gcr.io without auth; compose reuses the retagged local
images, falling back to a normal pull when the mirror misses.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* ci: route docker.io through gcr mirror for compose smoke
Pull-time pre-pulls don't cover BuildKit's base-image metadata lookups
(node:22-alpine, nginx:alpine, golang:1.26-alpine all 429). Daemon-level
registry-mirrors covers pulls and resolution in one move.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* ci: mirror docker.io in buildx for image publish job
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* ci: route smoke pulls/builds through mirror.gcr.io via compose overlay
daemon.json registry-mirrors isn't honored by the runner's containerd
image store. Qualify refs directly: ARG-parameterized Dockerfile FROMs
(defaults unchanged) plus a CI-only compose overlay that points image
pulls and build args at mirror.gcr.io.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
168 lines
4.2 KiB
Go
168 lines
4.2 KiB
Go
package api
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
func composeToSpecsForTest(t *testing.T, y string) ([]TemplateServiceSpec, []string, error) {
|
|
t.Helper()
|
|
var file composeFileDef
|
|
if err := yaml.Unmarshal([]byte(y), &file); err != nil {
|
|
t.Fatalf("yaml: %v", err)
|
|
}
|
|
return composeToSpecs(&file)
|
|
}
|
|
|
|
func TestComposeToSpecsConvertsStack(t *testing.T) {
|
|
specs, warnings, err := composeToSpecsForTest(t, `services:
|
|
web:
|
|
image: traefik/whoami:latest
|
|
ports:
|
|
- "80"
|
|
environment:
|
|
GREETING: hello-compose
|
|
depends_on:
|
|
- db
|
|
db:
|
|
image: postgres:16-alpine
|
|
environment:
|
|
POSTGRES_PASSWORD: pgsecret123
|
|
POSTGRES_DB: appdb
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(specs) != 2 {
|
|
t.Fatalf("want 2 specs, got %d", len(specs))
|
|
}
|
|
db, web := specs[0], specs[1]
|
|
if db.Key != "db" || db.Type != "database" || db.Runtime != "postgresql" {
|
|
t.Errorf("db spec wrong: %+v", db)
|
|
}
|
|
if db.Environment["POSTGRES_PASSWORD"] != "pgsecret123" {
|
|
t.Errorf("db env lost: %v", db.Environment)
|
|
}
|
|
if web.Key != "web" || web.Type != "web" || web.Runtime != "traefik/whoami:latest" {
|
|
t.Errorf("web spec wrong: %+v", web)
|
|
}
|
|
if web.Port != 80 {
|
|
t.Errorf("web port = %d, want 80", web.Port)
|
|
}
|
|
if len(web.DependsOn) != 1 || web.DependsOn[0] != "db" {
|
|
t.Errorf("web depends_on = %v, want [db]", web.DependsOn)
|
|
}
|
|
if len(warnings) != 0 {
|
|
t.Errorf("unexpected warnings: %v", warnings)
|
|
}
|
|
}
|
|
|
|
func TestComposeToSpecsDeterministicOrder(t *testing.T) {
|
|
y := `services:
|
|
zebra:
|
|
image: alpine
|
|
alpha:
|
|
image: alpine
|
|
`
|
|
for i := 0; i < 5; i++ {
|
|
specs, _, err := composeToSpecsForTest(t, y)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if specs[0].Key != "alpha" || specs[1].Key != "zebra" {
|
|
t.Fatalf("unstable order: %v", specs)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestComposeToSpecsDatabaseImages(t *testing.T) {
|
|
cases := map[string]string{
|
|
"postgres:16": "postgresql",
|
|
"redis:7": "redis",
|
|
"mysql:8": "mysql",
|
|
"mariadb:11": "mariadb",
|
|
"mongo:7": "mongodb",
|
|
"clickhouse:24": "clickhouse",
|
|
}
|
|
for image, want := range cases {
|
|
specs, _, err := composeToSpecsForTest(t, "services:\n db:\n image: "+image+"\n")
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", image, err)
|
|
}
|
|
if specs[0].Type != "database" || specs[0].Runtime != want {
|
|
t.Errorf("%s → %+v, want database/%s", image, specs[0], want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestComposeToSpecsNoServices(t *testing.T) {
|
|
_, _, err := composeToSpecsForTest(t, "version: '3'\n")
|
|
if err == nil || !strings.Contains(err.Error(), "no services") {
|
|
t.Errorf("want no-services error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestComposeToSpecsWarnings(t *testing.T) {
|
|
_, warnings, err := composeToSpecsForTest(t, `services:
|
|
app:
|
|
build: .
|
|
orphan:
|
|
image: alpine
|
|
environment:
|
|
- UNSET_VAR
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
joined := strings.Join(warnings, "\n")
|
|
if !strings.Contains(joined, "build contexts are not supported") {
|
|
t.Errorf("missing build warning: %v", warnings)
|
|
}
|
|
if !strings.Contains(joined, "UNSET_VAR") {
|
|
t.Errorf("missing env warning: %v", warnings)
|
|
}
|
|
}
|
|
|
|
func TestResolveRepoFileContainment(t *testing.T) {
|
|
dir := t.TempDir()
|
|
inner := filepath.Join(dir, "sub")
|
|
if err := os.MkdirAll(inner, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(inner, "compose.yml"), []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
p, err := resolveRepoFile(dir, "sub/compose.yml")
|
|
if err != nil {
|
|
t.Fatalf("valid file rejected: %v", err)
|
|
}
|
|
if p != filepath.Join(dir, "sub", "compose.yml") {
|
|
t.Errorf("unexpected path %q", p)
|
|
}
|
|
|
|
for _, bad := range []string{"", "/etc/passwd", "../escape.yml", "..", "sub/../../out.yml"} {
|
|
if _, err := resolveRepoFile(dir, bad); err == nil {
|
|
t.Errorf("path %q should have been rejected", bad)
|
|
}
|
|
}
|
|
if _, err := resolveRepoFile(dir, "sub"); err == nil {
|
|
t.Error("directory accepted as file")
|
|
}
|
|
}
|
|
|
|
func TestIsCommitSHA(t *testing.T) {
|
|
if !isCommitSHA("0123456789abcdef0123456789abcdef01234567") {
|
|
t.Error("40-hex ref not recognised as a commit")
|
|
}
|
|
for _, bad := range []string{"main", "v1.2.3", "0123456789abcdef0123456789abcdef0123456", "0123456789abcdef0123456789abcdef0123456G"} {
|
|
if isCommitSHA(bad) {
|
|
t.Errorf("ref %q misclassified as commit SHA", bad)
|
|
}
|
|
}
|
|
}
|