18 Commits

Author SHA1 Message Date
Lourenço Vales
3b3b7aebb1 final fixes 2025-10-05 21:49:17 +02:00
Lourenço Vales
2182e2a4ea small typo 2025-10-05 21:36:38 +02:00
Lourenço Vales
ce4d2bfe50 changed validation for MX record corner case 2025-10-05 21:34:37 +02:00
Lourenço Vales
1787e44503 added more robust validation; small changes 2025-10-05 21:22:48 +02:00
Lourenço Vales
bc4f7b7309 fixed slight error in validation 2025-10-05 20:59:27 +02:00
Lourenço Vales
f7d8b42c7b adding doc comments 2025-10-05 20:49:02 +02:00
Lourenço Vales
1fb3ef1e71 fixed record deletion by changing the matching condition 2025-10-05 10:55:50 +02:00
Lourenço Vales
3e153f7f44 adding Poll condition to guarantee minimum time between tries 2025-10-05 10:39:47 +02:00
Lourenço Vales
8b30f7bd3d everything is implemented, now on to testing 2025-10-03 15:00:35 +02:00
Lourenço Vales
d304dafeea added partial cloudflare api integration 2025-10-03 09:29:32 +02:00
Lourenço Vales
9271906435 added cmp function 2025-10-03 09:29:32 +02:00
Lourenço Vales
7146139ae8 added CheckApply function; made some changes to structure 2025-10-03 09:29:32 +02:00
Lourenço Vales
ef74ede862 engine: resources: Add Cloudflare DNS resource 2025-10-03 09:29:32 +02:00
James Shubin
56e55dfad7 engine: resources: Sysctl was missing the errors 2025-10-03 01:34:26 -04:00
James Shubin
303e80dee7 engine, lang, lib, pgraph: Plumb through more ctx 2025-10-02 23:28:51 -04:00
James Shubin
6c6c9df75e engine: resources: Add a nif command and make it clearer
Let's have the opposite, and have an example so you don't forget which
is which.
2025-10-02 20:48:36 -04:00
James Shubin
5f4ae05340 readme: We moved to matrix 2025-10-02 03:05:05 -04:00
James Shubin
c48b884d16 misc: Add fpm repo script 2025-09-30 02:55:29 -04:00
18 changed files with 1073 additions and 35 deletions

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@ tmp/
mgmt.iml mgmt.iml
/rpmbuild/ /rpmbuild/
/releases/ /releases/
/repository/
/pprof/ /pprof/
/sites/ /sites/
# vim swap files # vim swap files

View File

