Skip to content

Commit 4aa077b

Browse files
feat(resolvables): target config resolution and cascade target deletes
Add support for resolvable target configs ($ref references to resource properties) and cascade target deletes on destroy. Target config resolution: - Targets can reference resource properties via $res/$ref (e.g., a Grafana target resolving its endpoint from a Compose stack) - TargetUpdater FSM gains a Resolving state for config $ref resolution - DAG edges enforce ordering: target resolves before its resources start - Resolve cache strips resolvable metadata before passing config to plugins - Non-blocking retries in resolve cache using SendAfter Cascade target deletes: - On destroy, targets with $ref dependencies are automatically deleted (plain targets like docker/us-east-1 survive destroy) - BFS discovery of transitive cascade chains via FindTargetsDependingOnMany - Cascade-failed targets properly marked as Failed in FormaCommandPersister - IsCascade/CascadeSource fields on target and resource updates for CLI display Also includes: - DB-agnostic idempotent target create (replaces SQLite-specific constraint matching) - StateNotStarted as initial TargetUpdater FSM state - Mutation test timeout increased to 90 minutes
1 parent 67616df commit 4aa077b

32 files changed

Lines changed: 2170 additions & 207 deletions

.github/workflows/mutation-test-pr.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ on:
77
jobs:
88
mutation-test:
99
runs-on: ubuntu-latest
10-
timeout-minutes: 60
10+
timeout-minutes: 90
1111
continue-on-error: true
1212
steps:
1313
- uses: actions/checkout@v4

internal/cli/destroy/destroy.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,25 @@ func runDestroyForHumans(app *app.App, opts *DestroyOptions) error {
184184
}
185185
}
186186

187+
hasCascadeTargets := false
188+
for _, tu := range res.Simulation.Command.TargetUpdates {
189+
if tu.IsCascade {
190+
hasCascadeTargets = true
191+
break
192+
}
193+
}
194+
if hasCascadeTargets {
195+
fmt.Printf("\n%s\n\n", display.Grey("The following targets will be cascade-deleted:"))
196+
for _, tu := range res.Simulation.Command.TargetUpdates {
197+
if tu.IsCascade {
198+
fmt.Printf(" %s %s (depends on %s)\n",
199+
display.Red("•"),
200+
display.LightBlue(tu.TargetLabel),
201+
display.Grey(tu.CascadeSource))
202+
}
203+
}
204+
}
205+
187206
fmt.Printf("\n%s\n", display.Grey("To proceed with cascade deletes, use --on-dependents=cascade"))
188207
return fmt.Errorf("cascade deletes detected, aborting (use --on-dependents=cascade to proceed)")
189208
}
@@ -259,12 +278,17 @@ func runDestroyForMachines(app *app.App, opts *DestroyOptions) error {
259278
return printer.Print(&apimodel.CommandID{CommandID: res.CommandID})
260279
}
261280

262-
// hasCascadeDeletes checks if any resource updates in the command are cascade deletes
281+
// hasCascadeDeletes checks if any resource or target updates in the command are cascade deletes
263282
func hasCascadeDeletes(cmd *apimodel.Command) bool {
264283
for _, ru := range cmd.ResourceUpdates {
265284
if ru.IsCascade {
266285
return true
267286
}
268287
}
288+
for _, tu := range cmd.TargetUpdates {
289+
if tu.IsCascade {
290+
return true
291+
}
292+
}
269293
return false
270294
}

internal/datastore/aurora/aurora.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1845,6 +1845,99 @@ func (d *DatastoreAuroraDataAPI) FindResourcesDependingOnMany(ksuids []string) (
18451845
return result, nil
18461846
}
18471847

