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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion internal/drivers/app_platform_buildspec.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,18 @@ func buildAppSpec(name string, cfg map[string]any, region string) (*godo.AppSpec
}
}

// Sidecars run as sibling AppServiceSpec entries (DO App Platform does not have
// a native sidecar concept; each sidecar becomes an independent service component).
sidecars, err := sidecarsFromConfig(cfg)
if err != nil {
return nil, fmt.Errorf("app platform sidecars: %w", err)
}
services := append([]*godo.AppServiceSpec{svc}, sidecars...)

spec := &godo.AppSpec{
Name: name,
Region: region,
Services: []*godo.AppServiceSpec{svc},
Services: services,
Jobs: jobsFromConfig(cfg),
Workers: workersFromConfig(cfg),
StaticSites: staticSitesFromConfig(cfg),
Expand Down Expand Up @@ -721,6 +729,59 @@ func stringsFromConfig(cfg map[string]any, key string) []string {
return out
}

// sidecarsFromConfig converts canonical "sidecars" list to sibling []*godo.AppServiceSpec.
// DO App Platform has no native sidecar concept; each sidecar becomes an independent
// service component in the same app. Components communicate via the app's internal
// networking (platform-managed DNS/routing), not via a shared Linux network namespace.
// An invalid image ref in any sidecar is returned as an error so misconfiguration fails fast.
func sidecarsFromConfig(cfg map[string]any) ([]*godo.AppServiceSpec, error) {
raw, ok := cfg["sidecars"].([]any)
if !ok || len(raw) == 0 {
return nil, nil
}
out := make([]*godo.AppServiceSpec, 0, len(raw))
for _, v := range raw {
m, ok := v.(map[string]any)
if !ok {
continue
}
name := strFromConfig(m, "name", "")
if name == "" {
continue
}
svc := &godo.AppServiceSpec{
Name: name,
RunCommand: strFromConfig(m, "run_command", ""),
Envs: envVarsFromConfig(m),
}
if imgStr := strFromConfig(m, "image", ""); imgStr != "" {
img, err := ParseImageRef(imgStr)
if err != nil {
return nil, fmt.Errorf("sidecar %q: %w", name, err)
}
svc.Image = img
}
// Map the first public port to HTTPPort if specified.
if ports, ok := m["ports"].([]any); ok {
for _, p := range ports {
pm, ok := p.(map[string]any)
if !ok {
continue
}
pub, _ := pm["public"].(bool)
if pub {
if port, ok2 := intFromConfig(pm, "port", 0); ok2 && port > 0 {
svc.HTTPPort = int64(port)
break
}
}
}
}
out = append(out, svc)
}
return out, nil
}

// stringMatchesFromConfig converts a []any of strings to []*godo.AppStringMatch using exact matching.
func stringMatchesFromConfig(cfg map[string]any, key string) []*godo.AppStringMatch {
raw, ok := cfg[key].([]any)
Expand Down
75 changes: 75 additions & 0 deletions internal/drivers/app_platform_buildspec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -946,6 +946,81 @@ func TestBuildAppSpec_LogDestinations(t *testing.T) {
}
}

// ── Sidecars ─────────────────────────────────────────────────────────────────

func TestBuildAppSpec_Sidecars(t *testing.T) {
cfg := map[string]any{
"image": "registry.digitalocean.com/myrepo/myapp:v1",
"sidecars": []any{
map[string]any{
"name": "tailscale",
"image": "docker.io/tailscale/tailscale:latest",
"run_command": "/usr/local/bin/containerboot",
"env_vars_secret": map[string]any{
"TS_AUTH_KEY": "ts-secret",
},
},
map[string]any{
"name": "envoy-proxy",
"image": "docker.io/envoyproxy/envoy:v1.29",
"run_command": "/usr/local/bin/envoy -c /config/envoy.yaml",
"ports": []any{map[string]any{"port": 9901, "public": true}},
},
},
}
spec := buildSpecViaCreate(t, cfg)
// Main service + 2 sidecars = 3 services total.
if len(spec.Services) != 3 {
t.Fatalf("expected 3 services (main + 2 sidecars), got %d", len(spec.Services))
}
if spec.Services[0].Name != "test-app" {
t.Errorf("services[0].Name = %q, want test-app", spec.Services[0].Name)
}
ts := spec.Services[1]
if ts.Name != "tailscale" {
t.Errorf("sidecar[0].Name = %q, want tailscale", ts.Name)
}
if ts.RunCommand != "/usr/local/bin/containerboot" {
t.Errorf("sidecar[0].RunCommand = %q", ts.RunCommand)
}
if ts.Image == nil || ts.Image.Repository != "tailscale" {
t.Errorf("sidecar[0].Image.Repository = %q, want tailscale", func() string {
if ts.Image == nil {
return "<nil>"
}
return ts.Image.Repository
}())
}
// Secret env var forwarded.
foundSecret := false
for _, e := range ts.Envs {
if e.Key == "TS_AUTH_KEY" && e.Type == godo.AppVariableType_Secret {
foundSecret = true
}
}
if !foundSecret {
t.Error("expected TS_AUTH_KEY secret env var in tailscale sidecar")
}

// Envoy sidecar: public port mapped to HTTPPort.
envoy := spec.Services[2]
if envoy.Name != "envoy-proxy" {
t.Errorf("sidecar[1].Name = %q, want envoy-proxy", envoy.Name)
}
if envoy.HTTPPort != 9901 {
t.Errorf("sidecar[1].HTTPPort = %d, want 9901", envoy.HTTPPort)
}
}

func TestBuildAppSpec_Sidecars_Empty(t *testing.T) {
cfg := map[string]any{"image": "registry.digitalocean.com/myrepo/myapp:v1"}
spec := buildSpecViaCreate(t, cfg)
// Only the main service, no sidecars.
if len(spec.Services) != 1 {
t.Errorf("expected 1 service (no sidecars), got %d", len(spec.Services))
}
}

// ── Update propagates buildAppSpec ──────────────────────────────────────────

func TestBuildAppSpec_UpdateUsesBuildSpec(t *testing.T) {
Expand Down
78 changes: 74 additions & 4 deletions internal/drivers/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type DatabaseClient interface {
Get(ctx context.Context, databaseID string) (*godo.Database, *godo.Response, error)
Resize(ctx context.Context, databaseID string, req *godo.DatabaseResizeRequest) (*godo.Response, error)
Delete(ctx context.Context, databaseID string) (*godo.Response, error)
UpdateFirewallRules(ctx context.Context, databaseID string, req *godo.DatabaseUpdateFirewallRulesRequest) (*godo.Response, error)
}

// DatabaseDriver manages DigitalOcean Managed Databases (infra.database).
Expand Down Expand Up @@ -47,6 +48,7 @@ func (d *DatabaseDriver) Create(ctx context.Context, spec interfaces.ResourceSpe
SizeSlug: size,
Region: region,
NumNodes: numNodes,
Rules: trustedSourceCreateRulesFromConfig(spec.Config),
}

db, _, err := d.client.Create(ctx, req)
Expand Down Expand Up @@ -74,6 +76,16 @@ func (d *DatabaseDriver) Update(ctx context.Context, ref interfaces.ResourceRef,
if err != nil {
return nil, fmt.Errorf("database update %q: %w", ref.Name, WrapGodoError(err))
}
// Sync firewall rules when trusted_sources key is present in config.
// "present but empty" clears all rules; absent key leaves existing rules unchanged.
if rules, ok := trustedSourceFirewallRulesFromConfig(spec.Config); ok {
_, err = d.client.UpdateFirewallRules(ctx, ref.ProviderID, &godo.DatabaseUpdateFirewallRulesRequest{
Rules: rules,
})
if err != nil {
return nil, fmt.Errorf("database update firewall %q: %w", ref.Name, WrapGodoError(err))
}
}
return d.Read(ctx, ref)
}

Expand Down Expand Up @@ -126,12 +138,70 @@ func (d *DatabaseDriver) SensitiveKeys() []string {
return []string{"uri", "password", "user"}
}

// trustedSourceCreateRulesFromConfig converts canonical "trusted_sources" to
// []*godo.DatabaseCreateFirewallRule for use in a DatabaseCreateRequest.
// Each entry must have "type" (ip_addr|k8s|app|droplet|tag) and "value".
func trustedSourceCreateRulesFromConfig(cfg map[string]any) []*godo.DatabaseCreateFirewallRule {
raw, ok := cfg["trusted_sources"].([]any)
if !ok || len(raw) == 0 {
return nil
}
rules := make([]*godo.DatabaseCreateFirewallRule, 0, len(raw))
for _, v := range raw {
m, ok := v.(map[string]any)
if !ok {
continue
}
ruleType := strFromConfig(m, "type", "")
ruleValue := strFromConfig(m, "value", "")
if ruleType == "" || ruleValue == "" {
continue
}
rules = append(rules, &godo.DatabaseCreateFirewallRule{
Type: ruleType,
Value: ruleValue,
})
}
return rules
}

// trustedSourceFirewallRulesFromConfig converts canonical "trusted_sources" to
// ([]*godo.DatabaseFirewallRule, bool) for use in a DatabaseUpdateFirewallRulesRequest.
// The bool indicates whether the "trusted_sources" key was present in config at all:
// - false → key absent → caller should leave existing firewall rules unchanged.
// - true, empty slice → key present but empty → caller should clear all rules.
// - true, non-empty slice → key present with rules → caller should apply the rules.
func trustedSourceFirewallRulesFromConfig(cfg map[string]any) ([]*godo.DatabaseFirewallRule, bool) {
raw, ok := cfg["trusted_sources"]
if !ok {
return nil, false // key absent: leave existing rules unchanged
}
list, _ := raw.([]any)
rules := make([]*godo.DatabaseFirewallRule, 0, len(list))
for _, v := range list {
m, ok := v.(map[string]any)
if !ok {
continue
}
ruleType := strFromConfig(m, "type", "")
ruleValue := strFromConfig(m, "value", "")
if ruleType == "" || ruleValue == "" {
continue
}
rules = append(rules, &godo.DatabaseFirewallRule{
Type: ruleType,
Value: ruleValue,
})
}
return rules, true
}

func dbOutput(db *godo.Database) *interfaces.ResourceOutput {
outputs := map[string]any{
"engine": db.EngineSlug,
"region": db.RegionSlug,
"size": db.SizeSlug,
"version": db.VersionSlug,
"engine": db.EngineSlug,
"region": db.RegionSlug,
"size": db.SizeSlug,
"version": db.VersionSlug,
}
if db.Connection != nil {
outputs["host"] = db.Connection.Host
Expand Down
Loading
Loading