@@ -6,7 +6,6 @@
[![Build Status](https://github.com/purpleidea/mgmt/workflows/.github/workflows/test.yaml/badge.svg)](https://github.com/purpleidea/mgmt/actions/) [![Build Status](https://github.com/purpleidea/mgmt/workflows/.github/workflows/test.yaml/badge.svg)](https://github.com/purpleidea/mgmt/actions/)
[![GoDoc](https://img.shields.io/badge/godoc-reference-5272B4.svg?style=flat-square)](https://godocs.io/github.com/purpleidea/mgmt) [![GoDoc](https://img.shields.io/badge/godoc-reference-5272B4.svg?style=flat-square)](https://godocs.io/github.com/purpleidea/mgmt)
[![Matrix](https://img.shields.io/badge/matrix-%23mgmtconfig-orange.svg?style=flat-square)](https://matrix.to/#/#mgmtconfig:matrix.org) [![Matrix](https://img.shields.io/badge/matrix-%23mgmtconfig-orange.svg?style=flat-square)](https://matrix.to/#/#mgmtconfig:matrix.org)
[![IRC](https://img.shields.io/badge/irc-%23mgmtconfig-orange.svg?style=flat-square)](https://web.libera.chat/?channels=#mgmtconfig)
[![Patreon](https://img.shields.io/badge/patreon-donate-yellow.svg?style=flat-square)](https://www.patreon.com/purpleidea) [![Patreon](https://img.shields.io/badge/patreon-donate-yellow.svg?style=flat-square)](https://www.patreon.com/purpleidea)
[![Liberapay](https://img.shields.io/badge/liberapay-donate-yellow.svg?style=flat-square)](https://liberapay.com/purpleidea/donate) [![Liberapay](https://img.shields.io/badge/liberapay-donate-yellow.svg?style=flat-square)](https://liberapay.com/purpleidea/donate)
@@ -73,7 +72,6 @@ Come join us in the `mgmt` community!
| Medium | Link | | Medium | Link |
|---|---| |---|---|
| Matrix | [#mgmtconfig](https://matrix.to/#/#mgmtconfig:matrix.org) on Matrix.org | | Matrix | [#mgmtconfig](https://matrix.to/#/#mgmtconfig:matrix.org) on Matrix.org |
| IRC | [#mgmtconfig](https://web.libera.chat/?channels=#mgmtconfig) on Libera.Chat |
| Twitter | [@mgmtconfig](https://twitter.com/mgmtconfig) & [#mgmtconfig](https://twitter.com/hashtag/mgmtconfig) | | Twitter | [@mgmtconfig](https://twitter.com/mgmtconfig) & [#mgmtconfig](https://twitter.com/hashtag/mgmtconfig) |
| Mailing list | [looking for a new home, suggestions welcome](https://gitlab.freedesktop.org/freedesktop/freedesktop/-/issues/1082) | | Mailing list | [looking for a new home, suggestions welcome](https://gitlab.freedesktop.org/freedesktop/freedesktop/-/issues/1082) |
| Patreon | [purpleidea](https://www.patreon.com/purpleidea) on Patreon | | Patreon | [purpleidea](https://www.patreon.com/purpleidea) on Patreon |

View File

@@ -53,16 +53,13 @@ find a number of tutorials online.
3. Spend between four to six hours with the [golang tour](https://tour.golang.org/). 3. Spend between four to six hours with the [golang tour](https://tour.golang.org/).
Skip over the longer problems, but try and get a solid overview of everything. Skip over the longer problems, but try and get a solid overview of everything.
If you forget something, you can always go back and repeat those parts. If you forget something, you can always go back and repeat those parts.
4. Connect to our [#mgmtconfig](https://web.libera.chat/?channels=#mgmtconfig) 4. Connect to our [#mgmtconfig](https://matrix.to/#/#mgmtconfig:matrix.org)
IRC channel on the [Libera.Chat](https://libera.chat/) network. You can use any Matrix channel and hang out with us there out there.
IRC client that you'd like, but the [hosted web portal](https://web.libera.chat/?channels=#mgmtconfig)
will suffice if you don't know what else to use. [Here are a few suggestions for
alternative clients.](https://libera.chat/guides/clients)
5. Now it's time to try and starting writing a patch! We have tagged a bunch of 5. Now it's time to try and starting writing a patch! We have tagged a bunch of
[open issues as #mgmtlove](https://github.com/purpleidea/mgmt/issues?q=is%3Aissue+is%3Aopen+label%3Amgmtlove) [open issues as #mgmtlove](https://github.com/purpleidea/mgmt/issues?q=is%3Aissue+is%3Aopen+label%3Amgmtlove)
for new users to have somewhere to get involved. Look through them to see if for new users to have somewhere to get involved. Look through them to see if
something interests you. If you find one, let us know you're working on it by something interests you. If you find one, let us know you're working on it by
leaving a comment in the ticket. We'll be around to answer questions in the IRC leaving a comment in the ticket. We'll be around to answer questions in the
channel, and to create new issues if there wasn't something that fit your channel, and to create new issues if there wasn't something that fit your
interests. When you submit a patch, we'll review it and give you some feedback. interests. When you submit a patch, we'll review it and give you some feedback.
Over time, we hope you'll learn a lot while supporting the project! Now get Over time, we hope you'll learn a lot while supporting the project! Now get
@@ -534,9 +531,7 @@ which definitely existed before the band did.
### You didn't answer my question, or I have a question! ### You didn't answer my question, or I have a question!
It's best to ask on [IRC](https://web.libera.chat/?channels=#mgmtconfig) It's best to ask on [Matrix](https://matrix.to/#/#mgmtconfig:matrix.org) to see
to see if someone can help you. If you don't get a response from IRC, you can if someone can help. If you don't get a response there, you can send a patch to
contact me through my [technical blog](https://purpleidea.com/contact/) and I'll
do my best to help. If you have a good question, please add it as a patch to
this documentation. I'll merge your question, and add a patch with the answer! this documentation. I'll merge your question, and add a patch with the answer!
For news and updates, subscribe to the [mailing list](https://www.redhat.com/mailman/listinfo/mgmtconfig-list). For news and updates, subscribe to the [mailing list](https://www.redhat.com/mailman/listinfo/mgmtconfig-list).

View File

@@ -0,0 +1,702 @@
// Mgmt
// Copyright (C) James Shubin and the project contributors
// Written by James Shubin <james@shubin.ca> and the project contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//
// Additional permission under GNU GPL version 3 section 7
//
// If you modify this program, or any covered work, by linking or combining it
// with embedded mcl code and modules (and that the embedded mcl code and
// modules which link with this program, contain a copy of their source code in
// the authoritative form) containing parts covered by the terms of any other
// license, the licensors of this program grant you additional permission to
// convey the resulting work. Furthermore, the licensors of this program grant
// the original author, James Shubin, additional permission to update this
// additional permission if he deems it necessary to achieve the goals of this
// additional permission.
package resources
import (
"context"
"fmt"
"strings"
"github.com/purpleidea/mgmt/engine"
"github.com/purpleidea/mgmt/engine/traits"
"github.com/purpleidea/mgmt/util/errwrap"
"github.com/cloudflare/cloudflare-go/v6"
"github.com/cloudflare/cloudflare-go/v6/dns"
"github.com/cloudflare/cloudflare-go/v6/option"
"github.com/cloudflare/cloudflare-go/v6/zones"
)
func init() {
engine.RegisterResource("cloudflare:dns", func() engine.Res { return &CloudflareDNSRes{} })
}
// CloudflareDNSRes is a resource for managing DNS records in Cloudflare zones.
// This resource uses the Cloudflare API to create, update, and delete DNS
// records in a specified zone. It supports various record types including A,
// AAAA, CNAME, MX, TXT, NS, SRV, and PTR records. The resource requires polling
// to detect changes, as the Cloudflare API does not provide an event stream.
// The Purge functionality allows enforcing that only managed DNS records exist
// in the zone, removing any unmanaged records.
type CloudflareDNSRes struct {
traits.Base
traits.GraphQueryable
init *engine.Init
// APIToken is the Cloudflare API token used for authentication. This is
// required and must have the necessary permissions to manage DNS records
// in the specified zone.
APIToken string `lang:"apitoken"`
// Comment is an optional comment to attach to the DNS record. This is
// stored in Cloudflare and can be used for documentation purposes.
Comment string `lang:"comment"`
// Content is the value for the DNS record. This is required when State
// is "exists" unless Purge is true. The format depends on the record
// Type (e.g., IP address for A records, hostname for CNAME records).
Content string `lang:"content"`
// Priority is the priority value for records that support it (e.g., MX
// records). This is a pointer to distinguish between an unset value and
// a zero value.
Priority *float64 `lang:"priority"`
// Proxied specifies whether the record should be proxied through
// Cloudflare's CDN. This is a pointer to distinguish between an unset
// value and false. Only applicable to certain record types.
Proxied *bool `lang:"proxied"`
// Purge specifies whether to delete all DNS records in the zone that are
// not defined in the mgmt graph. When true, this resource will query the
// graph for other cloudflare:dns resources in the same zone and delete
// any records not managed by those resources.
Purge bool `lang:"purge"`
// RecordName is the name of the DNS record (e.g., "www.example.com" or
// "@" for the zone apex). This is required.
RecordName string `lang:"record_name"`
// State determines whether the DNS record should exist or be absent.
// Valid values are "exists" (default) or "absent". When set to "absent",
// the record will be deleted if it exists.
State string `lang:"state"`
// TTL is the time-to-live value for the DNS record in seconds. Must be
// between 60 and 86400, or set to 1 for automatic TTL. Default is 1.
TTL int64 `lang:"ttl"`
// Type is the DNS record type (e.g., "A", "AAAA", "CNAME", "MX", "TXT",
// "NS", "SRV", "PTR"). This is required.
Type string `lang:"type"`
// Zone is the name of the Cloudflare zone (domain) where the DNS record
// should be managed (e.g., "example.com"). This is required.
Zone string `lang:"zone"`
client *cloudflare.Client
zoneID string
}
// Default returns some sensible defaults for this resource.
func (obj *CloudflareDNSRes) Default() engine.Res {
return &CloudflareDNSRes{
State: "exists",
TTL: 1, // this sets TTL to automatic
}
}
// Validate checks if the resource data structure was populated correctly.
func (obj *CloudflareDNSRes) Validate() error {
if obj.RecordName == "" {
return fmt.Errorf("record name is required")
}
if obj.APIToken == "" {
return fmt.Errorf("API token is required")
}
if obj.Type == "" {
return fmt.Errorf("record type is required")
}
if (obj.TTL < 60 || obj.TTL > 86400) && obj.TTL != 1 { // API requirement
return fmt.Errorf("TTL must be between 60s and 86400s, or set to 1")
}
if obj.Zone == "" {
return fmt.Errorf("zone name is required")
}
if obj.State != "exists" && obj.State != "absent" && obj.State != "" {
return fmt.Errorf("state must be either 'exists', 'absent', or empty")
}
if obj.State == "exists" && obj.Content == "" && !obj.Purge {
return fmt.Errorf("content is required when state is 'exists'")
}
if obj.Type == "MX" && obj.Priority == nil {
return fmt.Errorf("priority is required for MX records")
}
// cloudflare accepts ~4req/s so this is safe enough even when managing lots
// of records
if obj.MetaParams().Poll == 0 || obj.MetaParams().Poll < 60 {
return fmt.Errorf("cloudflare:dns requires polling, set Meta:poll param (e.g., 300s), min. 60s")
}
return nil
}
// Init runs some startup code for this resource. It initializes the Cloudflare
// API client and validates that the specified zone exists.
func (obj *CloudflareDNSRes) Init(init *engine.Init) error {
obj.init = init
obj.client = cloudflare.NewClient(
option.WithAPIToken(obj.APIToken),
)
zoneListParams := zones.ZoneListParams{
Name: cloudflare.F(obj.Zone),
}
zoneList, err := obj.client.Zones.List(context.Background(), zoneListParams)
if err != nil {
return errwrap.Wrapf(err, "failed to list zones")
}
if len(zoneList.Result) == 0 {
return fmt.Errorf("zone %s not found", obj.Zone)
}
obj.zoneID = zoneList.Result[0].ID
return nil
}
// Cleanup is run by the engine to clean up after the resource is done. It
// clears sensitive data and releases the API client connection.
func (obj *CloudflareDNSRes) Cleanup() error {
obj.APIToken = ""
obj.client = nil
obj.zoneID = ""
return nil
}
// Watch isn't implemented for this resource, since the Cloudflare API does not
// provide any event stream. Instead, always use polling.
func (obj *CloudflareDNSRes) Watch(context.Context) error {
return fmt.Errorf("invalid Watch call: requires poll metaparam")
}
// CheckApply is the main convergence function for this resource. It checks the
// current state of the DNS record against the desired state and applies changes
// if necessary. If apply is false, it only checks if changes are needed. If
// Purge is enabled, it will first delete any unmanaged records in the zone.
func (obj *CloudflareDNSRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
// We start by checking the need for purging
if obj.Purge {
checkOK, err := obj.purgeCheckApply(ctx, apply)
if err != nil {
return false, err
}
if !checkOK {
return false, nil
}
}
// we're using `contains` so as to get the candidates, as `exact` might not
// give the expected results depending on how the user specified it.
listParams := dns.RecordListParams{
ZoneID: cloudflare.F(obj.zoneID),
Name: cloudflare.F(dns.RecordListParamsName{
Contains: cloudflare.F(obj.RecordName),
}),
Type: cloudflare.F(dns.RecordListParamsType(obj.Type)),
}
recordList, err := obj.client.DNS.Records.List(ctx, listParams)
if err != nil {
return false, errwrap.Wrapf(err, "failed to list DNS records")
}
// here we filter to find the exact match
recordExists := false
var record dns.RecordResponse
for _, r := range recordList.Result {
if obj.matchesRecordName(r.Name) {
record = r
recordExists = true
break
}
}
switch obj.State {
case "exists", "":
if !recordExists {
if !apply {
return false, nil
}
if err := obj.createRecord(ctx); err != nil {
return false, err
}
return true, nil
}
if obj.needsUpdate(record) {
if !apply {
return false, nil
}
if err := obj.updateRecord(ctx, record.ID); err != nil {
return false, err
}
return true, nil
}
case "absent":
if recordExists {
if !apply {
return false, nil
}
deleteParams := dns.RecordDeleteParams{
ZoneID: cloudflare.F(obj.zoneID),
}
_, err := obj.client.DNS.Records.Delete(ctx, record.ID, deleteParams)
if err != nil {
return false, errwrap.Wrapf(err, "failed to delete DNS record")
}
return true, nil
}
}
return true, nil
}
// Cmp compares two resources and returns an error if they differ. This is used
// to determine if two resources are equivalent for graph operations.
func (obj *CloudflareDNSRes) Cmp(r engine.Res) error {
if obj == nil && r == nil {
return nil
}
if (obj == nil) != (r == nil) {
return fmt.Errorf("one resource is empty")
}
res, ok := r.(*CloudflareDNSRes)
if !ok {
return fmt.Errorf("not a %s", obj.Kind())
}
if obj.APIToken != res.APIToken {
return fmt.Errorf("apitoken differs")
}
if (obj.Proxied == nil) != (res.Proxied == nil) {
return fmt.Errorf("proxied values differ")
}
if obj.Proxied != nil && *obj.Proxied != *res.Proxied {
return fmt.Errorf("proxied values differ")
}
if obj.RecordName != res.RecordName {
return fmt.Errorf("record name differs")
}
if obj.Purge != res.Purge {
return fmt.Errorf("purge value differs")
}
if obj.State != res.State {
return fmt.Errorf("state differs")
}
if obj.TTL != res.TTL {
return fmt.Errorf("ttl differs")
}
if obj.Type != res.Type {
return fmt.Errorf("record type differs")
}
if obj.Zone != res.Zone {
return fmt.Errorf("zone differs")
}
if obj.zoneID != res.zoneID {
return fmt.Errorf("zoneid differs")
}
if obj.Content != res.Content {
return fmt.Errorf("content param differs")
}
if (obj.Priority == nil) != (res.Priority == nil) {
return fmt.Errorf("the priority param differs")
}
if obj.Priority != nil && *obj.Priority != *res.Priority {
return fmt.Errorf("the priority param differs")
}
return nil
}
// buildRecordParam creates the appropriate record parameter structure based on
// the record type. This is a helper function used by buildNewRecordParam and
// buildEditRecordParam.
// TODO: double check the fields for each record, might have missed some
func (obj *CloudflareDNSRes) buildRecordParam() (any, error) {
ttl := dns.TTL(obj.TTL)
switch obj.Type {
case "A":
param := dns.ARecordParam{
Name: cloudflare.F(obj.RecordName),
Type: cloudflare.F(dns.ARecordTypeA),
Content: cloudflare.F(obj.Content),
TTL: cloudflare.F(ttl),
}
if obj.Proxied != nil {
param.Proxied = cloudflare.F(*obj.Proxied)
}
if obj.Comment != "" {
param.Comment = cloudflare.F(obj.Comment)
}
return param, nil
case "AAAA":
param := dns.AAAARecordParam{
Name: cloudflare.F(obj.RecordName),
Type: cloudflare.F(dns.AAAARecordTypeAAAA),
Content: cloudflare.F(obj.Content),
TTL: cloudflare.F(ttl),
}
if obj.Proxied != nil {
param.Proxied = cloudflare.F(*obj.Proxied)
}
if obj.Comment != "" {
param.Comment = cloudflare.F(obj.Comment)
}
return param, nil
case "CNAME":
param := dns.CNAMERecordParam{
Name: cloudflare.F(obj.RecordName),
Type: cloudflare.F(dns.CNAMERecordTypeCNAME),
Content: cloudflare.F(obj.Content),
TTL: cloudflare.F(ttl),
}
if obj.Proxied != nil {
param.Proxied = cloudflare.F(*obj.Proxied)
}
if obj.Comment != "" {
param.Comment = cloudflare.F(obj.Comment)
}
return param, nil
case "MX":
param := dns.MXRecordParam{
Name: cloudflare.F(obj.RecordName),
Type: cloudflare.F(dns.MXRecordTypeMX),
Content: cloudflare.F(obj.Content),
TTL: cloudflare.F(ttl),
}
if obj.Proxied != nil {
param.Proxied = cloudflare.F(*obj.Proxied)
}
if obj.Priority != nil { // required for MX record
param.Priority = cloudflare.F(*obj.Priority)
}
if obj.Comment != "" {
param.Comment = cloudflare.F(obj.Comment)
}
return param, nil
case "TXT":
param := dns.TXTRecordParam{
Name: cloudflare.F(obj.RecordName),
Type: cloudflare.F(dns.TXTRecordTypeTXT),
Content: cloudflare.F(obj.Content),
TTL: cloudflare.F(ttl),
}
if obj.Proxied != nil {
param.Proxied = cloudflare.F(*obj.Proxied)
}
if obj.Comment != "" {
param.Comment = cloudflare.F(obj.Comment)
}
return param, nil
case "NS":
param := dns.NSRecordParam{
Name: cloudflare.F(obj.RecordName),
Type: cloudflare.F(dns.NSRecordTypeNS),
Content: cloudflare.F(obj.Content),
TTL: cloudflare.F(ttl),
}
if obj.Proxied != nil {
param.Proxied = cloudflare.F(*obj.Proxied)
}
if obj.Comment != "" {
param.Comment = cloudflare.F(obj.Comment)
}
return param, nil
case "PTR":
param := dns.PTRRecordParam{
Name: cloudflare.F(obj.RecordName),
Type: cloudflare.F(dns.PTRRecordTypePTR),
Content: cloudflare.F(obj.Content),
TTL: cloudflare.F(ttl),
}
if obj.Proxied != nil {
param.Proxied = cloudflare.F(*obj.Proxied)
}
if obj.Comment != "" {
param.Comment = cloudflare.F(obj.Comment)
}
return param, nil
default:
return nil, fmt.Errorf("record type %s is not supported", obj.Type)
}
}
// buildNewRecordParam creates the appropriate record parameter for creating new
// records.
func (obj *CloudflareDNSRes) buildNewRecordParam() (dns.RecordNewParamsBodyUnion, error) {
result, err := obj.buildRecordParam()
if err != nil {
return nil, err
}
return result.(dns.RecordNewParamsBodyUnion), nil
}
// buildEditRecordParam creates the appropriate record parameter for editing
// records.
func (obj *CloudflareDNSRes) buildEditRecordParam() (dns.RecordEditParamsBodyUnion, error) {
result, err := obj.buildRecordParam()
if err != nil {
return nil, err
}
return result.(dns.RecordEditParamsBodyUnion), nil
}
// createRecord creates a new DNS record in Cloudflare using the resource's
// parameters.
func (obj *CloudflareDNSRes) createRecord(ctx context.Context) error {
recordParams, err := obj.buildNewRecordParam()
if err != nil {
return err
}
createParams := dns.RecordNewParams{
ZoneID: cloudflare.F(obj.zoneID),
Body: recordParams,
}
_, err = obj.client.DNS.Records.New(ctx, createParams)
if err != nil {
return errwrap.Wrapf(err, "failed to create dns record")
}
return nil
}
// updateRecord updates an existing DNS record in Cloudflare with the resource's
// parameters.
func (obj *CloudflareDNSRes) updateRecord(ctx context.Context, recordID string) error {
recordParams, err := obj.buildEditRecordParam()
if err != nil {
return err
}
editParams := dns.RecordEditParams{
ZoneID: cloudflare.F(obj.zoneID),
Body: recordParams,
}
_, err = obj.client.DNS.Records.Edit(ctx, recordID, editParams)
if err != nil {
return errwrap.Wrapf(err, "failed to update dns record")
}
return nil
}
// needsUpdate compares the current DNS record with the desired state and
// returns true if an update is needed.
func (obj *CloudflareDNSRes) needsUpdate(record dns.RecordResponse) bool {
if obj.Content != record.Content {
return true
}
if obj.TTL != int64(record.TTL) {
return true
}
if obj.Proxied != nil {
if *obj.Proxied != record.Proxied {
return true
}
}
if obj.Priority != nil {
if *obj.Priority != record.Priority {
return true
}
}
if obj.Comment != "" && obj.Comment != record.Comment {
return true
}
// TODO add more checks?
return false
}
// purgeCheckApply deletes all DNS records in the zone that are not defined in
// the mgmt graph. It queries the graph for other cloudflare:dns resources in
// the same zone and builds an exclusion list. If apply is false, it only checks
// if purge is needed.
func (obj *CloudflareDNSRes) purgeCheckApply(ctx context.Context, apply bool) (bool, error) {
listParams := dns.RecordListParams{
ZoneID: cloudflare.F(obj.zoneID),
}
iter := obj.client.DNS.Records.ListAutoPaging(ctx, listParams)
allRecords := []dns.RecordResponse{}
for iter.Next() {
allRecords = append(allRecords, iter.Current())
}
if err := iter.Err(); err != nil {
return false, errwrap.Wrapf(err, "failed to list dns records for purge")
}
excludes := make(map[string]bool)
graph, err := obj.init.FilteredGraph()
if err != nil {
return false, errwrap.Wrapf(err, "can't read the filtered graph")
}
for _, vertex := range graph.Vertices() {
res, ok := vertex.(engine.Res)
if !ok {
return false, fmt.Errorf("not a resource")
}
if res.Kind() != "cloudflare:dns" {
continue // we only want cloudflare dns resources
}
if res.Name() == obj.Name() {
continue // skip self
}
cfRes, ok := res.(*CloudflareDNSRes)
if !ok {
return false, fmt.Errorf("wrong resource type")
}
if cfRes.Zone == obj.Zone {
recordKey := fmt.Sprintf("%s:%s:%s", cfRes.RecordName, cfRes.Type,
cfRes.Content)
if cfRes.Priority != nil {
// corner case for MX records which require priority set
recordKey = fmt.Sprintf("%s:%d", recordKey, *cfRes.Priority)
}
excludes[recordKey] = true
}
}
checkOK := true
for _, record := range allRecords {
recordKey := fmt.Sprintf("%s:%s:%s", record.Name, record.Type,
record.Content)
if record.Priority != 0 {
recordKey = fmt.Sprintf("%s:%d", recordKey, record.Priority)
}
if excludes[recordKey] {
continue
}
if apply {
deleteParams := dns.RecordDeleteParams{
ZoneID: cloudflare.F(obj.zoneID),
}
_, err := obj.client.DNS.Records.Delete(ctx, record.ID, deleteParams)
if err != nil {
return false, errwrap.Wrapf(err, "failed to purge %s", recordKey)
}
} else {
checkOK = false
}
}
return checkOK, nil
}
// GraphQueryAllowed returns nil if you're allowed to query the graph. This
// function accepts information about the requesting resource so we can
// determine the access with some form of fine-grained control.
func (obj *CloudflareDNSRes) GraphQueryAllowed(opts ...engine.GraphQueryableOption) error {
options := &engine.GraphQueryableOptions{} // default options
options.Apply(opts...) // apply the options
if options.Kind != "cloudflare:dns" {
return fmt.Errorf("only other cloudflare dns resources can access this info")
}
return nil
}
// matchesRecordName checks if a record name from the API matches our desired record name.
// Handles both FQDN (www.example.com) and short form (www) comparisons.
func (obj *CloudflareDNSRes) matchesRecordName(apiRecordName string) bool {
desired := obj.normalizeRecordName(obj.RecordName)
actual := obj.normalizeRecordName(apiRecordName)
return desired == actual
}
// normalizeRecordName converts a record name to a consistent format for comparison.
// Converts to FQDN format (e.g., "www" -> "www.example.com", "@" -> "example.com")
func (obj *CloudflareDNSRes) normalizeRecordName(name string) string {
if name == "@" || name == obj.Zone {
return obj.Zone
}
if strings.HasSuffix(name, "."+obj.Zone) || name == obj.Zone {
return name
}
return name + "." + obj.Zone
}

View File

@@ -127,9 +127,9 @@ type ExecRes struct {
WatchShell string `lang:"watchshell" yaml:"watchshell"` WatchShell string `lang:"watchshell" yaml:"watchshell"`
// IfCmd is the command that runs to guard against running the Cmd. If // IfCmd is the command that runs to guard against running the Cmd. If
// this command succeeds, then Cmd *will* be run. If this command // this command succeeds, then Cmd *will not* be blocked from running.
// returns a non-zero result, then the Cmd will not be run. Any error // If this command returns a non-zero result, then the Cmd will not be
// scenario or timeout will cause the resource to error. // run. Any error scenario or timeout will cause the resource to error.
IfCmd string `lang:"ifcmd" yaml:"ifcmd"` IfCmd string `lang:"ifcmd" yaml:"ifcmd"`
// IfCwd is the Cwd for the IfCmd. See the docs for Cwd. // IfCwd is the Cwd for the IfCmd. See the docs for Cwd.
@@ -145,6 +145,19 @@ type ExecRes struct {
// does!) // does!)
IfEquals *string `lang:"ifequals" yaml:"ifequals"` IfEquals *string `lang:"ifequals" yaml:"ifequals"`
// NIfCmd is the command that runs to guard against running the Cmd. If
// this command succeeds, then Cmd *will* be blocked from running. If
// this command returns a non-zero result, then the Cmd will be allowed
// to run if not blocked by anything else. This is the opposite of the
// IfCmd.
NIfCmd string `lang:"nifcmd" yaml:"nifcmd"`
// NIfCwd is the Cwd for the NIfCmd. See the docs for Cwd.
NIfCwd string `lang:"nifcwd" yaml:"nifcwd"`
// NIfShell is the Shell for the NIfCmd. See the docs for Shell.
NIfShell string `lang:"nifshell" yaml:"nifshell"`
// Creates is the absolute file path to check for before running the // Creates is the absolute file path to check for before running the
// main cmd. If this path exists, then the cmd will not run. More // main cmd. If this path exists, then the cmd will not run. More
// precisely we attempt to `stat` the file, so it must succeed for a // precisely we attempt to `stat` the file, so it must succeed for a
@@ -469,7 +482,7 @@ func (obj *ExecRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
cmdName = obj.IfShell // usually bash, or sh cmdName = obj.IfShell // usually bash, or sh
cmdArgs = []string{"-c", obj.IfCmd} cmdArgs = []string{"-c", obj.IfCmd}
} }
cmd := exec.Command(cmdName, cmdArgs...) cmd := exec.CommandContext(ctx, cmdName, cmdArgs...)
cmd.Dir = obj.IfCwd // run program in pwd if "" cmd.Dir = obj.IfCwd // run program in pwd if ""
// ignore signals sent to parent process (we're in our own group) // ignore signals sent to parent process (we're in our own group)
cmd.SysProcAttr = &syscall.SysProcAttr{ cmd.SysProcAttr = &syscall.SysProcAttr{
@@ -535,6 +548,93 @@ func (obj *ExecRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
} }
} }
if obj.NIfCmd != "" { // opposite of the ifcmd check
var cmdName string
var cmdArgs []string
if obj.NIfShell == "" {
// call without a shell
// FIXME: are there still whitespace splitting issues?
split := strings.Fields(obj.NIfCmd)
cmdName = split[0]
//d, _ := os.Getwd() // TODO: how does this ever error ?
//cmdName = path.Join(d, cmdName)
cmdArgs = split[1:]
} else {
cmdName = obj.NIfShell // usually bash, or sh
cmdArgs = []string{"-c", obj.NIfCmd}
}
cmd := exec.CommandContext(ctx, cmdName, cmdArgs...)
cmd.Dir = obj.NIfCwd // run program in pwd if ""
// ignore signals sent to parent process (we're in our own group)
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
Pgid: 0,
}
// if we have an user and group, use them
var err error
if cmd.SysProcAttr.Credential, err = obj.getCredential(); err != nil {
return false, errwrap.Wrapf(err, "error while setting credential")
}
var out splitWriter
out.Init()
cmd.Stdout = out.Stdout
cmd.Stderr = out.Stderr
err = cmd.Run()
if err == nil {
obj.init.Logf("nifcmd: %s", strings.Join(cmd.Args, " "))
obj.init.Logf("nifcmd exited with: %d, skipping cmd", 0)
s := out.String()
if s == "" {
obj.init.Logf("nifcmd out empty!")
} else {
obj.init.Logf("nifcmd out:")
obj.init.Logf("%s", s)
}
//if err := obj.checkApplyWriteCache(); err != nil {
// return false, err
//}
obj.safety()
if err := obj.send(); err != nil {
return false, err
}
return true, nil // don't run
}
exitErr, ok := err.(*exec.ExitError) // embeds an os.ProcessState
if !ok {
// command failed in some bad way
return false, errwrap.Wrapf(err, "nifcmd failed in some bad way")
}
pStateSys := exitErr.Sys() // (*os.ProcessState) Sys
wStatus, ok := pStateSys.(syscall.WaitStatus)
if !ok {
return false, errwrap.Wrapf(err, "could not get exit status of nifcmd")
}
exitStatus := wStatus.ExitStatus()
if exitStatus == 0 {
// i'm not sure if this could happen
return false, errwrap.Wrapf(err, "unexpected nifcmd exit status of zero")
}
obj.init.Logf("nifcmd: %s", strings.Join(cmd.Args, " "))
obj.init.Logf("nifcmd exited with: %d, not skipping cmd", exitStatus)
if s := out.String(); s == "" {
obj.init.Logf("nifcmd out empty!")
} else {
obj.init.Logf("nifcmd out:")
obj.init.Logf("%s", s)
}
//if obj.NIfEquals != nil && *obj.NIfEquals == s {
// obj.init.Logf("nifequals matched")
// return true, nil // don't run
//}
}
if obj.Creates != "" { // gate the extra syscall if obj.Creates != "" { // gate the extra syscall
if _, err := os.Stat(obj.Creates); err == nil { if _, err := os.Stat(obj.Creates); err == nil {
obj.init.Logf("creates file exists, skipping cmd") obj.init.Logf("creates file exists, skipping cmd")
@@ -717,7 +817,7 @@ func (obj *ExecRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
cmdName = obj.DoneShell // usually bash, or sh cmdName = obj.DoneShell // usually bash, or sh
cmdArgs = []string{"-c", obj.DoneCmd} cmdArgs = []string{"-c", obj.DoneCmd}
} }
cmd := exec.Command(cmdName, cmdArgs...) cmd := exec.CommandContext(ctx, cmdName, cmdArgs...)
cmd.Dir = obj.DoneCwd // run program in pwd if "" cmd.Dir = obj.DoneCwd // run program in pwd if ""
// ignore signals sent to parent process (we're in our own group) // ignore signals sent to parent process (we're in our own group)
cmd.SysProcAttr = &syscall.SysProcAttr{ cmd.SysProcAttr = &syscall.SysProcAttr{
@@ -910,6 +1010,16 @@ func (obj *ExecRes) Cmp(r engine.Res) error {
return errwrap.Wrapf(err, "the IfEquals differs") return errwrap.Wrapf(err, "the IfEquals differs")
} }
if obj.NIfCmd != res.NIfCmd {
return fmt.Errorf("the NIfCmd differs")
}
if obj.NIfCwd != res.NIfCwd {
return fmt.Errorf("the NIfCwd differs")
}
if obj.NIfShell != res.NIfShell {
return fmt.Errorf("the NIfShell differs")
}
if obj.Creates != res.Creates { if obj.Creates != res.Creates {
return fmt.Errorf("the Creates differs") return fmt.Errorf("the Creates differs")
} }
@@ -956,6 +1066,7 @@ type ExecUID struct {
Cmd string Cmd string
WatchCmd string WatchCmd string
IfCmd string IfCmd string
NIfCmd string
DoneCmd string DoneCmd string
// TODO: add more elements here // TODO: add more elements here
} }
@@ -1046,6 +1157,7 @@ func (obj *ExecRes) UIDs() []engine.ResUID {
Cmd: obj.getCmd(), Cmd: obj.getCmd(),
WatchCmd: obj.WatchCmd, WatchCmd: obj.WatchCmd,
IfCmd: obj.IfCmd, IfCmd: obj.IfCmd,
NIfCmd: obj.NIfCmd,
DoneCmd: obj.DoneCmd, DoneCmd: obj.DoneCmd,
// TODO: add more params here // TODO: add more params here
} }
@@ -1140,6 +1252,11 @@ func (obj *ExecRes) cmdFiles() []string {
} else if sp := strings.Fields(obj.IfCmd); len(sp) > 0 { } else if sp := strings.Fields(obj.IfCmd); len(sp) > 0 {
paths = append(paths, sp[0]) paths = append(paths, sp[0])
} }
if obj.NIfShell != "" {
paths = append(paths, obj.NIfShell)
} else if sp := strings.Fields(obj.NIfCmd); len(sp) > 0 {
paths = append(paths, sp[0])
}
if obj.DoneShell != "" { if obj.DoneShell != "" {
paths = append(paths, obj.DoneShell) paths = append(paths, obj.DoneShell)
} else if sp := strings.Fields(obj.DoneCmd); len(sp) > 0 { } else if sp := strings.Fields(obj.DoneCmd); len(sp) > 0 {

View File

@@ -197,7 +197,7 @@ func (obj *GroupRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
cmdName = "groupdel" cmdName = "groupdel"
} }
cmd := exec.Command(cmdName, args...) cmd := exec.CommandContext(ctx, cmdName, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{ cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true, Setpgid: true,
Pgid: 0, Pgid: 0,

View File

@@ -661,7 +661,7 @@ func TestResources1(t *testing.T) {
} }
t.Logf("test #%d: running CheckApply", index) t.Logf("test #%d: running CheckApply", index)
checkOK, err := res.CheckApply(doneCtx, true) // no noop! checkOK, err := res.CheckApply(context.TODO(), true) // no noop!
if err != nil { if err != nil {
t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: FAIL", index)
t.Errorf("test #%d: CheckApply failed: %s", index, err.Error()) t.Errorf("test #%d: CheckApply failed: %s", index, err.Error())

View File

@@ -287,7 +287,7 @@ func (obj *SysctlRes) runtimeCheckApply(ctx context.Context, apply bool) (bool,
b, err := os.ReadFile(obj.toPath()) b, err := os.ReadFile(obj.toPath())
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
// system or permissions error? // system or permissions error?
return false, nil return false, err
} }
if err == nil && bytes.Equal(expected, b) { if err == nil && bytes.Equal(expected, b) {
return true, nil // we match! return true, nil // we match!
@@ -323,7 +323,7 @@ func (obj *SysctlRes) persistCheckApply(ctx context.Context, apply bool) (bool,
b, err := os.ReadFile(obj.getFilename()) b, err := os.ReadFile(obj.getFilename())
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
// system or permissions error? // system or permissions error?
return false, nil return false, err
} }
if err == nil && bytes.Equal(expected, b) { if err == nil && bytes.Equal(expected, b) {
return true, nil // we match! return true, nil // we match!

20
examples/lang/exec-if.mcl Normal file
View File

@@ -0,0 +1,20 @@
exec "i will run 1" {
cmd => "/usr/bin/echo i WILL run",
ifcmd => "/usr/bin/true",
}
exec "i will not run 2" {
cmd => "/usr/bin/echo i will NOT run",
ifcmd => "/usr/bin/false",
}
# nifcmd exited with: 0, skipping cmd
exec "i will not run 1" {
cmd => "/usr/bin/echo i will NOT run",
nifcmd => "/usr/bin/true",
}
exec "i will run 2" {
cmd => "/usr/bin/echo i WILL run",
nifcmd => "/usr/bin/false",
}

5
go.mod
View File

@@ -70,6 +70,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chappjc/logrus-prefix v0.0.0-20180227015900-3a1d64819adb // indirect github.com/chappjc/logrus-prefix v0.0.0-20180227015900-3a1d64819adb // indirect
github.com/cloudflare/circl v1.5.0 // indirect github.com/cloudflare/circl v1.5.0 // indirect
github.com/cloudflare/cloudflare-go/v6 v6.1.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect github.com/cloudwego/base64x v0.1.5 // indirect
github.com/containerd/log v0.1.0 // indirect github.com/containerd/log v0.1.0 // indirect
github.com/coreos/go-semver v0.3.1 // indirect github.com/coreos/go-semver v0.3.1 // indirect
@@ -156,6 +157,10 @@ require (
github.com/spf13/cobra v1.8.1 // indirect github.com/spf13/cobra v1.8.1 // indirect
github.com/spf13/pflag v1.0.6-0.20250109003754-5ca813443bd2 // indirect github.com/spf13/pflag v1.0.6-0.20250109003754-5ca813443bd2 // indirect
github.com/stmcginnis/gofish v0.20.0 // indirect github.com/stmcginnis/gofish v0.20.0 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 // indirect github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect

12
go.sum
View File

@@ -68,6 +68,8 @@ github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudflare/circl v1.5.0 h1:hxIWksrX6XN5a1L2TI/h53AGPhNHoUBo+TD1ms9+pys= github.com/cloudflare/circl v1.5.0 h1:hxIWksrX6XN5a1L2TI/h53AGPhNHoUBo+TD1ms9+pys=
github.com/cloudflare/circl v1.5.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cloudflare/circl v1.5.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cloudflare/cloudflare-go/v6 v6.1.0 h1:208leV/QEyIZuxFKNk3ztiOh4PeNW/qvLHvzafcbpjI=
github.com/cloudflare/cloudflare-go/v6 v6.1.0/go.mod h1:Lj3MUqjvKctXRpdRhLQxZYRrNZHuRs0XYuH8JtQGyoI=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
@@ -496,6 +498,16 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75 h1:6fotK7otjonDflCTK0BCfls4SPy3NcCVb5dqqmbRknE=
github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk=
github.com/tredoe/osutil v1.5.0 h1:UGVxbbHRoZi8xXVmbNZ2vgG6XoJ15ndE4LniiQ3rJKg= github.com/tredoe/osutil v1.5.0 h1:UGVxbbHRoZi8xXVmbNZ2vgG6XoJ15ndE4LniiQ3rJKg=

View File

@@ -5624,7 +5624,7 @@ func (obj *StmtProg) SetScope(scope *interfaces.Scope) error {
// debugging visualizations // debugging visualizations
if obj.data.Debug && orderingGraphSingleton { if obj.data.Debug && orderingGraphSingleton {
obj.data.Logf("running graphviz for ordering graph...") obj.data.Logf("running graphviz for ordering graph...")
if err := orderingGraph.ExecGraphviz("/tmp/graphviz-ordering.dot"); err != nil { if err := orderingGraph.ExecGraphviz(context.TODO(), "/tmp/graphviz-ordering.dot"); err != nil {
obj.data.Logf("graphviz: errored: %+v", err) obj.data.Logf("graphviz: errored: %+v", err)
} }
//if err := orderingGraphFiltered.ExecGraphviz("/tmp/graphviz-ordering-filtered.dot"); err != nil { //if err := orderingGraphFiltered.ExecGraphviz("/tmp/graphviz-ordering-filtered.dot"); err != nil {

View File

@@ -961,7 +961,7 @@ func (obj *Engine) Graph() *pgraph.Graph {
// ExecGraphviz writes out the diagram of a graph to be used for visualization // ExecGraphviz writes out the diagram of a graph to be used for visualization
// and debugging. You must not modify the graph (eg: during Lock) when calling // and debugging. You must not modify the graph (eg: during Lock) when calling
// this method. // this method.
func (obj *Engine) ExecGraphviz(dir string) error { func (obj *Engine) ExecGraphviz(ctx context.Context, dir string) error {
// No mutex needed here since this func runs in a non-concurrent Txn. // No mutex needed here since this func runs in a non-concurrent Txn.
// No mutex is needed at this time because we only run this in txn's and // No mutex is needed at this time because we only run this in txn's and
@@ -1019,7 +1019,7 @@ func (obj *Engine) ExecGraphviz(dir string) error {
}, },
} }
if err := gv.Exec(); err != nil { if err := gv.Exec(ctx); err != nil {
return err return err
} }
return nil return nil

View File

@@ -567,7 +567,7 @@ func TestAstFunc1(t *testing.T) {
} }
if runGraphviz { if runGraphviz {
t.Logf("test #%d: Running graphviz...", index) t.Logf("test #%d: Running graphviz...", index)
if err := fgraph.ExecGraphviz("/tmp/graphviz.dot"); err != nil { if err := fgraph.ExecGraphviz(context.TODO(), "/tmp/graphviz.dot"); err != nil {
t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: FAIL", index)
t.Errorf("test #%d: writing graph failed: %+v", index, err) t.Errorf("test #%d: writing graph failed: %+v", index, err)
return return
@@ -1120,7 +1120,7 @@ func TestAstFunc2(t *testing.T) {
} }
ast.ScopeGraph(graph) ast.ScopeGraph(graph)
if err := graph.ExecGraphviz("/tmp/set-scope.dot"); err != nil { if err := graph.ExecGraphviz(context.TODO(), "/tmp/set-scope.dot"); err != nil {
t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: FAIL", index)
t.Errorf("test #%d: writing graph failed: %+v", index, err) t.Errorf("test #%d: writing graph failed: %+v", index, err)
return return
@@ -1233,7 +1233,7 @@ func TestAstFunc2(t *testing.T) {
if runGraphviz { if runGraphviz {
t.Logf("test #%d: Running graphviz...", index) t.Logf("test #%d: Running graphviz...", index)
if err := fgraph.ExecGraphviz("/tmp/graphviz.dot"); err != nil { if err := fgraph.ExecGraphviz(context.TODO(), "/tmp/graphviz.dot"); err != nil {
t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: FAIL", index)
t.Errorf("test #%d: writing graph failed: %+v", index, err) t.Errorf("test #%d: writing graph failed: %+v", index, err)
return return
@@ -1996,7 +1996,7 @@ func TestAstFunc3(t *testing.T) {
} }
ast.ScopeGraph(graph) ast.ScopeGraph(graph)
if err := graph.ExecGraphviz("/tmp/set-scope.dot"); err != nil { if err := graph.ExecGraphviz(context.TODO(), "/tmp/set-scope.dot"); err != nil {
t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: FAIL", index)
t.Errorf("test #%d: writing graph failed: %+v", index, err) t.Errorf("test #%d: writing graph failed: %+v", index, err)
return return
@@ -2088,7 +2088,7 @@ func TestAstFunc3(t *testing.T) {
if runGraphviz { if runGraphviz {
t.Logf("test #%d: Running graphviz...", index) t.Logf("test #%d: Running graphviz...", index)
if err := fgraph.ExecGraphviz("/tmp/graphviz.dot"); err != nil { if err := fgraph.ExecGraphviz(context.TODO(), "/tmp/graphviz.dot"); err != nil {
t.Errorf("test #%d: FAIL", index) t.Errorf("test #%d: FAIL", index)
t.Errorf("test #%d: writing graph failed: %+v", index, err) t.Errorf("test #%d: writing graph failed: %+v", index, err)
return return

View File

@@ -1049,7 +1049,8 @@ func (obj *Main) Run() error {
obj.ge.Graph(): nil, obj.ge.Graph(): nil,
}, },
} }
if err := gv.Exec(); err != nil { // FIXME: is this the right ctx?
if err := gv.Exec(exitCtx); err != nil {
Logf("graphviz: %+v", err) Logf("graphviz: %+v", err)
} else { } else {
Logf("graphviz: successfully generated graph!") Logf("graphviz: successfully generated graph!")

186
misc/fpm-repo.sh Executable file
View File

@@ -0,0 +1,186 @@
#!/usr/bin/env bash
# This script makes packages of mgmt using fpm. It pulls in any binaries from:
# releases/$version/binary-linux-$arch/mgmt-linux-$arch-$version and builds to:
# repository/$distro-$version/$arch/ before it runs the appropriate createrepo
# commands.
# NOTE: run `gem install fpm` to update my ~/bin/fpm to the latest version.
# TODO: consider switching to https://github.com/goreleaser/nfpm
# the binary to package
BINARY="mgmt"
# maintainer email
MAINTAINER="fpm@mgmtconfig.com"
# project url
URL="https://github.com/purpleidea/mgmt/"
# project description
DESCRIPTION="Next generation distributed, event-driven, parallel config management!"
# project license
LICENSE="GPLv3"
# location to install the binary
PREFIX="/usr/bin"
# release directory
DIR="releases"
# repository directory
OUT="repository"
## make sure the distro is a known valid one
#if [[ "$DISTRO" == fedora-* ]]; then
# typ="rpm"
#elif [[ "$DISTRO" == centos-* ]]; then
# typ="rpm"
#elif [[ "$DISTRO" == debian-* ]]; then
# typ="deb"
#elif [[ "$DISTRO" == ubuntu-* ]]; then
# typ="deb"
#elif [[ "$DISTRO" == archlinux ]]; then
# typ="pacman"
#else
# echo "unknown distro: ${DISTRO}."
# exit 1
#fi
#if [ "$typ" != "rpm" ] && [ "$typ" != "deb" ] && [ "$typ" != "pacman" ]; then
# echo "invalid package type"
# exit 1
#fi
## assume the file extension
#ext="$typ"
#if [ "$typ" = "pacman" ]; then # archlinux is an exception
# ext="pkg.tar.xz"
#fi
# in case the `fpm` gem bin isn't in the $PATH
if command -v ruby >/dev/null && command -v gem >/dev/null && ! command -v fpm >/dev/null; then
PATH="$(ruby -r rubygems -e 'puts Gem.user_dir')/bin:$PATH"
fi
# skip putting these versions into the repos
skip_mgmt_version=()
skip_mgmt_version+=("0.0.25")
skip_mgmt_version+=("0.0.26")
skip_mgmt_version+=("0.0.27")
# from binary arch to repoarch
declare -A map_repoarch=(
[amd64]="x86_64"
[arm64]="aarch64"
)
declare -A map_distrotype=(
[fedora]="rpm"
[debian]="deb"
)
declare -A map_distro_version=(
["fedora-41"]="libvirt-devel augeas-devel"
["fedora-42"]="libvirt-devel augeas-devel"
["debian-13"]="libvirt-dev libaugeas-dev"
)
#echo releases:
#for dv in "fedora-41" "fedora-42" "debian-11" "archlinux-xx"; do
for dv in "${!map_distro_version[@]}"; do
distro=${dv%%-*};
version=${dv##*-}
deps=${map_distro_version[$dv]}
echo "distro-version: ${distro}-${version}"
mkdir -p ${OUT}/$dv/
type=${map_distrotype[$distro]}
# track the arches we see
declare -A repoarches=()
for chunk1 in ${DIR}/*; do
if [ ! -d "$chunk1" ]; then # check if it's a regular dir
continue
fi
package_version=$(basename "$chunk1")
#echo "package_version: $package_version"
if [[ " ${skip_mgmt_version[*]} " == *" $package_version "* ]]; then
echo "skip: ${package_version}"
continue
fi
for chunk2 in $chunk1/binary-linux-*; do
if [ ! -d "$chunk2" ]; then # check if it's a regular dir
continue
fi
arch=${chunk2##*-}
#echo "arch: $arch"
repoarch=${map_repoarch[$arch]}
#echo "repoarch: $repoarch"
repoarches["${repoarch}"]="${type}" # tag it
mkdir -p ${OUT}/${distro}-${version}/$repoarch/
#file $chunk2/mgmt-linux-$arch-$package_version # found it
output="${OUT}/${distro}-${version}/${repoarch}/mgmt-${package_version}.${repoarch}.${type}"
if [ -f "$output" ]; then
echo "skip: ${output}"
continue
fi
depends=""
for i in $deps; do
depends="$depends --depends $i"
done
# build the package
echo "fpm..."
echo "output: ${output}"
fpm \
--log error \
--name "$BINARY" \
--version "$package_version" \
--maintainer "$MAINTAINER" \
--url "$URL" \
--description "$DESCRIPTION" \
--license "$LICENSE" \
--input-type dir \
--output-type "$type" \
--package "${output}" \
${depends} \
"misc/mgmt.service"="/usr/lib/systemd/system/mgmt.service" \
"$BINARY"="$PREFIX/mgmt" \
|| rm "${output}" # if it fails, remove it...
done
done
# now run createrepo or similar
for key in "${!repoarches[@]}"; do
type=${repoarches[$key]}
outdir="${OUT}/$dv/${key}/"
if [ "$type" = "rpm" ]; then
echo "createrepo ${type} ${outdir}"
# TODO: use --deltas ?
createrepo_c --update "${outdir}"
fi
if [ "$type" = "deb" ]; then
cd ${outdir} > /dev/null
# don't regenerate unnecessarily
if [[ ! -f Packages.gz ]] || find . -name '*.deb' -newer Packages.gz | grep -q .; then
echo "dpkg-scanpackages ${type} ${outdir}"
dpkg-scanpackages --multiversion . /dev/null | gzip -9 > Packages.gz
fi
cd - > /dev/null # silence it
fi
done
done
USERNAME=$(cat ~/.config/copr 2>/dev/null | grep username | awk -F '=' '{print $2}' | tr -d ' ')
SERVER='dl.fedoraproject.org'
REMOTE_PATH="/srv/pub/alt/${USERNAME}/${BINARY}/repo/"
if [ "${USERNAME}" = "" ]; then
echo "empty username, can't rsync"
fi
rsync -avzSH --progress --delete ${OUT}/ ${SERVER}:${REMOTE_PATH}

View File

@@ -30,6 +30,7 @@
package pgraph // TODO: this should be a subpackage package pgraph // TODO: this should be a subpackage
import ( import (
"context"
"fmt" "fmt"
"html" "html"
"os" "os"
@@ -153,7 +154,7 @@ func (obj *Graphviz) Text() string {
// Exec writes out the graphviz data and runs the correct graphviz filter // Exec writes out the graphviz data and runs the correct graphviz filter
// command. // command.
func (obj *Graphviz) Exec() error { func (obj *Graphviz) Exec(ctx context.Context) error {
filter := "" filter := ""
switch obj.Filter { switch obj.Filter {
case "": case "":
@@ -195,7 +196,7 @@ func (obj *Graphviz) Exec() error {
} }
out := fmt.Sprintf("%s.png", filename) out := fmt.Sprintf("%s.png", filename)
cmd := exec.Command(path, "-Tpng", fmt.Sprintf("-o%s", out), filename) cmd := exec.CommandContext(ctx, path, "-Tpng", fmt.Sprintf("-o%s", out), filename)
if err1 == nil && err2 == nil { if err1 == nil && err2 == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{} cmd.SysProcAttr = &syscall.SysProcAttr{}
@@ -286,7 +287,7 @@ func (obj *Graph) Graphviz() string {
// ExecGraphviz writes out the graphviz data and runs the correct graphviz // ExecGraphviz writes out the graphviz data and runs the correct graphviz
// filter command. // filter command.
func (obj *Graph) ExecGraphviz(filename string) error { func (obj *Graph) ExecGraphviz(ctx context.Context, filename string) error {
gv := &Graphviz{ gv := &Graphviz{
Graphs: map[*Graph]*GraphvizOpts{ Graphs: map[*Graph]*GraphvizOpts{
obj: nil, obj: nil,
@@ -296,5 +297,5 @@ func (obj *Graph) ExecGraphviz(filename string) error {
Filename: filename, Filename: filename,
//Hostname: hostname, //Hostname: hostname,
} }
return gv.Exec() return gv.Exec(ctx)
} }

View File

@@ -485,7 +485,7 @@ func TestTopoSort3(t *testing.T) {
G.AddEdge(v5, v6, e5) G.AddEdge(v5, v6, e5)
G.AddEdge(v4, v2, e6) // cycle G.AddEdge(v4, v2, e6) // cycle
G.ExecGraphviz("/tmp/g.dot") //G.ExecGraphviz(context.TODO(), "/tmp/g.dot")
_, err := G.TopologicalSort() _, err := G.TopologicalSort()
if err == nil { if err == nil {