Files
DDI/server/internal/service/configpreview.go
T
beug.k@beugsiud.deandClaude Opus 5.5 00b9ffb65b feat(server): operating modes, push deployments, rollback, diff, pending changes and SSE
- PATCH /agents/{id} switches the mode and reconnects the agent
- POST /revisions/{n}/deploy creates pending push deployments, sends
  ApplyRevisionCommand with the deployment ID, re-sends open pushes on
  connect and announces them in GetDesiredState; reports with the ID
  complete the deployment
- POST /revisions/{n}/rollback re-renders the stored generator input
  with the next revision number
- GET /revisions/{n}/diff and GET /config/pending with unified diffs
- GET /events streams agent, deployment, revision and pending events

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:23:47 +02:00

156 lines
4.8 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
package service
import (
"context"
"errors"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen/bindgen"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen/derive"
"git.beugsiud.de/beug.k/DDI/server/internal/configgen/keagen"
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
)
// previewSerial is the SOA serial shown in the preview; revisions use their
// own number.
const previewSerial = 1
// GeneratedFile is one generated file.
type GeneratedFile struct {
Path string `json:"path"`
Content string `json:"content"`
CreateOnly bool `json:"create_only"`
}
// GeneratorProblem explains why a part could not be generated.
type GeneratorProblem struct {
Code string `json:"code"`
Params map[string]string `json:"params,omitempty"`
}
// ConfigPreview is the generated configuration for the current data.
type ConfigPreview struct {
DNS []GeneratedFile `json:"dns"`
DHCP []GeneratedFile `json:"dhcp"`
Warnings []configgen.Warning `json:"warnings"`
DNSError *GeneratorProblem `json:"dns_error,omitempty"`
DHCPError *GeneratorProblem `json:"dhcp_error,omitempty"`
}
// loadSource reads everything the generator needs. serial becomes the SOA
// serial of every zone.
func (s *Service) loadSource(ctx context.Context, serial uint32) (configgen.Source, error) {
tx := s.st.Q()
nets, err := tx.ListNetworks(ctx)
if err != nil {
return configgen.Source{}, translate(err)
}
addrs, err := tx.AllAddresses(ctx)
if err != nil {
return configgen.Source{}, translate(err)
}
ids, err := tx.SubnetIDs(ctx)
if err != nil {
return configgen.Source{}, translate(err)
}
settings, err := s.GetGeneratorSettings(ctx)
if err != nil {
return configgen.Source{}, err
}
src := configgen.Source{
Serial: serial,
Addresses: addrs,
SubnetIDs: ids,
DNS: settings.dnsSettings(),
DHCP: configgen.DHCPSettings{
Interfaces: settings.DHCP.Interfaces, DirectClients: settings.DHCP.DirectClients,
ControlSocket: settings.DHCP.ControlSocket, LeaseFile: settings.DHCP.LeaseFile,
HooksDir: settings.DHCP.HooksDir, ValidLifetime: settings.DHCP.ValidLifetime,
},
}
src.Networks = make([]ipam.Network, len(nets))
for i, n := range nets {
src.Networks[i] = *n
}
return src, nil
}
func files(arts []configgen.Artifact) []GeneratedFile {
out := make([]GeneratedFile, len(arts)) // never nil: the API returns []
for i, a := range arts {
out[i] = GeneratedFile{Path: a.Path, Content: string(a.Content), CreateOnly: a.CreateOnly}
}
return out
}
func generatorProblem(err error) *GeneratorProblem {
var ve *configgen.ValidationError
if errors.As(err, &ve) {
return &GeneratorProblem{Code: "config_invalid", Params: map[string]string{"path": ve.Path, "message": ve.Message}}
}
return &GeneratorProblem{Code: "config_failed", Params: map[string]string{"message": err.Error()}}
}
// PreviewConfig generates the BIND and Kea configuration for the current
// data without storing or deploying anything.
func (s *Service) PreviewConfig(ctx context.Context) (*ConfigPreview, error) {
g, err := s.generate(ctx, previewSerial)
if err != nil {
return nil, err
}
out := &ConfigPreview{
DNS: files(g.DNS), DHCP: files(g.DHCP), Warnings: g.Warnings,
DNSError: g.DNSError, DHCPError: g.DHCPError,
}
return out, nil
}
// Generated is the output of one generator run. A part that could not be
// generated has no artifacts and a problem instead.
type Generated struct {
DNS []configgen.Artifact
DHCP []configgen.Artifact
Warnings []configgen.Warning
DNSError *GeneratorProblem
DHCPError *GeneratorProblem
}
// generate runs the generator on the current data (read outside a
// transaction; callers that need a consistent snapshot hold their own).
func (s *Service) generate(ctx context.Context, serial uint32) (*Generated, error) {
src, err := s.loadSource(ctx, serial)
if err != nil {
return nil, err
}
return render(src), nil
}
// render runs the generator on src (a pure function: equal input gives
// byte-identical output).
func render(src configgen.Source) *Generated {
out := &Generated{Warnings: []configgen.Warning{}}
if src.DNS.SOA.PrimaryNS == "" || src.DNS.SOA.Hostmaster == "" || len(src.DNS.NameServers) == 0 {
out.DNSError = &GeneratorProblem{Code: "dns_settings_missing"}
} else {
dnsCfg, warnings := derive.BuildDNS(src)
out.Warnings = append(out.Warnings, warnings...)
if arts, err := bindgen.Render(dnsCfg); err != nil {
out.DNSError = generatorProblem(err)
} else {
out.DNS = arts
}
}
dhcpCfg, warnings := derive.BuildDHCP(src)
out.Warnings = append(out.Warnings, warnings...)
if arts, err := keagen.Render(dhcpCfg); err != nil {
out.DHCPError = generatorProblem(err)
} else {
out.DHCP = arts
}
return out
}