package hostclaim import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "math" "os" "github.com/carlosframework/platform/internal/release" ) var ErrConflict = fmt.Errorf("hostclaim: claim changed since it was read: %w", release.ErrCASConflict) type Observed struct { Host string Claim *Claim ETag string Absent bool } func decodeStrict(body []byte) (*Claim, error) { dec := json.NewDecoder(bytes.NewReader(body)) dec.DisallowUnknownFields() var c Claim if err := dec.Decode(&c); err != nil { return nil, fmt.Errorf("%w: %v", ErrMalformed, err) } if _, err := dec.Token(); err != io.EOF { return nil, fmt.Errorf("%w: trailing data", ErrMalformed) } return &c, nil } func Read(ctx context.Context, s release.Store, host string) (Observed, error) { key, err := Key(host) if err != nil { return Observed{}, err } body, etag, err := s.Get(ctx, key) if errors.Is(err, os.ErrNotExist) { return Observed{Host: host, Absent: true}, nil } if err != nil { return Observed{}, err // C6: an error is never absence } c, err := decodeStrict(body) if err != nil { return Observed{}, err } if c.Host != host { return Observed{}, fmt.Errorf("%w: host mismatch", ErrMalformed) } if err := c.Validate(); err != nil { return Observed{}, err } return Observed{Host: host, Claim: c, ETag: etag}, nil } func Update(ctx context.Context, s release.Store, obs Observed, tr func(*Claim) error) (Observed, error) { key, err := Key(obs.Host) if err != nil { return Observed{}, err } var next *Claim var oldGen uint64 if obs.Absent { next = &Claim{V: schemaVersion, Host: obs.Host} } else { if obs.ETag == "" { return Observed{}, fmt.Errorf("%w: non-absent observation without ETag", ErrMalformed) } if obs.Claim == nil || obs.Claim.Host != obs.Host { return Observed{}, fmt.Errorf("%w: observation not bound to %s", ErrMalformed, obs.Host) } raw, err := json.Marshal(obs.Claim) if err != nil { return Observed{}, err } if next, err = decodeStrict(raw); err != nil { return Observed{}, err } oldGen = obs.Claim.Gen if obs.Claim.Origins != nil || originEnabled() { fresh, err := Read(ctx, s, obs.Host) if err != nil { return Observed{}, err } if fresh.Absent || fresh.ETag != obs.ETag || !sameOriginHead(fresh.Claim.Origins, obs.Claim.Origins) { return Observed{}, ErrConflict } } } if err := tr(next); err != nil { return Observed{}, err } if next.Host != obs.Host || next.Gen != oldGen { return Observed{}, fmt.Errorf("%w: a transition may not change host or gen", ErrMalformed) } if oldGen == math.MaxUint64 { return Observed{}, fmt.Errorf("%w: gen overflow", ErrMalformed) } next.Gen = oldGen + 1 if err := next.Validate(); err != nil { return Observed{}, err } if err := commitOrigins(ctx, s, obs, next); err != nil { return Observed{}, err } body, err := json.Marshal(next) if err != nil { return Observed{}, err } ifMatch := obs.ETag if obs.Absent { ifMatch = "" // the host's very first claim (C5a) } etag, err := s.Put(ctx, key, body, ifMatch) if errors.Is(err, release.ErrCASConflict) { return Observed{}, ErrConflict } if err != nil { return Observed{}, err } return Observed{Host: obs.Host, Claim: next, ETag: etag}, nil }