feat(api): agents, enrollment tokens, revisions, deployments and leases

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
beug.k@beugsiud.de
2026-10-13 19:02:50 +02:00
co-authored by Claude Opus 5.5
parent 1bdd877051
commit 92d1d9ec3d
4 changed files with 654 additions and 2 deletions
+420
View File
@@ -0,0 +1,420 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package httpapi
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"errors"
"strconv"
"strings"
"testing"
"time"
"git.beugsiud.de/beug.k/DDI/internal/brand"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
)
// enroll registers an agent through the service (the gRPC layer is tested
// in package agentapi).
func (e *env) enroll(token, role string) string {
e.t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
e.t.Fatal(err)
}
csr, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{}, key)
if err != nil {
e.t.Fatal(err)
}
res, err := e.svc.EnrollAgent(context.Background(), service.EnrollInput{Token: token, CSR: csr, Role: role,
Hostname: "host-" + role, AgentVersion: "1.0.0"})
if err != nil {
e.t.Fatalf("enroll: %v", err)
}
return res.AgentID
}
func (c *client) createToken(role string) service.EnrollmentTokenCreated {
c.e.t.Helper()
var out service.EnrollmentTokenCreated
c.mustStatus(201, "POST", "/api/v1/enrollment-tokens", map[string]any{"role": role, "description": "for " + role}).json(c.e.t, &out)
return out
}
// configureDNS stores generator settings that allow DNS generation.
func (c *client) configureDNS() {
c.e.t.Helper()
var g service.GeneratorSettings
c.mustStatus(200, "GET", "/api/v1/settings/generator", nil).json(c.e.t, &g)
g.DNS.PrimaryNS = "ns1.office.test"
g.DNS.Hostmaster = "hostmaster@office.test"
g.DNS.NameServers = []service.GeneratorNS{{Name: "ns1.office.test", Addresses: []string{"192.0.2.53"}}}
c.mustStatus(200, "PUT", "/api/v1/settings/generator", g)
}
type agentList = listResponse[service.AgentDTO]
func TestEnrollmentTokensAndAgents(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
viewer := e.userClient(admin, "vera", "viewer")
operator := e.userClient(admin, "otto", "operator")
var agents agentList
viewer.mustStatus(200, "GET", "/api/v1/agents", nil).json(t, &agents)
if len(agents.Items) != 0 {
t.Fatalf("agents %+v", agents.Items)
}
viewer.mustStatus(403, "GET", "/api/v1/enrollment-tokens", nil)
operator.mustStatus(403, "POST", "/api/v1/enrollment-tokens", map[string]any{"role": "dns"})
tok := admin.createToken("dns")
id, secret, ok := strings.Cut(tok.Secret, ".")
if !ok || len(id) != 16 || id != tok.Token.ID || len(secret) != 43 {
t.Errorf("token format %q", tok.Secret)
}
if tok.CAFingerprint != e.pki.Fingerprint() || tok.ServerAddress != "ddi.example.test:8444" {
t.Errorf("token %+v", tok)
}
wantCmd := brand.AgentDNSBinary + " enroll --server ddi.example.test:8444 --token " + tok.Secret + " --ca-hash " + e.pki.Fingerprint()
if tok.Command != wantCmd {
t.Errorf("command %q, want %q", tok.Command, wantCmd)
}
if dhcp := admin.createToken("dhcp"); !strings.HasPrefix(dhcp.Command, brand.AgentDHCPBinary+" enroll ") {
t.Errorf("dhcp command %q", dhcp.Command)
}
r := admin.mustStatus(422, "POST", "/api/v1/enrollment-tokens", map[string]any{"role": "dns", "valid_hours": 721},
skipRequestValidation, "1")
if p := r.problem(t).Problems; len(p) != 1 || p[0].Field != "valid_hours" {
t.Errorf("problems %+v", p)
}
var tokens listResponse[service.EnrollmentTokenDTO]
admin.mustStatus(200, "GET", "/api/v1/enrollment-tokens", nil).json(t, &tokens)
if len(tokens.Items) != 2 || strings.Contains(string(admin.do("GET", "/api/v1/enrollment-tokens", nil).Body), secret) {
t.Fatalf("tokens %+v", tokens.Items)
}
// Enrollment through the service: wrong role, wrong secret, success,
// reuse.
ctx := context.Background()
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
csr, _ := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{}, key)
if _, err := e.svc.EnrollAgent(ctx, service.EnrollInput{Token: tok.Secret, CSR: csr, Role: "dhcp"}); !errors.Is(err, service.ErrTokenRoleMismatch) {
t.Errorf("wrong role: %v", err)
}
if _, err := e.svc.EnrollAgent(ctx, service.EnrollInput{Token: id + ".wrong", CSR: csr, Role: "dns"}); !errors.Is(err, service.ErrInvalidToken) {
t.Errorf("wrong secret: %v", err)
}
agentID := e.enroll(tok.Secret, "dns")
if _, err := e.svc.EnrollAgent(ctx, service.EnrollInput{Token: tok.Secret, CSR: csr, Role: "dns"}); !errors.Is(err, service.ErrInvalidToken) {
t.Errorf("reused token: %v", err)
}
admin.mustStatus(200, "GET", "/api/v1/enrollment-tokens", nil).json(t, &tokens)
var used *service.EnrollmentTokenDTO
for i := range tokens.Items {
if tokens.Items[i].ID == id {
used = &tokens.Items[i]
}
}
if used == nil || used.UsedAt == nil || used.AgentID != agentID {
t.Errorf("used token %+v", used)
}
var ag service.AgentDTO
viewer.mustStatus(200, "GET", "/api/v1/agents/"+agentID, nil).json(t, &ag)
if ag.Role != "dns" || ag.Hostname != "host-dns" || ag.Connected || ag.Mode != "auto_pull" || ag.Health.State != "unknown" ||
ag.DesiredRevision != 0 || ag.CertExpiresAt.IsZero() {
t.Errorf("agent %+v", ag)
}
viewer.mustStatus(404, "GET", "/api/v1/agents/not-a-uuid", nil)
viewer.mustStatus(404, "GET", "/api/v1/agents/00000000-0000-4000-8000-000000000000", nil)
// Status from the agent.
if _, err := e.svc.AgentHello(ctx, agentID, service.HelloInput{Hostname: "ns1.office.test", AgentVersion: "1.0.1",
ServiceVersion: "BIND 9.20.3", Health: &service.AgentHealth{State: "ok", Checks: []service.HealthCheck{{Name: "rndc", State: "ok"}}}}); err != nil {
t.Fatal(err)
}
conn := e.hub.Attach(agentID, "dns", "auto_pull")
viewer.mustStatus(200, "GET", "/api/v1/agents", nil).json(t, &agents)
if len(agents.Items) != 1 || !agents.Items[0].Connected || agents.Items[0].ServiceVersion != "BIND 9.20.3" ||
agents.Items[0].Health.State != "ok" || len(agents.Items[0].Health.Checks) != 1 || agents.Items[0].LastSeenAt == nil {
t.Fatalf("agents %+v", agents.Items)
}
// Revocation closes the stream and is audited.
viewer.mustStatus(403, "POST", "/api/v1/agents/"+agentID+"/revoke", nil)
admin.mustStatus(200, "POST", "/api/v1/agents/"+agentID+"/revoke", nil).json(t, &ag)
if ag.RevokedAt == nil || ag.Connected {
t.Errorf("revoked agent %+v", ag)
}
select {
case <-conn.Done():
default:
t.Error("stream of the revoked agent is still open")
}
if _, err := e.svc.AgentHello(ctx, agentID, service.HelloInput{}); !errors.Is(err, service.ErrAgentRevoked) {
t.Errorf("hello after revocation: %v", err)
}
admin.mustStatus(200, "POST", "/api/v1/agents/"+agentID+"/revoke", nil) // idempotent
var audit auditPage
admin.mustStatus(200, "GET", "/api/v1/audit?object_type=agent", nil).json(t, &audit)
if len(audit.Items) != 2 { // enroll, revoke
t.Errorf("agent audit %+v", audit.Items)
}
viewer.mustStatus(403, "DELETE", "/api/v1/agents/"+agentID, nil)
admin.mustStatus(204, "DELETE", "/api/v1/agents/"+agentID, nil)
admin.mustStatus(404, "GET", "/api/v1/agents/"+agentID, nil)
admin.mustStatus(404, "DELETE", "/api/v1/agents/"+agentID, nil)
admin.mustStatus(204, "DELETE", "/api/v1/enrollment-tokens/"+id, nil)
admin.mustStatus(404, "DELETE", "/api/v1/enrollment-tokens/"+id, nil)
admin.mustStatus(404, "DELETE", "/api/v1/enrollment-tokens/nonsense", nil)
admin.mustStatus(200, "GET", "/api/v1/audit?object_type=enrollment_token", nil).json(t, &audit)
if len(audit.Items) != 3 { // two creates, one delete
t.Errorf("token audit %+v", audit.Items)
}
}
func TestRevisionsAndDeployments(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
viewer := e.userClient(admin, "vera", "viewer")
operator := e.userClient(admin, "otto", "operator")
ctx := context.Background()
admin.mustStatus(201, "POST", "/api/v1/networks", map[string]any{
"cidr": "192.0.2.0/24", "name": "Office", "gateway": "192.0.2.1", "domain": "office.test",
"dhcp_enabled": true, "pools": []map[string]string{{"start": "192.0.2.100", "end": "192.0.2.199"}},
})
admin.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": "192.0.2.53", "type": "static", "hostname": "ns1"})
// Without DNS settings nothing is stored.
r := operator.mustStatus(422, "POST", "/api/v1/revisions", map[string]any{"comment": "first"})
if p := r.problem(t); p.Code != "dns_settings_missing" || p.Params["part"] != "dns" {
t.Errorf("problem %+v", p)
}
admin.configureDNS()
dnsAgent := e.enroll(admin.createToken("dns").Secret, "dns")
conn := e.hub.Attach(dnsAgent, "dns", "auto_pull")
viewer.mustStatus(403, "POST", "/api/v1/revisions", nil)
var rev service.RevisionDTO
operator.mustStatus(201, "POST", "/api/v1/revisions", map[string]any{"comment": "first"}).json(t, &rev)
if rev.Number != 1 || rev.Comment != "first" || rev.CreatedByName != "otto" || len(rev.DNS) == 0 || len(rev.DHCP) != 1 {
t.Fatalf("revision %+v", rev)
}
select {
case cmd := <-conn.Commands():
if cmd.GetApplyRevision().GetRevision() != 1 {
t.Errorf("command %v", cmd)
}
case <-time.After(time.Second):
t.Error("connected agent was not notified")
}
// Unchanged data: the latest revision with 200, no notification.
rev = service.RevisionDTO{}
operator.mustStatus(200, "POST", "/api/v1/revisions", nil).json(t, &rev)
if rev.Number != 1 || rev.Comment != "first" {
t.Errorf("unchanged %+v", rev)
}
select {
case cmd := <-conn.Commands():
t.Errorf("unexpected command %v", cmd)
default:
}
admin.mustStatus(201, "POST", "/api/v1/addresses", map[string]any{"ip": "192.0.2.10", "type": "static", "hostname": "www"})
rev = service.RevisionDTO{}
operator.mustStatus(201, "POST", "/api/v1/revisions", map[string]any{}).json(t, &rev)
if rev.Number != 2 {
t.Fatalf("second revision %+v", rev)
}
zone := fileByPath(rev.DNS, "zones/db.office.test")
if zone == nil || !strings.Contains(zone.Content, "192.0.2.10") {
t.Errorf("zone %+v", zone)
}
var page service.RevisionPage
viewer.mustStatus(200, "GET", "/api/v1/revisions", nil).json(t, &page)
if len(page.Items) != 2 || page.Items[0].Number != 2 || page.NextBefore != nil || page.Items[0].DNSFiles != len(rev.DNS) || page.Items[0].DHCPFiles != 1 {
t.Fatalf("revisions %+v", page)
}
page = service.RevisionPage{}
viewer.mustStatus(200, "GET", "/api/v1/revisions?limit=1", nil).json(t, &page)
if len(page.Items) != 1 || page.NextBefore == nil || *page.NextBefore != 2 {
t.Fatalf("first page %+v", page)
}
page = service.RevisionPage{}
viewer.mustStatus(200, "GET", "/api/v1/revisions?limit=1&before=2", nil).json(t, &page)
if len(page.Items) != 1 || page.Items[0].Number != 1 || page.NextBefore != nil {
t.Fatalf("second page %+v", page)
}
rev = service.RevisionDTO{}
viewer.mustStatus(200, "GET", "/api/v1/revisions/1", nil).json(t, &rev)
if rev.Number != 1 || fileByPath(rev.DNS, "zones/db.office.test") == nil {
t.Errorf("revision 1 %+v", rev)
}
viewer.mustStatus(404, "GET", "/api/v1/revisions/99", nil)
var audit auditPage
admin.mustStatus(200, "GET", "/api/v1/audit?action=revision.create", nil).json(t, &audit)
if len(audit.Items) != 2 {
t.Errorf("revision audit %+v", audit.Items)
}
// Deployment reports.
started := e.now.Add(-time.Minute)
for _, rep := range []service.DeploymentReport{
{Revision: 1, Trigger: "push", Outcome: "succeeded", ActiveRevision: 1, StartedAt: started, FinishedAt: started.Add(time.Second)},
{Revision: 2, Trigger: "pull", Outcome: "rolled_back", Message: "SOA serial mismatch", ActiveRevision: 1, StartedAt: started},
} {
if err := e.svc.RecordDeployment(ctx, dnsAgent, rep); err != nil {
t.Fatal(err)
}
}
if err := e.svc.RecordDeployment(ctx, dnsAgent, service.DeploymentReport{Revision: 0, Trigger: "pull", Outcome: "succeeded"}); err == nil {
t.Error("revision 0 accepted")
}
var deps service.DeploymentPage
viewer.mustStatus(200, "GET", "/api/v1/deployments", nil).json(t, &deps)
if len(deps.Items) != 2 || deps.Items[0].Outcome != "rolled_back" || deps.Items[0].AgentHostname != "host-dns" || deps.NextBeforeID != nil {
t.Fatalf("deployments %+v", deps)
}
deps = service.DeploymentPage{}
viewer.mustStatus(200, "GET", "/api/v1/deployments?limit=1&revision=1&agent_id="+dnsAgent, nil).json(t, &deps)
if len(deps.Items) != 1 || deps.Items[0].Revision != 1 || deps.NextBeforeID != nil {
t.Errorf("filtered %+v", deps)
}
deps = service.DeploymentPage{}
viewer.mustStatus(200, "GET", "/api/v1/deployments?limit=1", nil).json(t, &deps)
if deps.NextBeforeID == nil {
t.Fatalf("paging %+v", deps)
}
before := *deps.NextBeforeID
deps = service.DeploymentPage{}
viewer.mustStatus(200, "GET", "/api/v1/deployments?before_id="+strconv.FormatInt(before, 10), nil).json(t, &deps)
if len(deps.Items) != 1 || deps.Items[0].Outcome != "succeeded" {
t.Errorf("before_id %+v", deps)
}
viewer.mustStatus(200, "GET", "/api/v1/deployments?agent_id=unknown", nil)
var ag service.AgentDTO
viewer.mustStatus(200, "GET", "/api/v1/agents/"+dnsAgent, nil).json(t, &ag)
if ag.AppliedRevision != 1 || ag.LastGoodRevision != 1 || ag.DesiredRevision != 2 || ag.LastDeployment == nil ||
ag.LastDeployment.Outcome != "rolled_back" {
t.Errorf("agent after deployments %+v", ag)
}
// Read-only mode blocks revisions but not the agents.
e.advance(31 * 24 * time.Hour)
admin.login("admin", "admin-password-123")
if p := admin.mustStatus(403, "POST", "/api/v1/revisions", nil).problem(t); p.Code != "license_read_only" {
t.Errorf("read-only %+v", p)
}
if err := e.svc.AgentHeartbeat(ctx, dnsAgent, service.HeartbeatInput{AppliedRevision: 2, LastGoodRevision: 2}); err != nil {
t.Errorf("heartbeat in read-only mode: %v", err)
}
}
func TestLeases(t *testing.T) {
e := newEnv(t)
admin := e.adminClient()
viewer := e.userClient(admin, "vera", "viewer")
ctx := context.Background()
var netw service.NetworkDTO
admin.mustStatus(201, "POST", "/api/v1/networks", map[string]any{"cidr": "192.0.2.0/24", "name": "Office"}).json(t, &netw)
ids, err := e.st.Q().SubnetIDs(ctx)
if err != nil {
t.Fatal(err)
}
subnet := ids[netw.ID]
dhcpAgent := e.enroll(admin.createToken("dhcp").Secret, "dhcp")
dnsAgent := e.enroll(admin.createToken("dns").Secret, "dns")
exp := e.now.Add(time.Hour)
leases := []service.LeaseInput{
{IP: "192.0.2.100", MAC: "00:11:22:33:44:55", Hostname: "laptop", State: "active", ExpiresAt: &exp, SubnetID: subnet},
{IP: "192.0.2.101", MAC: "00:11:22:33:44:66", Hostname: "phone", State: "released", SubnetID: subnet},
{IP: "198.51.100.7", MAC: "00:11:22:33:44:77", State: "active", SubnetID: 9999},
{IP: "not-an-ip", State: "active"},
}
res, err := e.svc.StoreLeaseBatch(ctx, dhcpAgent, 1, true, leases)
if err != nil || res.Acknowledged != 1 || res.Duplicate || res.Skipped != 1 {
t.Fatalf("batch %+v %v", res, err)
}
// A duplicate batch is dropped and acknowledged with the stored sequence.
res, err = e.svc.StoreLeaseBatch(ctx, dhcpAgent, 1, true, nil)
if err != nil || !res.Duplicate || res.Acknowledged != 1 {
t.Errorf("duplicate %+v %v", res, err)
}
if _, err := e.svc.StoreLeaseBatch(ctx, dnsAgent, 1, true, leases); !errors.Is(err, service.ErrNotDHCPAgent) {
t.Errorf("DNS agent leases: %v", err)
}
var page service.LeasePage
viewer.mustStatus(200, "GET", "/api/v1/leases", nil).json(t, &page)
if len(page.Items) != 3 || page.NextAfter != nil || page.Items[0].NetworkID != netw.ID || page.Items[2].NetworkID != "" ||
page.Items[0].AgentID != dhcpAgent || page.Items[0].ExpiresAt == nil {
t.Fatalf("leases %+v", page)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases?network_id="+netw.ID+"&state=active", nil).json(t, &page)
if len(page.Items) != 1 || page.Items[0].Hostname != "laptop" {
t.Errorf("filtered %+v", page)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases?q=44:66", nil).json(t, &page)
if len(page.Items) != 1 || page.Items[0].Hostname != "phone" {
t.Errorf("search %+v", page)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases?limit=2", nil).json(t, &page)
if len(page.Items) != 2 || page.NextAfter == nil || *page.NextAfter != "192.0.2.101" {
t.Fatalf("first page %+v", page)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases?limit=2&after=192.0.2.101", nil).json(t, &page)
if len(page.Items) != 1 || page.Items[0].IP.String() != "198.51.100.7" || page.NextAfter != nil {
t.Errorf("second page %+v", page)
}
viewer.mustStatus(422, "GET", "/api/v1/leases?after=nonsense", nil)
// Changes are merged; a full sync replaces the set.
if _, err := e.svc.StoreLeaseBatch(ctx, dhcpAgent, 2, false, []service.LeaseInput{
{IP: "192.0.2.101", MAC: "00:11:22:33:44:66", Hostname: "phone", State: "active", SubnetID: subnet},
}); err != nil {
t.Fatal(err)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases?state=active", nil).json(t, &page)
if len(page.Items) != 3 {
t.Errorf("after delta %+v", page)
}
if _, err := e.svc.StoreLeaseBatch(ctx, dhcpAgent, 3, true, leases[:1]); err != nil {
t.Fatal(err)
}
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases", nil).json(t, &page)
if len(page.Items) != 1 {
t.Errorf("after full sync %+v", page)
}
// Deleting the agent removes its leases.
admin.mustStatus(204, "DELETE", "/api/v1/agents/"+dhcpAgent, nil)
page = service.LeasePage{}
viewer.mustStatus(200, "GET", "/api/v1/leases", nil).json(t, &page)
if len(page.Items) != 0 {
t.Errorf("leases of deleted agent %+v", page)
}
}
+206
View File
@@ -0,0 +1,206 @@
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package httpapi
import (
"bytes"
"io"
"net/http"
"net/netip"
"net/url"
"strconv"
"strings"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
)
// intParam parses an optional integer query parameter within [lo, hi]
// (0 if absent).
func intParam(q url.Values, name string, lo, hi int64) (int64, error) {
v := q.Get(name)
if v == "" {
return 0, nil
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n < lo || n > hi {
return 0, errBadRequest("invalid_parameter", "invalid "+name)
}
return n, nil
}
func (a *API) listAgents(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
agents, err := a.svc.ListAgents(r.Context())
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, listResponse[service.AgentDTO]{Items: agents})
}
func (a *API) getAgent(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
ag, err := a.svc.GetAgent(r.Context(), r.PathValue("id"))
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, ag)
}
func (a *API) revokeAgent(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
ag, err := a.svc.RevokeAgent(r.Context(), rc.Actor, r.PathValue("id"))
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, ag)
}
func (a *API) deleteAgent(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
if err := a.svc.DeleteAgent(r.Context(), rc.Actor, r.PathValue("id")); err != nil {
return err
}
return noContent(w)
}
func (a *API) listEnrollmentTokens(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
tokens, err := a.svc.ListEnrollmentTokens(r.Context())
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, listResponse[service.EnrollmentTokenDTO]{Items: tokens})
}
type enrollmentTokenRequest struct {
Role string `json:"role"`
Description string `json:"description"`
ValidHours *int `json:"valid_hours"`
}
func (a *API) createEnrollmentToken(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
var req enrollmentTokenRequest
if err := decode(w, r, &req); err != nil {
return err
}
out, err := a.svc.CreateEnrollmentToken(r.Context(), rc.Actor, service.TokenInput{
Role: req.Role, Description: req.Description, ValidHours: req.ValidHours,
})
if err != nil {
return err
}
return writeJSON(w, http.StatusCreated, out)
}
func (a *API) deleteEnrollmentToken(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
if err := a.svc.DeleteEnrollmentToken(r.Context(), rc.Actor, r.PathValue("id")); err != nil {
return err
}
return noContent(w)
}
func (a *API) listRevisions(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
q := r.URL.Query()
limit, err := intParam(q, "limit", 1, service.MaxRevisionPage)
if err != nil {
return err
}
before, err := intParam(q, "before", 1, 1<<62)
if err != nil {
return err
}
page, err := a.svc.ListRevisions(r.Context(), int(limit), before)
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, page)
}
type revisionRequest struct {
Comment string `json:"comment"`
}
func (a *API) createRevision(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
var req revisionRequest
// The body is optional.
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBody))
if err != nil {
return errBadRequest("body_too_large", "request body too large")
}
if len(bytes.TrimSpace(body)) > 0 {
r.Body = io.NopCloser(bytes.NewReader(body))
if err := decode(w, r, &req); err != nil {
return err
}
}
rev, created, err := a.svc.CreateRevision(r.Context(), rc.Actor, req.Comment)
if err != nil {
return err
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
return writeJSON(w, status, rev)
}
func (a *API) getRevision(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
n, err := strconv.ParseInt(r.PathValue("number"), 10, 64)
if err != nil {
return service.ErrNotFound
}
rev, err := a.svc.GetRevision(r.Context(), n)
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, rev)
}
func (a *API) listDeployments(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
q := r.URL.Query()
f := service.DeploymentFilter{AgentID: q.Get("agent_id")}
var err error
if f.Revision, err = intParam(q, "revision", 1, store.MaxRevision); err != nil {
return err
}
limit, err := intParam(q, "limit", 1, service.MaxDeploymentPage)
if err != nil {
return err
}
f.Limit = int(limit)
if f.BeforeID, err = intParam(q, "before_id", 1, 1<<62); err != nil {
return err
}
page, err := a.svc.ListDeployments(r.Context(), f)
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, page)
}
var leaseStates = map[string]bool{"active": true, "expired": true, "released": true, "declined": true}
func (a *API) listLeases(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
q := r.URL.Query()
f := service.LeaseFilter{NetworkID: q.Get("network_id"), State: q.Get("state"), Query: strings.TrimSpace(q.Get("q"))}
if f.State != "" && !leaseStates[f.State] {
return errBadRequest("invalid_parameter", "unknown lease state "+strconv.Quote(f.State))
}
if len(f.Query) > 200 {
return errBadRequest("invalid_parameter", "q is too long")
}
limit, err := intParam(q, "limit", 1, store.MaxLeasePage)
if err != nil {
return err
}
f.Limit = int(limit)
if v := q.Get("after"); v != "" {
ip, err := netip.ParseAddr(v)
if err != nil {
return errBadRequest("invalid_parameter", "after must be an IP address")
}
ip = ip.Unmap()
f.After = &ip
}
page, err := a.svc.ListLeases(r.Context(), f)
if err != nil {
return err
}
return writeJSON(w, http.StatusOK, page)
}
+12 -2
View File
@@ -22,8 +22,10 @@ import (
"github.com/getkin/kin-openapi/routers/legacy"
"git.beugsiud.de/beug.k/DDI/server/api"
"git.beugsiud.de/beug.k/DDI/server/internal/agenthub"
"git.beugsiud.de/beug.k/DDI/server/internal/httpserver"
"git.beugsiud.de/beug.k/DDI/server/internal/license"
"git.beugsiud.de/beug.k/DDI/server/internal/pki"
"git.beugsiud.de/beug.k/DDI/server/internal/secrets"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
"git.beugsiud.de/beug.k/DDI/server/internal/store"
@@ -121,6 +123,8 @@ type env struct {
st *store.Store
lic *license.Manager
now *time.Time
pki *pki.Authority
hub *agenthub.Hub
}
type stubProvider struct{}
@@ -173,8 +177,14 @@ func newEnvWith(t *testing.T, adjust func(*Options)) *env {
if err := lic.Load(context.Background()); err != nil {
t.Fatal(err)
}
authority, err := pki.LoadOrCreate(context.Background(), st, kr, log, nil)
if err != nil {
t.Fatal(err)
}
hub := agenthub.New()
svc := service.New(service.Options{Store: st, Keyring: kr, License: lic, ProductName: "DDI-Suite", Logger: log,
Now: func() time.Time { return now }})
Now: func() time.Time { return now }, PKI: authority, Hub: hub, AgentAPIAddress: "ddi.example.test:8444",
AgentServerNames: []string{"ddi.example.test"}})
opts := Options{Service: svc, ProductName: "DDI-Suite", Version: "test", Logger: log}
srv := httptest.NewUnstartedServer(nil)
if adjust != nil {
@@ -185,7 +195,7 @@ func newEnvWith(t *testing.T, adjust func(*Options)) *env {
srv.Config.Handler = httpserver.RequestID(contractHandler(t, a.Handler()))
srv.Start()
t.Cleanup(srv.Close)
return &env{t: t, srv: srv, svc: svc, st: st, lic: lic, now: &now}
return &env{t: t, srv: srv, svc: svc, st: st, lic: lic, now: &now, pki: authority, hub: hub}
}
// advance moves the shared clock (sessions, license).
+16
View File
@@ -59,6 +59,22 @@ func (a *API) Routes() []Route {
// Generated configuration (read-only preview until agents arrive).
perm(get, "/api/v1/config/preview", auth.PermIPAMRead, false, a.previewConfig),
// Agents and enrollment (ADR 0021).
perm(get, "/api/v1/agents", auth.PermIPAMRead, false, a.listAgents),
perm(get, "/api/v1/agents/{id}", auth.PermIPAMRead, false, a.getAgent),
perm(del, "/api/v1/agents/{id}", auth.PermSettingsAdm, true, a.deleteAgent),
perm(post, "/api/v1/agents/{id}/revoke", auth.PermSettingsAdm, true, a.revokeAgent),
perm(get, "/api/v1/enrollment-tokens", auth.PermSettingsAdm, false, a.listEnrollmentTokens),
perm(post, "/api/v1/enrollment-tokens", auth.PermSettingsAdm, true, a.createEnrollmentToken),
perm(del, "/api/v1/enrollment-tokens/{id}", auth.PermSettingsAdm, true, a.deleteEnrollmentToken),
// Revisions, deployments and leases.
perm(get, "/api/v1/revisions", auth.PermIPAMRead, false, a.listRevisions),
perm(post, "/api/v1/revisions", auth.PermDeploy, true, a.createRevision),
perm(get, "/api/v1/revisions/{number}", auth.PermIPAMRead, false, a.getRevision),
perm(get, "/api/v1/deployments", auth.PermIPAMRead, false, a.listDeployments),
perm(get, "/api/v1/leases", auth.PermIPAMRead, false, a.listLeases),
// Networks.
perm(get, "/api/v1/networks", auth.PermIPAMRead, false, a.listNetworks),
perm(post, "/api/v1/networks", auth.PermIPAMWrite, true, a.createNetwork),