-
Notifications
You must be signed in to change notification settings - Fork 1
/
spice.go
413 lines (360 loc) · 10.5 KB
/
spice.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
package main
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"github.com/alecthomas/kong"
v1 "github.com/authzed/authzed-go/proto/authzed/api/v1"
"github.com/authzed/authzed-go/v1"
"github.com/authzed/grpcutil"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
"gopkg.in/yaml.v3"
)
const MigrationResourceType = "spice_schema_migrations/migration"
const MigrationResourceId = "current"
const MigrationRelation = "version"
const MigrationSubjectType = "spice_schema_migrations/version"
// CLI is filled by kong.Parse in main
var CLI struct {
Migrate struct {
Force bool `help:"Skip schema version check."`
} `cmd:"" help:"Migrate SpiceDB schema"`
Schema struct {
} `cmd:"" help:"Print SpiceDB schema"`
Test struct{} `cmd:"" help:"Runs SpiceDB tests using zed"`
Directory string `flag:"" short:"d" type:"path" default:"./spicedb" help:"SpiceDB directory"`
Endpoint string `flag:"" short:"e" env:"ZED_ENDPOINT" help:"e.g., 'spicedb:50051'"`
Token string `flag:"" short:"t" env:"ZED_TOKEN" help:"SpiceDB token"`
Insecure bool `flag:"" default:"false" env:"ZED_INSECURE" help:"connect over plaintext connection"`
}
func main() {
ctx := kong.Parse(&CLI)
switch ctx.Command() {
case "migrate":
migrateCmd(ctx)
case "schema":
schemaCmd(ctx)
case "test":
testCmd(ctx)
default:
panic(ctx.Command())
}
}
// collectSchema finds all .zed files and concats them
func collectSchema() string {
pattern := filepath.Join(CLI.Directory, "./*.zed")
matches, err := filepath.Glob(pattern)
if err != nil {
fmt.Println("cant glob " + pattern)
panic(err)
}
var schema string
for _, file := range matches {
content, err := os.ReadFile(file)
if err != nil {
fmt.Println("could not read " + file)
panic(err)
}
head := "\n//\n// " + filepath.Base(file) + "\n//\n\n"
schema += head + string(content)
}
return schema
}
// getDiskVersion parses the _version file
func getDiskVersion() int {
content, err := os.ReadFile(filepath.Join(CLI.Directory, "_version"))
if err != nil {
fmt.Println("could not read _version")
panic(err)
}
contentStr := strings.TrimSpace(string(content))
i, err := strconv.Atoi(contentStr)
if err != nil {
fmt.Println("_version content not a number")
}
return i
}
// getClient yields an authed.Client using CLI arguments
func getClient() *authzed.Client {
endpoint := CLI.Endpoint
token := CLI.Token
insec := CLI.Insecure
if endpoint == "" || token == "" {
panic("endpoint and token must be set, see --help")
}
// set token
var opts []grpc.DialOption
if insec {
fmt.Println("WARN: using insecure connection")
opts = append(
opts,
grpcutil.WithInsecureBearerToken(token),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
} else {
// never tested this branch, might not work
opts = append(opts, grpcutil.WithBearerToken(token))
}
// create client
client, err := authzed.NewClient(endpoint, opts...)
if err != nil {
fmt.Println("Could not create SpiceDB client")
panic(err)
}
return client
}
// getCurrentVersion queries existing relations to parse the currently deployed version of the schema
func getCurrentVersion(ctx context.Context, client *authzed.Client) int {
// first, open a stream
stream, err := client.ReadRelationships(ctx, &v1.ReadRelationshipsRequest{
RelationshipFilter: &v1.RelationshipFilter{
ResourceType: MigrationResourceType,
OptionalResourceId: MigrationResourceId,
OptionalRelation: MigrationRelation,
},
OptionalLimit: 0,
})
if err != nil {
fmt.Println("could not open relationship stream")
panic(err)
}
// now collect the first element
response, err := stream.Recv()
if err != nil {
if err == io.EOF {
return 0
}
if statusErr, ok := status.FromError(err); ok {
if statusErr.Code() == codes.FailedPrecondition && statusErr.Message() == "object definition `spice_schema_migrations/migration` not found" {
return 0
}
}
fmt.Println("could not read relationships")
panic(err)
}
// parse id to int
versionStr := response.Relationship.Subject.Object.ObjectId
i, err := strconv.Atoi(versionStr)
if err != nil {
fmt.Println("current version is not a number")
}
// if more relations exist, the version is not clear, user must fix it
_, err = stream.Recv()
if err != io.EOF {
panic("more than one version relation exist")
}
return i
}
// writeSchema writes a whole schema to spicedb
func writeSchema(ctx context.Context, client *authzed.Client, schema string) {
_, err := client.WriteSchema(ctx, &v1.WriteSchemaRequest{Schema: schema})
if err != nil {
fmt.Println("could not write new schema")
panic(err)
}
}
// relationshipOfVersion yields a Relationship object, helper for updateCurrentVersion
func relationshipOfVersion(version int) *v1.Relationship {
return &v1.Relationship{
Resource: &v1.ObjectReference{
ObjectType: MigrationResourceType,
ObjectId: MigrationResourceId,
},
Relation: MigrationRelation,
Subject: &v1.SubjectReference{
Object: &v1.ObjectReference{
ObjectType: MigrationSubjectType,
ObjectId: strconv.Itoa(version),
},
},
}
}
// updateCurrentVersion removes the old relation and adds a new relation (atomically)
func updateCurrentVersion(ctx context.Context, client *authzed.Client, oldVersion, newVersion int) {
_, err := client.WriteRelationships(ctx, &v1.WriteRelationshipsRequest{
Updates: []*v1.RelationshipUpdate{
{
Operation: v1.RelationshipUpdate_OPERATION_TOUCH, // Create newVersion Relation
Relationship: relationshipOfVersion(newVersion),
},
{
Operation: v1.RelationshipUpdate_OPERATION_DELETE, // Delete oldVersion Relation
Relationship: relationshipOfVersion(oldVersion),
},
},
})
if err != nil {
fmt.Println("could not update version")
panic(err)
}
}
// migrateCmd is the <migrate> command handler
func migrateCmd(kctx *kong.Context) {
ctx := context.Background()
client := getClient()
diskVersion := getDiskVersion()
currentVersion := getCurrentVersion(ctx, client)
fmt.Printf("current: %d, expected: %d\n", currentVersion, diskVersion)
if diskVersion == currentVersion {
fmt.Println("no action needed")
return
} else if diskVersion < currentVersion {
fmt.Println("WARN: you are running an old schema version")
return
}
fmt.Println("migrating")
writeSchema(ctx, client, collectSchema())
updateCurrentVersion(ctx, client, currentVersion, diskVersion)
fmt.Println("done")
}
// schemaCmd is the <schema> command handler
func schemaCmd(kctx *kong.Context) {
schema := collectSchema()
fmt.Print(schema)
}
//
// Test Yaml Structs
//
type TestRelationship = string
// TestCheck is parsed from either a string or a mapping of `not: string`
type TestCheck struct {
Not bool
Value TestRelationship
}
func (c *TestCheck) UnmarshalYAML(node *yaml.Node) error {
// node is a string
if node.Kind == yaml.ScalarNode {
c.Value = node.Value
c.Not = false
return nil
}
// If it's not a string, try to unmarshal
var obj struct {
Not string `yaml:"not"`
}
err := node.Decode(&obj)
if err != nil {
return err
}
c.Value = obj.Not
c.Not = true
return nil
}
type TestValidate struct {
Relation string `yaml:"relation"`
Exhaustive []string `yaml:"exhaustive,omitempty"`
}
type TestBlock struct {
Name string `yaml:"name,omitempty"`
Check []TestCheck `yaml:"check,omitempty"`
Validate []TestValidate `yaml:"validate,omitempty"`
}
type TestFile struct {
Relationships []TestRelationship `yaml:"relationships"`
Tests []TestBlock `yaml:"tests"`
}
type SpiceDBAssertion struct {
AssertTrue []string `yaml:"assertTrue,omitempty"`
AssertFalse []string `yaml:"assertFalse,omitempty"`
}
// SpiceDBValidationFile is the schema zed uses for `zed validate`
type SpiceDBValidationFile struct {
Schema string `yaml:"schema"`
Relationships string `yaml:"relationships,omitempty"`
Assertions SpiceDBAssertion `yaml:"assertions,omitempty"`
Validation map[string][]string `yaml:"validation,omitempty"`
}
// execute runs a command with the same stdout and stdin as the current process
func execute(name string, arg ...string) {
cmd := exec.Command(name, arg...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
panic(err)
}
}
// readTestFile reads and parses a TestFile
func readTestFile(path string) (parsed TestFile) {
content, err := os.ReadFile(path)
if err != nil {
fmt.Print("could not read file " + path)
panic(err)
}
err = yaml.Unmarshal(content, &parsed)
if err != nil {
fmt.Println("could not parse " + path)
panic(err)
}
return
}
// writeSpiceDBYaml marshals and write a SpiceDB file to disk
func writeSpiceDBYaml(file SpiceDBValidationFile, path string) {
marshalled, err := yaml.Marshal(file)
if err != nil {
fmt.Println("could not marshal spicedb yaml for " + path)
panic(err)
}
err = os.WriteFile(path, marshalled, 0644)
if err != nil {
fmt.Println("could not write spicedb yaml for " + path)
panic(err)
}
}
// testCmd is the <test> command handler
func testCmd(kctx *kong.Context) {
schema := collectSchema()
// collect test yamls
pattern := filepath.Join(CLI.Directory, "./*.test.yaml")
files, err := filepath.Glob(pattern)
if err != nil {
fmt.Println("could not glob pattern " + pattern)
panic(err)
}
// prepare fs
buildDir := filepath.Join(CLI.Directory, "./build")
_ = os.RemoveAll(buildDir)
err = os.MkdirAll(buildDir, os.ModePerm)
if err != nil {
panic(err)
}
// handle test yamls
for _, file := range files {
parsed := readTestFile(file)
// re-format assertions and validations
assertions := SpiceDBAssertion{}
validation := make(map[string][]string)
for _, test := range parsed.Tests {
for _, check := range test.Check {
if check.Not {
assertions.AssertFalse = append(assertions.AssertFalse, check.Value)
} else {
assertions.AssertTrue = append(assertions.AssertTrue, check.Value)
}
}
for _, validate := range test.Validate {
// I wonder how zed deals with multiple occurrences of the same subject here
validation[validate.Relation] = append(validation[validate.Relation], validate.Exhaustive...)
}
}
// write new spicedb.yaml
outFilepath := filepath.Join(buildDir, filepath.Base(file))
writeSpiceDBYaml(SpiceDBValidationFile{
Schema: schema,
Relationships: strings.Join(parsed.Relationships, "\n"),
Assertions: assertions,
Validation: validation,
}, outFilepath)
// run zed validate
fmt.Println(outFilepath)
execute("zed", "validate", outFilepath)
}
}