diff --git a/action.yml b/action.yml index c8ecfc4..4f4c5de 100644 --- a/action.yml +++ b/action.yml @@ -97,6 +97,10 @@ inputs: description: "Enable automatic HTTPS forwarding with Let's Encrypt (format: service:port, e.g. web:80)" required: false default: "" + proxy_tls_hosts: + description: "Additional public hostnames served by the Helm Caddy gateway, comma-separated" + required: false + default: "" pre_script: description: "Path to a local bash script (relative to app_path) executed inline over SSH on the instance before docker compose" required: false @@ -196,6 +200,7 @@ runs: --deployment-variant "${{ inputs.deployment_variant }}" \ --registries "${{ inputs.registries }}" \ --proxy-tls "${{ inputs.proxy_tls }}" \ + --proxy-tls-hosts "${{ inputs.proxy_tls_hosts }}" \ --pre-script "${{ inputs.pre_script }}" \ --ttl "${{ inputs.ttl }}" \ --templated-url "${{ inputs.templated_url }}" diff --git a/cmd/pullpreview/main.go b/cmd/pullpreview/main.go index c2c515c..4e3ac88 100644 --- a/cmd/pullpreview/main.go +++ b/cmd/pullpreview/main.go @@ -194,6 +194,7 @@ type commonFlagValues struct { chartRepository string chartValues string chartSet string + proxyTLSHosts string tags multiValue options pullpreview.CommonOptions } @@ -208,6 +209,7 @@ func registerCommonFlags(fs *flag.FlagSet) *commonFlagValues { fs.StringVar(&values.registries, "registries", "", "URIs of docker registries to authenticate against") fs.StringVar((*string)(&values.options.DeploymentTarget), "deployment-target", string(pullpreview.DeploymentTargetCompose), "Deployment target to use: compose or helm") fs.StringVar(&values.options.ProxyTLS, "proxy-tls", "", "Enable automatic HTTPS proxying with Let's Encrypt (format: service:port, e.g. web:80)") + fs.StringVar(&values.proxyTLSHosts, "proxy-tls-hosts", "", "Additional public hostnames served by the Helm proxy, comma-separated") fs.StringVar(&values.options.DNS, "dns", "my.preview.run", "DNS suffix to use") fs.StringVar(&values.ports, "ports", "80/tcp,443/tcp", "Ports to open for external access") fs.StringVar(&values.options.InstanceType, "instance-type", "small", "Instance type to use") @@ -240,6 +242,7 @@ func (c *commonFlagValues) ToOptions(ctx context.Context) pullpreview.CommonOpti opts.ChartRepository = strings.TrimSpace(c.chartRepository) opts.ChartValues = splitCommaList(c.chartValues) opts.ChartSet = splitCommaList(c.chartSet) + opts.ProxyTLSHosts = splitCommaList(c.proxyTLSHosts) opts.Tags = parseTags(c.tags) return opts } diff --git a/cmd/pullpreview/main_test.go b/cmd/pullpreview/main_test.go index 28877b8..a387c11 100644 --- a/cmd/pullpreview/main_test.go +++ b/cmd/pullpreview/main_test.go @@ -68,6 +68,7 @@ func TestRegisterCommonFlagsParsesHelmOptions(t *testing.T) { "--chart-repository", "https://charts.bitnami.com/bitnami", "--chart-values", "values.yaml,values.preview.yaml", "--chart-set", "image.tag=123,ingress.host={{ release_name }}.preview.run", + "--proxy-tls-hosts", "nextcloud.{{ pullpreview_public_dns }},keycloak.{{ pullpreview_public_dns }}", }); err != nil { t.Fatalf("Parse() error: %v", err) } @@ -91,6 +92,9 @@ func TestRegisterCommonFlagsParsesHelmOptions(t *testing.T) { if len(opts.ChartSet) != 2 { t.Fatalf("unexpected chart set values: %#v", opts.ChartSet) } + if len(opts.ProxyTLSHosts) != 2 { + t.Fatalf("unexpected proxy tls hosts: %#v", opts.ProxyTLSHosts) + } } func TestRegisterCommonFlagsDefaultsToCompose(t *testing.T) { diff --git a/dist/pullpreview-linux-amd64 b/dist/pullpreview-linux-amd64 index 18bfae3..87e1f98 100755 Binary files a/dist/pullpreview-linux-amd64 and b/dist/pullpreview-linux-amd64 differ diff --git a/go.mod b/go.mod index 645dc87..4291f12 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/hetznercloud/hcloud-go/v2 v2.36.0 golang.org/x/crypto v0.48.0 golang.org/x/oauth2 v0.34.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/internal/providers/hetzner/hetzner.go b/internal/providers/hetzner/hetzner.go index e38fe3e..e9669c8 100644 --- a/internal/providers/hetzner/hetzner.go +++ b/internal/providers/hetzner/hetzner.go @@ -29,7 +29,7 @@ const ( // Hardcoded to a location with the highest server-type availability (snapshot: 2026-02-13). defaultHetznerLocation = "nbg1" defaultHetznerImage = "ubuntu-24.04" - defaultHetznerServerType = "cpx21" + defaultHetznerServerType = "cpx22" defaultHetznerSSHUser = "root" defaultHetznerSSHRetries = 10 defaultHetznerSSHInterval = 15 * time.Second diff --git a/internal/providers/hetzner/hetzner_test.go b/internal/providers/hetzner/hetzner_test.go index 5bd6b06..5d7d4b2 100644 --- a/internal/providers/hetzner/hetzner_test.go +++ b/internal/providers/hetzner/hetzner_test.go @@ -928,7 +928,7 @@ func mustNewProviderWithContext(t *testing.T, cfg Config) *Provider { func makeTestServer(name, ip string, status hcloud.ServerStatus, serverType *hcloud.ServerType) *hcloud.Server { if serverType == nil { - serverType = &hcloud.ServerType{Name: "cpx21"} + serverType = &hcloud.ServerType{Name: "cpx22"} } return &hcloud.Server{ Name: name, diff --git a/internal/pullpreview/deploy_helm.go b/internal/pullpreview/deploy_helm.go index bbb34d2..0434f8d 100644 --- a/internal/pullpreview/deploy_helm.go +++ b/internal/pullpreview/deploy_helm.go @@ -8,19 +8,35 @@ import ( "os/exec" "path/filepath" "strings" + + "gopkg.in/yaml.v3" ) const ( helmReleaseName = "app" helmFailureReportOutputSize = 12000 + helmDeployTimeout = "15m" ) type helmChartSource struct { - ChartRef string - LocalChart string - RepoURL string - RequiresSync bool - SyncAppTree bool + ChartRef string + LocalChart string + RepoURL string + RepoDefs []helmRepoDefinition + RequiresSync bool + SyncAppTree bool + DependencyBuildRefs []string + ExtraSyncPaths []helmSyncPath +} + +type helmRepoDefinition struct { + Name string + URL string +} + +type helmSyncPath struct { + Local string + Remote string } func (i *Instance) HelmNamespace() string { @@ -94,6 +110,11 @@ func (i *Instance) DeployWithHelm(appPath string) error { return err } } + for _, syncPath := range chartSource.ExtraSyncPaths { + if err := i.syncRemotePath(syncPath.Local, syncPath.Remote); err != nil { + return err + } + } if err := i.runRemotePreScript(appPath); err != nil { return err } @@ -116,6 +137,7 @@ func (i *Instance) resolveHelmChartSource(appPath string) (helmChartSource, erro return helmChartSource{ ChartRef: fmt.Sprintf("pullpreview/%s", strings.TrimLeft(chart, "/")), RepoURL: repoURL, + RepoDefs: []helmRepoDefinition{{Name: "pullpreview", URL: repoURL}}, }, nil } @@ -131,25 +153,178 @@ func (i *Instance) resolveHelmChartSource(appPath string) (helmChartSource, erro if _, err := os.Stat(localChart); err != nil { return helmChartSource{}, fmt.Errorf("unable to access chart %s: %w", chart, err) } + repoDefs, dependencyPaths, err := helmDependencyInputs(localChart) + if err != nil { + return helmChartSource{}, fmt.Errorf("chart %s: %w", chart, err) + } + buildRefs := make([]string, 0, len(dependencyPaths)) + extraSyncPaths := []helmSyncPath{} + for _, dependencyPath := range dependencyPaths { + if pathWithinRoot(absAppPath, dependencyPath) { + remoteRef, err := remoteBindSource(dependencyPath, absAppPath, remoteAppPath) + if err != nil { + return helmChartSource{}, fmt.Errorf("chart %s: %w", chart, err) + } + buildRefs = append(buildRefs, remoteRef) + continue + } + + remoteRef := externalHelmChartPath(dependencyPath) + buildRefs = append(buildRefs, remoteRef) + extraSyncPaths = append(extraSyncPaths, helmSyncPath{ + Local: dependencyPath, + Remote: remoteRef, + }) + } if pathWithinRoot(absAppPath, localChart) { remoteChart, err := remoteBindSource(localChart, absAppPath, remoteAppPath) if err != nil { return helmChartSource{}, fmt.Errorf("chart %s: %w", chart, err) } return helmChartSource{ - ChartRef: remoteChart, - LocalChart: localChart, - RequiresSync: true, - SyncAppTree: true, + ChartRef: remoteChart, + LocalChart: localChart, + RepoDefs: repoDefs, + RequiresSync: true, + SyncAppTree: true, + DependencyBuildRefs: buildRefs, + ExtraSyncPaths: extraSyncPaths, }, nil } + remoteChart := externalHelmChartPath(localChart) return helmChartSource{ - ChartRef: externalHelmChartPath(localChart), - LocalChart: localChart, - RequiresSync: true, + ChartRef: remoteChart, + LocalChart: localChart, + RepoDefs: repoDefs, + RequiresSync: true, + DependencyBuildRefs: buildRefs, + ExtraSyncPaths: append(extraSyncPaths, helmSyncPath{ + Local: localChart, + Remote: remoteChart, + }), }, nil } +type localHelmChartMetadata struct { + Dependencies []localHelmChartDependency `yaml:"dependencies"` +} + +type localHelmChartDependency struct { + Name string `yaml:"name"` + Repository string `yaml:"repository"` +} + +func helmDependencyInputs(chartPath string) ([]helmRepoDefinition, []string, error) { + visited := map[string]bool{} + repos := []helmRepoDefinition{} + buildPaths := []string{} + urlToName := map[string]string{} + usedNames := map[string]bool{} + + var walk func(string) error + walk = func(path string) error { + path = filepath.Clean(path) + if visited[path] { + return nil + } + visited[path] = true + + metadata, err := loadLocalHelmChartMetadata(path) + if err != nil { + return err + } + + for _, dep := range metadata.Dependencies { + repoURL := strings.TrimSpace(dep.Repository) + switch { + case strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://"): + if existing, ok := urlToName[repoURL]; ok { + repos = append(repos, helmRepoDefinition{Name: existing, URL: repoURL}) + continue + } + name := uniqueHelmRepoName(dep.Name, repoURL, usedNames) + urlToName[repoURL] = name + usedNames[name] = true + repos = append(repos, helmRepoDefinition{Name: name, URL: repoURL}) + case strings.HasPrefix(repoURL, "file://"): + dependencyPath := strings.TrimSpace(strings.TrimPrefix(repoURL, "file://")) + if dependencyPath == "" { + return fmt.Errorf("chart %s: empty file:// dependency for %q", path, dep.Name) + } + if !filepath.IsAbs(dependencyPath) { + dependencyPath = filepath.Join(path, dependencyPath) + } + if err := walk(dependencyPath); err != nil { + return err + } + } + } + + buildPaths = append(buildPaths, path) + return nil + } + + if err := walk(chartPath); err != nil { + return nil, nil, err + } + if len(buildPaths) > 0 { + buildPaths = buildPaths[:len(buildPaths)-1] + } + return uniqueHelmRepoDefinitions(repos), buildPaths, nil +} + +func loadLocalHelmChartMetadata(chartPath string) (localHelmChartMetadata, error) { + chartYAMLPath := filepath.Join(chartPath, "Chart.yaml") + data, err := os.ReadFile(chartYAMLPath) + if err != nil { + return localHelmChartMetadata{}, fmt.Errorf("read %s: %w", chartYAMLPath, err) + } + + var metadata localHelmChartMetadata + if err := yaml.Unmarshal(data, &metadata); err != nil { + return localHelmChartMetadata{}, fmt.Errorf("parse %s: %w", chartYAMLPath, err) + } + return metadata, nil +} + +func uniqueHelmRepoName(preferred, repoURL string, used map[string]bool) string { + candidate := sanitizeRemotePathComponent(preferred) + if candidate == "" || candidate == "chart" { + candidate = sanitizeRemotePathComponent(repoURL) + } + if candidate == "" || candidate == "chart" { + candidate = "repo" + } + if !used[candidate] { + return candidate + } + sum := fmt.Sprintf("%x", sha1.Sum([]byte(repoURL))) + for idx := 0; ; idx++ { + suffix := sum[:6] + if idx > 0 { + suffix = fmt.Sprintf("%s-%d", suffix, idx) + } + name := fmt.Sprintf("%s-%s", candidate, suffix) + if !used[name] { + return name + } + } +} + +func uniqueHelmRepoDefinitions(values []helmRepoDefinition) []helmRepoDefinition { + seen := map[string]bool{} + result := make([]helmRepoDefinition, 0, len(values)) + for _, value := range values { + key := value.Name + "\x00" + value.URL + if seen[key] { + continue + } + seen[key] = true + result = append(result, value) + } + return result +} + func pathWithinRoot(root, candidate string) bool { rel, err := filepath.Rel(filepath.Clean(root), filepath.Clean(candidate)) if err != nil { @@ -321,13 +496,29 @@ func (i *Instance) runHelmDeployment(source helmChartSource, valueArgs []string) "export KUBECONFIG=/etc/rancher/k3s/k3s.yaml", fmt.Sprintf("kubectl create namespace %s --dry-run=client -o yaml | kubectl apply -f - >/dev/null", shellQuote(namespace)), } - if source.RepoURL != "" { - lines = append(lines, - fmt.Sprintf("helm repo add pullpreview %s --force-update >/dev/null", shellQuote(source.RepoURL)), - "helm repo update pullpreview >/dev/null", - ) + manifest := i.renderHelmCaddyManifest(namespace, upstreamHost, target.Port) + lines = append(lines, + "cat <<'EOF' >/tmp/pullpreview-caddy.yaml", + manifest, + "EOF", + "kubectl apply -f /tmp/pullpreview-caddy.yaml >/dev/null", + fmt.Sprintf("kubectl rollout restart deployment/pullpreview-caddy -n %s >/dev/null", shellQuote(namespace)), + fmt.Sprintf("kubectl rollout status deployment/pullpreview-caddy -n %s --timeout=10m", shellQuote(namespace)), + ) + repoDefs := source.RepoDefs + if len(repoDefs) == 0 && strings.TrimSpace(source.RepoURL) != "" { + repoDefs = []helmRepoDefinition{{Name: "pullpreview", URL: source.RepoURL}} + } + if len(repoDefs) > 0 { + for _, repo := range repoDefs { + lines = append(lines, fmt.Sprintf("helm repo add %s %s --force-update >/dev/null", shellQuote(repo.Name), shellQuote(repo.URL))) + } + lines = append(lines, "helm repo update >/dev/null") } if source.LocalChart != "" { + for _, ref := range source.DependencyBuildRefs { + lines = append(lines, fmt.Sprintf("helm dependency build %s >/dev/null", shellQuote(ref))) + } lines = append(lines, fmt.Sprintf("helm dependency build %s >/dev/null", shellQuote(source.ChartRef))) } @@ -337,19 +528,11 @@ func (i *Instance) runHelmDeployment(source helmChartSource, valueArgs []string) "--create-namespace", "--wait", "--atomic", + "--timeout", helmDeployTimeout, } helmArgs = append(helmArgs, valueArgs...) lines = append(lines, shellJoin(helmArgs...)) - manifest := i.renderHelmCaddyManifest(namespace, upstreamHost, target.Port) - lines = append(lines, - "cat <<'EOF' >/tmp/pullpreview-caddy.yaml", - manifest, - "EOF", - "kubectl apply -f /tmp/pullpreview-caddy.yaml >/dev/null", - fmt.Sprintf("kubectl rollout status deployment/pullpreview-caddy -n %s --timeout=10m", shellQuote(namespace)), - ) - if i.Logger != nil { i.Logger.Infof("Deploying Helm release=%s namespace=%s chart=%s", helmReleaseName, namespace, source.ChartRef) } @@ -364,7 +547,28 @@ func shellJoin(args ...string) string { return strings.Join(quoted, " ") } +func (i *Instance) helmProxyTLSPublicHosts() []string { + hosts := []string{i.PublicDNS()} + for _, raw := range i.ProxyTLSHosts { + hosts = append(hosts, i.expandDeploymentValue(raw)) + } + return uniqueStrings(hosts) +} + func (i *Instance) renderHelmCaddyManifest(namespace, upstreamHost string, upstreamPort int) string { + var caddySites strings.Builder + for _, host := range i.helmProxyTLSPublicHosts() { + caddySites.WriteString(fmt.Sprintf(` %s { + reverse_proxy %s:%d { + header_up Host {host} + header_up X-Forwarded-Host {host} + header_up X-Forwarded-Proto https + header_up X-Forwarded-Port 443 + } + } +`, host, upstreamHost, upstreamPort)) + } + return fmt.Sprintf(`apiVersion: v1 kind: ConfigMap metadata: @@ -372,9 +576,7 @@ metadata: namespace: %s data: Caddyfile: | - %s { - reverse_proxy %s:%d - } +%s --- apiVersion: apps/v1 kind: Deployment @@ -433,7 +635,7 @@ spec: hostPath: path: /var/lib/pullpreview/caddy-config type: DirectoryOrCreate -`, namespace, i.PublicDNS(), upstreamHost, upstreamPort, namespace) +`, namespace, caddySites.String(), namespace) } func (i *Instance) emitHelmFailureReport() { diff --git a/internal/pullpreview/deploy_helm_test.go b/internal/pullpreview/deploy_helm_test.go index f5bfac9..4e049f8 100644 --- a/internal/pullpreview/deploy_helm_test.go +++ b/internal/pullpreview/deploy_helm_test.go @@ -113,6 +113,15 @@ func TestValidateDeploymentConfigRejectsComposeWithHelmOptions(t *testing.T) { if err := inst.ValidateDeploymentConfig(); err == nil || !strings.Contains(err.Error(), "require deployment_target=helm") { t.Fatalf("expected compose/helm validation error, got %v", err) } + + inst = NewInstance("demo", CommonOptions{ + DeploymentTarget: DeploymentTargetCompose, + ProxyTLSHosts: []string{"nextcloud.example.test"}, + }, fakeProvider{}, nil) + + if err := inst.ValidateDeploymentConfig(); err == nil || !strings.Contains(err.Error(), "proxy_tls_hosts") { + t.Fatalf("expected proxy_tls_hosts validation error, got %v", err) + } } func TestValidateDeploymentConfigAcceptsHelmForLightsailProvider(t *testing.T) { @@ -170,7 +179,11 @@ func TestExpandDeploymentValue(t *testing.T) { Chart: "wordpress", ChartRepository: "https://charts.bitnami.com/bitnami", ProxyTLS: "{{ release_name }}-wordpress:80", - DNS: "rev2.click", + ProxyTLSHosts: []string{ + "nextcloud.{{ pullpreview_public_dns }}", + "keycloak.{{ pullpreview_public_dns }}", + }, + DNS: "rev2.click", }, fakeProvider{}, nil) inst.Access = AccessDetails{IPAddress: "1.2.3.4", Username: "root"} @@ -212,6 +225,81 @@ func TestResolveHelmChartSourceForLocalChart(t *testing.T) { } } +func TestResolveHelmChartSourceCollectsRemoteDependencyRepos(t *testing.T) { + appPath := t.TempDir() + chartPath := filepath.Join(appPath, "charts", "demo") + nestedPath := filepath.Join(appPath, "charts", "local-subchart") + if err := os.MkdirAll(chartPath, 0755); err != nil { + t.Fatalf("mkdir chart path: %v", err) + } + if err := os.MkdirAll(nestedPath, 0755); err != nil { + t.Fatalf("mkdir nested chart path: %v", err) + } + chartYAML := `apiVersion: v2 +name: demo +version: 0.1.0 +dependencies: + - name: local-subchart + repository: file://../local-subchart + version: 0.1.0 + - name: nextcloud + repository: https://nextcloud.github.io/helm + version: 7.0.0 + - name: keycloak + repository: https://charts.bitnami.com/bitnami + version: 24.7.5 +` + if err := os.WriteFile(filepath.Join(chartPath, "Chart.yaml"), []byte(chartYAML), 0644); err != nil { + t.Fatalf("write chart: %v", err) + } + nestedChartYAML := `apiVersion: v2 +name: local-subchart +version: 0.1.0 +dependencies: + - name: traefik + repository: https://traefik.github.io/charts + version: 39.0.5 +` + if err := os.WriteFile(filepath.Join(nestedPath, "Chart.yaml"), []byte(nestedChartYAML), 0644); err != nil { + t.Fatalf("write nested chart: %v", err) + } + + inst := NewInstance("demo", CommonOptions{ + DeploymentTarget: DeploymentTargetHelm, + Chart: "charts/demo", + ProxyTLS: "demo:80", + }, fakeProvider{}, nil) + inst.Access = AccessDetails{IPAddress: "1.2.3.4", Username: "root"} + + source, err := inst.resolveHelmChartSource(appPath) + if err != nil { + t.Fatalf("resolveHelmChartSource() error: %v", err) + } + if len(source.RepoDefs) != 3 { + t.Fatalf("expected three remote repo defs, got %#v", source.RepoDefs) + } + gotRepos := map[string]string{} + for _, repo := range source.RepoDefs { + gotRepos[repo.Name] = repo.URL + } + wantRepos := map[string]string{ + "nextcloud": "https://nextcloud.github.io/helm", + "keycloak": "https://charts.bitnami.com/bitnami", + "traefik": "https://traefik.github.io/charts", + } + if len(gotRepos) != len(wantRepos) { + t.Fatalf("unexpected repo defs: %#v", source.RepoDefs) + } + for name, url := range wantRepos { + if gotRepos[name] != url { + t.Fatalf("expected repo %q=%q, got %#v", name, url, source.RepoDefs) + } + } + if len(source.DependencyBuildRefs) != 1 || source.DependencyBuildRefs[0] != "/app/charts/local-subchart" { + t.Fatalf("unexpected dependency build refs: %#v", source.DependencyBuildRefs) + } +} + func TestResolveHelmChartSourceForLocalChartOutsideAppPath(t *testing.T) { root := t.TempDir() appPath := filepath.Join(root, "app") @@ -348,7 +436,11 @@ func TestRunHelmDeploymentBuildsExpectedScriptForRepoChart(t *testing.T) { Chart: "wordpress", ChartRepository: "https://charts.bitnami.com/bitnami", ProxyTLS: "{{ release_name }}-wordpress:80", - DNS: "rev2.click", + ProxyTLSHosts: []string{ + "nextcloud.{{ pullpreview_public_dns }}", + "keycloak.{{ pullpreview_public_dns }}", + }, + DNS: "rev2.click", }, fakeProvider{}, nil) inst.Access = AccessDetails{IPAddress: "1.2.3.4", Username: "root", PrivateKey: "PRIVATE", CertKey: "CERT"} runner := &scriptCaptureRunner{} @@ -379,12 +471,15 @@ func TestRunHelmDeploymentBuildsExpectedScriptForRepoChart(t *testing.T) { "source /etc/pullpreview/env", "export KUBECONFIG=/etc/rancher/k3s/k3s.yaml", "kubectl create namespace 'pp-demo-app' --dry-run=client -o yaml | kubectl apply -f - >/dev/null", - "helm repo add pullpreview 'https://charts.bitnami.com/bitnami' --force-update >/dev/null", - "helm repo update pullpreview >/dev/null", - "'helm' 'upgrade' '--install' 'app' 'pullpreview/wordpress' '--namespace' 'pp-demo-app' '--create-namespace' '--wait' '--atomic' '--values' '/app/values.yaml' '--set' 'service.type=ClusterIP' '--set' 'ingress.hostname=Demo-App-ip-1-2-3-4.rev2.click'", + "helm repo add 'pullpreview' 'https://charts.bitnami.com/bitnami' --force-update >/dev/null", + "helm repo update >/dev/null", + "'helm' 'upgrade' '--install' 'app' 'pullpreview/wordpress' '--namespace' 'pp-demo-app' '--create-namespace' '--wait' '--atomic' '--timeout' '15m' '--values' '/app/values.yaml' '--set' 'service.type=ClusterIP' '--set' 'ingress.hostname=Demo-App-ip-1-2-3-4.rev2.click'", "cat <<'EOF' >/tmp/pullpreview-caddy.yaml", "Demo-App-ip-1-2-3-4.rev2.click {", + "nextcloud.Demo-App-ip-1-2-3-4.rev2.click {", + "keycloak.Demo-App-ip-1-2-3-4.rev2.click {", "reverse_proxy app-wordpress.pp-demo-app.svc.cluster.local:80", + "kubectl rollout restart deployment/pullpreview-caddy -n 'pp-demo-app' >/dev/null", "kubectl rollout status deployment/pullpreview-caddy -n 'pp-demo-app' --timeout=10m", } for _, check := range checks { @@ -423,12 +518,114 @@ func TestRunHelmDeploymentBuildsDependencyStepForLocalChart(t *testing.T) { } } +func TestRunHelmDeploymentAddsRemoteReposForLocalChartDependencies(t *testing.T) { + inst := NewInstance("demo", CommonOptions{ + DeploymentTarget: DeploymentTargetHelm, + Chart: "charts/demo", + ProxyTLS: "app-wordpress:80", + }, fakeProvider{}, nil) + inst.Access = AccessDetails{IPAddress: "1.2.3.4", Username: "root", PrivateKey: "PRIVATE"} + runner := &scriptCaptureRunner{} + inst.Runner = runner + + err := inst.runHelmDeployment(helmChartSource{ + ChartRef: "/app/charts/demo", + LocalChart: "/tmp/demo", + DependencyBuildRefs: []string{"/app/charts/local-subchart"}, + RepoDefs: []helmRepoDefinition{ + {Name: "nextcloud", URL: "https://nextcloud.github.io/helm"}, + {Name: "bitnami", URL: "https://charts.bitnami.com/bitnami"}, + }, + }, nil) + if err != nil { + t.Fatalf("runHelmDeployment() error: %v", err) + } + script := runner.inputs[0] + checks := []string{ + "helm repo add 'nextcloud' 'https://nextcloud.github.io/helm' --force-update >/dev/null", + "helm repo add 'bitnami' 'https://charts.bitnami.com/bitnami' --force-update >/dev/null", + "helm repo update >/dev/null", + "helm dependency build '/app/charts/local-subchart' >/dev/null", + "helm dependency build '/app/charts/demo' >/dev/null", + } + for _, check := range checks { + if !strings.Contains(script, check) { + t.Fatalf("expected script to contain %q, script:\n%s", check, script) + } + } +} + +func TestDeployWithHelmSyncsExternalLocalDependencies(t *testing.T) { + appPath := t.TempDir() + chartPath := filepath.Join(appPath, "charts", "demo") + externalDepRoot := filepath.Join(t.TempDir(), "external") + externalDepPath := filepath.Join(externalDepRoot, "shared-chart") + if err := os.MkdirAll(chartPath, 0755); err != nil { + t.Fatalf("mkdir chart path: %v", err) + } + if err := os.MkdirAll(externalDepPath, 0755); err != nil { + t.Fatalf("mkdir external dependency path: %v", err) + } + chartYAML := `apiVersion: v2 +name: demo +version: 0.1.0 +dependencies: + - name: shared-chart + repository: file://` + externalDepPath + ` + version: 0.1.0 +` + if err := os.WriteFile(filepath.Join(chartPath, "Chart.yaml"), []byte(chartYAML), 0644); err != nil { + t.Fatalf("write chart: %v", err) + } + if err := os.WriteFile(filepath.Join(externalDepPath, "Chart.yaml"), []byte("apiVersion: v2\nname: shared-chart\nversion: 0.1.0\n"), 0644); err != nil { + t.Fatalf("write external chart: %v", err) + } + + inst := NewInstance("demo", CommonOptions{ + DeploymentTarget: DeploymentTargetHelm, + Chart: "charts/demo", + ProxyTLS: "demo:80", + }, fakeProvider{}, nil) + inst.Access = AccessDetails{IPAddress: "1.2.3.4", Username: "root", PrivateKey: "PRIVATE"} + runner := &scriptCaptureRunner{} + inst.Runner = runner + + if err := inst.DeployWithHelm(appPath); err != nil { + t.Fatalf("DeployWithHelm() error: %v", err) + } + foundAppSync := false + foundExternalSync := false + foundHelmBuild := false + for idx, args := range runner.args { + joined := strings.Join(args, " ") + if len(args) > 0 && args[0] == "rsync" && strings.Contains(joined, remoteAppPath+"/") { + foundAppSync = true + } + if len(args) > 0 && args[0] == "rsync" && strings.Contains(joined, externalHelmChartPath(externalDepPath)) { + foundExternalSync = true + } + if idx < len(runner.inputs) && strings.Contains(runner.inputs[idx], "helm dependency build '"+externalHelmChartPath(externalDepPath)+"' >/dev/null") { + foundHelmBuild = true + } + } + if !foundAppSync { + t.Fatalf("expected app tree sync, commands: %#v", runner.args) + } + if !foundExternalSync { + t.Fatalf("expected external dependency sync, commands: %#v", runner.args) + } + if !foundHelmBuild { + t.Fatalf("expected helm dependency build for external dependency, scripts: %#v", runner.inputs) + } +} + func TestRenderHelmCaddyManifest(t *testing.T) { inst := NewInstance("demo", CommonOptions{ DeploymentTarget: DeploymentTargetHelm, Chart: "wordpress", ChartRepository: "https://charts.bitnami.com/bitnami", ProxyTLS: "app-wordpress:80", + ProxyTLSHosts: []string{"nextcloud.{{ pullpreview_public_dns }}", "keycloak.{{ pullpreview_public_dns }}"}, DNS: "rev2.click", }, fakeProvider{}, nil) inst.Access = AccessDetails{IPAddress: "1.2.3.4", Username: "root"} @@ -443,7 +640,23 @@ func TestRenderHelmCaddyManifest(t *testing.T) { if !strings.Contains(manifest, "demo-ip-1-2-3-4.rev2.click") { t.Fatalf("expected public DNS in manifest: %s", manifest) } + if !strings.Contains(manifest, "nextcloud.demo-ip-1-2-3-4.rev2.click") { + t.Fatalf("expected nextcloud host in manifest: %s", manifest) + } + if !strings.Contains(manifest, "keycloak.demo-ip-1-2-3-4.rev2.click") { + t.Fatalf("expected keycloak host in manifest: %s", manifest) + } if !strings.Contains(manifest, "reverse_proxy app-wordpress.pp-demo.svc.cluster.local:80") { t.Fatalf("expected reverse proxy upstream in manifest: %s", manifest) } + for _, header := range []string{ + "header_up Host {host}", + "header_up X-Forwarded-Host {host}", + "header_up X-Forwarded-Proto https", + "header_up X-Forwarded-Port 443", + } { + if !strings.Contains(manifest, header) { + t.Fatalf("expected proxy header %q in manifest: %s", header, manifest) + } + } } diff --git a/internal/pullpreview/github_sync.go b/internal/pullpreview/github_sync.go index 6d50ace..f1b15d9 100644 --- a/internal/pullpreview/github_sync.go +++ b/internal/pullpreview/github_sync.go @@ -1305,6 +1305,7 @@ func instanceToCommon(inst *Instance) CommonOptions { CIDRs: inst.CIDRs, Registries: inst.Registries, ProxyTLS: inst.ProxyTLS, + ProxyTLSHosts: inst.ProxyTLSHosts, DNS: inst.DNS, Ports: inst.Ports, InstanceType: inst.Size, diff --git a/internal/pullpreview/github_sync_test.go b/internal/pullpreview/github_sync_test.go index 8daa902..e811fe4 100644 --- a/internal/pullpreview/github_sync_test.go +++ b/internal/pullpreview/github_sync_test.go @@ -1148,3 +1148,22 @@ func writeFixtureToTempEventFile(t *testing.T, event GitHubEvent) string { } return path } + +func TestInstanceToCommonPreservesHelmProxyTLSHosts(t *testing.T) { + inst := NewInstance("demo", CommonOptions{ + DeploymentTarget: DeploymentTargetHelm, + ProxyTLS: "traefik:80", + ProxyTLSHosts: []string{ + "nextcloud.demo.preview.run", + "keycloak.demo.preview.run", + }, + }, fakeProvider{}, nil) + + common := instanceToCommon(inst) + if len(common.ProxyTLSHosts) != 2 { + t.Fatalf("expected proxy TLS hosts to be preserved, got %#v", common.ProxyTLSHosts) + } + if common.ProxyTLSHosts[0] != "nextcloud.demo.preview.run" || common.ProxyTLSHosts[1] != "keycloak.demo.preview.run" { + t.Fatalf("unexpected proxy TLS hosts %#v", common.ProxyTLSHosts) + } +} diff --git a/internal/pullpreview/instance.go b/internal/pullpreview/instance.go index 0549815..fa65170 100644 --- a/internal/pullpreview/instance.go +++ b/internal/pullpreview/instance.go @@ -74,6 +74,7 @@ type Instance struct { DNS string Ports []string ProxyTLS string + ProxyTLSHosts []string Provider Provider Registries []string Size string @@ -113,6 +114,7 @@ func NewInstance(name string, opts CommonOptions, provider Provider, logger *Log DNS: defaultString(opts.DNS, "my.preview.run"), Ports: opts.Ports, ProxyTLS: proxyTLS, + ProxyTLSHosts: uniqueStrings(opts.ProxyTLSHosts), Provider: provider, Registries: opts.Registries, Size: opts.InstanceType, @@ -184,8 +186,8 @@ func (i *Instance) ValidateDeploymentConfig() error { switch i.DeploymentTarget { case DeploymentTargetCompose: - if strings.TrimSpace(i.Chart) != "" || strings.TrimSpace(i.ChartRepository) != "" || len(i.ChartValues) > 0 || len(i.ChartSet) > 0 { - return fmt.Errorf("chart, chart_repository, chart_values, and chart_set require deployment_target=helm") + if strings.TrimSpace(i.Chart) != "" || strings.TrimSpace(i.ChartRepository) != "" || len(i.ChartValues) > 0 || len(i.ChartSet) > 0 || len(i.ProxyTLSHosts) > 0 { + return fmt.Errorf("chart, chart_repository, chart_values, chart_set, and proxy_tls_hosts require deployment_target=helm") } case DeploymentTargetHelm: if !providerSupportsDeploymentTarget(i.Provider, DeploymentTargetHelm) { diff --git a/internal/pullpreview/types.go b/internal/pullpreview/types.go index 010b8db..38112f9 100644 --- a/internal/pullpreview/types.go +++ b/internal/pullpreview/types.go @@ -79,6 +79,7 @@ type CommonOptions struct { CIDRs []string Registries []string ProxyTLS string + ProxyTLSHosts []string DNS string Ports []string InstanceType string