Skip to content

Commit 86a3b73

Browse files
committed
feat: app-scoped control plane for multi-app deployments
Rewrites the control plane to key everything by app_id instead of project_id, enabling concurrent deploys across different apps in the same project. - New AppService (CreateApp, ListApps) with transactional app + settings creation - Deployment service resolves app from slug, uses app-scoped settings/env vars - Deploy worker loads app, upserts internal K8s services for inter-app DNS - Promote/rollback handlers use app.LiveDeploymentID instead of project - Routing and Restate workflows keyed by app_id - Domain generation always includes app slug in prefix - Env var template resolver (${{ shared.KEY }}, ${{ app.KEY }}) in svc/ctrl/internal/envresolve - Proto: app_slug on CreateDeploymentRequest, app_id on Deployment, new AppService
1 parent b1b9076 commit 86a3b73

File tree

19 files changed

+663
-116
lines changed

19 files changed

+663
-116
lines changed

gen/proto/ctrl/v1/deployment.pb.go

Lines changed: 25 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gen/proto/hydra/v1/deploy_restate.pb.go

Lines changed: 4 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "envresolve",
5+
srcs = ["resolve.go"],
6+
importpath = "github.com/unkeyed/unkey/svc/ctrl/internal/envresolve",
7+
visibility = ["//svc/ctrl:__subpackages__"],
8+
)
9+
10+
go_test(
11+
name = "envresolve_test",
12+
srcs = ["resolve_test.go"],
13+
embed = [":envresolve"],
14+
)
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package envresolve
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
"strings"
7+
)
8+
9+
// templatePattern matches ${{ ... }} references, capturing the inner content
10+
// with optional surrounding whitespace trimmed.
11+
var templatePattern = regexp.MustCompile(`\$\{\{\s*([^}]+?)\s*\}\}`)
12+
13+
// AppVar is an environment variable belonging to an app.
14+
type AppVar struct {
15+
Key string
16+
Value string
17+
}
18+
19+
// SiblingVar is an environment variable belonging to a sibling app.
20+
type SiblingVar struct {
21+
AppSlug string
22+
Key string
23+
Value string
24+
}
25+
26+
// Resolve processes template references in app environment variables.
27+
// It replaces ${{ ref }} patterns with actual values from the provided
28+
// lookup maps.
29+
//
30+
// appVars: this app's own variables (for self-reference)
31+
// sharedVars: environment-level shared variables (for ${{ shared.* }})
32+
// siblingVars: variables from sibling apps (for ${{ app-slug.* }})
33+
//
34+
// Returns a map of key -> resolved value, or error if a referenced variable doesn't exist.
35+
func Resolve(appVars []AppVar, sharedVars []AppVar, siblingVars []SiblingVar) (map[string]string, error) {
36+
// Build lookup maps.
37+
selfLookup := make(map[string]string, len(appVars))
38+
for _, v := range appVars {
39+
selfLookup[v.Key] = v.Value
40+
}
41+
42+
sharedLookup := make(map[string]string, len(sharedVars))
43+
for _, v := range sharedVars {
44+
sharedLookup[v.Key] = v.Value
45+
}
46+
47+
// siblingLookup is keyed by "appSlug.key".
48+
siblingLookup := make(map[string]string, len(siblingVars))
49+
for _, v := range siblingVars {
50+
siblingLookup[v.AppSlug+"."+v.Key] = v.Value
51+
}
52+
53+
result := make(map[string]string, len(appVars))
54+
55+
for _, av := range appVars {
56+
resolved, err := resolveValue(av.Value, selfLookup, sharedLookup, siblingLookup)
57+
if err != nil {
58+
return nil, fmt.Errorf("resolving variable %q: %w", av.Key, err)
59+
}
60+
result[av.Key] = resolved
61+
}
62+
63+
return result, nil
64+
}
65+
66+
// resolveValue replaces all ${{ ref }} patterns in a single value string.
67+
func resolveValue(
68+
value string,
69+
selfLookup map[string]string,
70+
sharedLookup map[string]string,
71+
siblingLookup map[string]string,
72+
) (string, error) {
73+
var resolveErr error
74+
75+
resolved := templatePattern.ReplaceAllStringFunc(value, func(match string) string {
76+
if resolveErr != nil {
77+
return match
78+
}
79+
80+
// Extract the captured group from the match.
81+
submatches := templatePattern.FindStringSubmatch(match)
82+
if len(submatches) < 2 {
83+
resolveErr = fmt.Errorf("invalid template syntax: %s", match)
84+
return match
85+
}
86+
87+
ref := submatches[1]
88+
89+
if dotIdx := strings.IndexByte(ref, '.'); dotIdx != -1 {
90+
scope := ref[:dotIdx]
91+
key := ref[dotIdx+1:]
92+
93+
if scope == "shared" {
94+
val, ok := sharedLookup[key]
95+
if !ok {
96+
resolveErr = fmt.Errorf("shared variable %q not found", key)
97+
return match
98+
}
99+
return val
100+
}
101+
102+
// Scope is an app slug; look up in sibling vars.
103+
lookupKey := scope + "." + key
104+
val, ok := siblingLookup[lookupKey]
105+
if !ok {
106+
resolveErr = fmt.Errorf("sibling app variable %q.%q not found", scope, key)
107+
return match
108+
}
109+
return val
110+
}
111+
112+
// No dot: self-reference.
113+
val, ok := selfLookup[ref]
114+
if !ok {
115+
resolveErr = fmt.Errorf("self-referenced variable %q not found", ref)
116+
return match
117+
}
118+
return val
119+
})
120+
121+
if resolveErr != nil {
122+
return "", resolveErr
123+
}
124+
125+
return resolved, nil
126+
}

0 commit comments

Comments
 (0)