1848+
func (d *DatastoreAuroraDataAPI) FindTargetsDependingOnMany(ksuids []string) (map[string][]*pkgmodel.Target, error) {
1849+
ctx := context.Background()
1850+
1851+
if len(ksuids) == 0 {
1852+
return make(map[string][]*pkgmodel.Target), nil
1853+
}
1854+
1855+
// Build OR conditions for each KSUID pattern with named parameters.
1856+
// Use regex to handle PostgreSQL's jsonb::text formatting, which adds spaces after colons.
1857+
var conditions []string
1858+
var params []types.SqlParameter
1859+
for i, ksuid := range ksuids {
1860+
pattern := fmt.Sprintf(`"\$ref"\s*:\s*"formae://%s#`, ksuid)
1861+
paramName := fmt.Sprintf("pattern%d", i)
1862+
conditions = append(conditions, fmt.Sprintf("config::text ~ :%s", paramName))
1863+
params = append(params, types.SqlParameter{
1864+
Name: aws.String(paramName),
1865+
Value: &types.FieldMemberStringValue{Value: pattern},
1866+
})
1867+
}
1868+
1869+
query := fmt.Sprintf(`
1870+
SELECT label, version, namespace, config, discoverable
1871+
FROM targets t1
1872+
WHERE (%s)
1873+
AND NOT EXISTS (
1874+
SELECT 1
1875+
FROM targets t2
1876+
WHERE t1.label = t2.label
1877+
AND t2.version > t1.version
1878+
)
1879+
`, strings.Join(conditions, " OR "))
1880+
1881+
output, err := d.executeStatement(ctx, query, params)
1882+
if err != nil {
1883+
return nil, err
1884+
}
1885+
1886+
// Build a map of KSUID -> targets that depend on it
1887+
result := make(map[string][]*pkgmodel.Target)
1888+
for _, record := range output.Records {
1889+
if len(record) < 5 {
1890+
return nil, fmt.Errorf("unexpected record length: %d", len(record))
1891+
}
1892+
1893+
label, err := getStringField(record[0])
1894+
if err != nil {
1895+
return nil, fmt.Errorf("failed to parse label: %w", err)
1896+
}
1897+
1898+
version, err := getIntField(record[1])
1899+
if err != nil {
1900+
return nil, fmt.Errorf("failed to parse version: %w", err)
1901+
}
1902+
1903+
namespace, err := getStringField(record[2])
1904+
if err != nil {
1905+
return nil, fmt.Errorf("failed to parse namespace: %w", err)
1906+
}
1907+
1908+
config, err := getRawJSONField(record[3])
1909+
if err != nil {
1910+
return nil, fmt.Errorf("failed to parse config: %w", err)
1911+
}
1912+
1913+
discoverable, err := getBoolField(record[4])
1914+
if err != nil {
1915+
return nil, fmt.Errorf("failed to parse discoverable: %w", err)
1916+
}
1917+
1918+
target := &pkgmodel.Target{
1919+
Label: label,
1920+
Namespace: namespace,
1921+
Config: config,
1922+
Discoverable: discoverable,
1923+
Version: version,
1924+
}
1925+
1926+
// Find which of the input KSUIDs this target depends on.
1927+
// jsonb::text output has spaces after colons, so check both forms.
1928+
configStr := string(config)
1929+
for _, ksuid := range ksuids {
1930+
withSpace := fmt.Sprintf("\"$ref\": \"formae://%s#", ksuid)
1931+
withoutSpace := fmt.Sprintf("\"$ref\":\"formae://%s#", ksuid)
1932+
if strings.Contains(configStr, withSpace) || strings.Contains(configStr, withoutSpace) {
1933+
result[ksuid] = append(result[ksuid], target)
1934+
}
1935+
}
1936+
}
1937+
1938+
return result, nil
1939+
}
1940+
18481941
func (d *DatastoreAuroraDataAPI) StoreStack(stack *pkgmodel.Forma, commandID string) (string, error) {
18491942
var lastVersionID string
18501943
for _, resource := range stack.Resources {

internal/datastore/datastore.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,9 @@ type Datastore interface {
153153
// FindResourcesDependingOnMany returns all resources that reference any of the given resources via $ref.
154154
// Returns a map from referenced KSUID to the resources that depend on it.
155155
FindResourcesDependingOnMany(ksuids []string) (map[string][]*pkgmodel.Resource, error)
156+
// FindTargetsDependingOnMany returns all targets whose config references any of the given resources via $ref.
157+
// Returns a map from source KSUID to the list of dependent targets.
158+
FindTargetsDependingOnMany(ksuids []string) (map[string][]*pkgmodel.Target, error)
156159

157160
// Resource-by-stack operations - query resources grouped by stack
158161

internal/datastore/mock_datastore_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ func (m *mockDatastore) FindResourcesDependingOn(_ string) ([]*pkgmodel.Resource
7171
func (m *mockDatastore) FindResourcesDependingOnMany(_ []string) (map[string][]*pkgmodel.Resource, error) {
7272
return nil, nil
7373
}
74+
func (m *mockDatastore) FindTargetsDependingOnMany(_ []string) (map[string][]*pkgmodel.Target, error) {
75+
return make(map[string][]*pkgmodel.Target), nil
76+
}
7477
func (m *mockDatastore) BulkStoreResources(_ []pkgmodel.Resource, _ string) (string, error) {
7578
return "", nil
7679
}

internal/datastore/postgres/postgres.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,6 +1174,76 @@ func (d DatastorePostgres) FindResourcesDependingOnMany(ksuids []string) (map[st
11741174
return result, nil
11751175
}
11761176

1177+
func (d DatastorePostgres) FindTargetsDependingOnMany(ksuids []string) (map[string][]*pkgmodel.Target, error) {
1178+
ctx, span := tracer.Start(context.Background(), "FindTargetsDependingOnMany")
1179+
defer span.End()
1180+
1181+
if len(ksuids) == 0 {
1182+
return make(map[string][]*pkgmodel.Target), nil
1183+
}
1184+
1185+
// Build OR conditions for each KSUID pattern with numbered placeholders
1186+
// Use regex to handle Postgres JSONB text formatting which adds spaces after colons
1187+
var conditions []string
1188+
var args []any
1189+
for i, ksuid := range ksuids {
1190+
pattern := fmt.Sprintf(`"\$ref"\s*:\s*"formae://%s#`, ksuid)
1191+
conditions = append(conditions, fmt.Sprintf("config::text ~ $%d", i+1))
1192+
args = append(args, pattern)
1193+
}
1194+
1195+
query := fmt.Sprintf(`
1196+
SELECT label, version, namespace, config, discoverable
1197+
FROM targets t1
1198+
WHERE (%s)
1199+
AND NOT EXISTS (
1200+
SELECT 1
1201+
FROM targets t2
1202+
WHERE t1.label = t2.label
1203+
AND t2.version > t1.version
1204+
)
1205+
`, strings.Join(conditions, " OR "))
1206+
1207+
rows, err := d.pool.Query(ctx, query, args...)
1208+
if err != nil {
1209+
return nil, err
1210+
}
1211+
defer rows.Close()
1212+
1213+
// Build a map of KSUID -> targets that depend on it
1214+
result := make(map[string][]*pkgmodel.Target)
1215+
for rows.Next() {
1216+
var label, namespace string
1217+
var version int
1218+
var config json.RawMessage
1219+
var discoverable bool
1220+
if err := rows.Scan(&label, &version, &namespace, &config, &discoverable); err != nil {
1221+
return nil, err
1222+
}
1223+
1224+
target := &pkgmodel.Target{
1225+
Label: label,
1226+
Namespace: namespace,
1227+
Config: config,
1228+
Discoverable: discoverable,
1229+
Version: version,
1230+
}
1231+
1232+
// Find which of the input KSUIDs this target depends on
1233+
// jsonb::text output has spaces after colons, so check both forms.
1234+
configStr := string(config)
1235+
for _, ksuid := range ksuids {
1236+
withSpace := fmt.Sprintf("\"$ref\": \"formae://%s#", ksuid)
1237+
withoutSpace := fmt.Sprintf("\"$ref\":\"formae://%s#", ksuid)
1238+
if strings.Contains(configStr, withSpace) || strings.Contains(configStr, withoutSpace) {
1239+
result[ksuid] = append(result[ksuid], target)
1240+
}
1241+
}
1242+
}
1243+
1244+
return result, nil
1245+
}
1246+
11771247
func (d DatastorePostgres) LoadResourceByNativeID(nativeID string, resourceType string) (*pkgmodel.Resource, error) {
11781248
ctx, span := tracer.Start(context.Background(), "LoadResourceByNativeID")
11791249
defer span.End()

internal/datastore/sqlite/sqlite.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3117,6 +3117,79 @@ func (d DatastoreSQLite) FindResourcesDependingOnMany(ksuids []string) (map[stri
31173117
return result, nil
31183118
}
31193119

3120+
func (d DatastoreSQLite) FindTargetsDependingOnMany(ksuids []string) (map[string][]*pkgmodel.Target, error) {
3121+
slog.Debug("SQLite START", "method", "FindTargetsDependingOnMany", "ksuids", len(ksuids))
3122+
start := time.Now()
3123+
defer func() {
3124+
slog.Debug("SQLite END", "method", "FindTargetsDependingOnMany", "ksuids", len(ksuids), "duration", time.Since(start))
3125+
}()
3126+
_, span := sqliteTracer.Start(context.Background(), "FindTargetsDependingOnMany")
3127+
defer span.End()
3128+
3129+
if len(ksuids) == 0 {
3130+
return make(map[string][]*pkgmodel.Target), nil
3131+
}
3132+
3133+
// Build OR conditions for each KSUID pattern
3134+
// The format is: "$ref":"formae://KSUID#/..."
3135+
var conditions []string
3136+
var args []any
3137+
for _, ksuid := range ksuids {
3138+
pattern := fmt.Sprintf("%%\"$ref\":\"formae://%s#%%", ksuid)
3139+
conditions = append(conditions, "config LIKE ?")
3140+
args = append(args, pattern)
3141+
}
3142+
3143+
query := fmt.Sprintf(`
3144+
SELECT label, version, namespace, config, discoverable
3145+
FROM targets t1
3146+
WHERE (%s)
3147+
AND NOT EXISTS (
3148+
SELECT 1
3149+
FROM targets t2
3150+
WHERE t1.label = t2.label
3151+
AND t2.version > t1.version
3152+
)
3153+
`, strings.Join(conditions, " OR "))
3154+
3155+
rows, err := d.conn.Query(query, args...)
3156+
if err != nil {
3157+
return nil, err
3158+
}
3159+
defer closeRows(rows)
3160+
3161+
// Build a map of KSUID -> targets that depend on it
3162+
result := make(map[string][]*pkgmodel.Target)
3163+
for rows.Next() {
3164+
var label, namespace string
3165+
var version int
3166+
var config json.RawMessage
3167+
var discoverable int
3168+
if err := rows.Scan(&label, &version, &namespace, &config, &discoverable); err != nil {
3169+
return nil, err
3170+
}
3171+
3172+
target := &pkgmodel.Target{
3173+
Label: label,
3174+
Namespace: namespace,
3175+
Config: config,
3176+
Discoverable: discoverable == 1,
3177+
Version: version,
3178+
}
3179+
3180+
// Find which of the input KSUIDs this target depends on
3181+
configStr := string(config)
3182+
for _, ksuid := range ksuids {
3183+
pattern := fmt.Sprintf("\"$ref\":\"formae://%s#", ksuid)
3184+
if strings.Contains(configStr, pattern) {
3185+
result[ksuid] = append(result[ksuid], target)
3186+
}
3187+
}
3188+
}
3189+
3190+
return result, nil
3191+
}
3192+
31203193
func (d DatastoreSQLite) GetKSUIDByTriplet(stack, label, resourceType string) (string, error) {
31213194
_, span := sqliteTracer.Start(context.Background(), "GetKSUIDByTriplet")
31223195
defer span.End()

0 commit comments

Comments
 (0)