13 Commits

Author SHA1 Message Date
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 1051 additions and 35 deletions

1
.gitignore vendored
View File

@@ -15,6 +15,7 @@ tmp/
mgmt.iml
/rpmbuild/
/releases/
/repository/
/pprof/
/sites/
# 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/)
[![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)
[![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)
[![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 |
|---|---|
| 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) |
| 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 |

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/).
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.
4. Connect to our [#mgmtconfig](https://web.libera.chat/?channels=#mgmtconfig)
IRC channel on the [Libera.Chat](https://libera.chat/) network. You can use any
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)
4. Connect to our [#mgmtconfig](https://matrix.to/#/#mgmtconfig:matrix.org)
Matrix channel and hang out with us there out there.
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)
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
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
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
@@ -534,9 +531,7 @@ which definitely existed before the band did.
### You didn't answer my question, or I have a question!
It's best to ask on [IRC](https://web.libera.chat/?channels=#mgmtconfig)
to see if someone can help you. If you don't get a response from IRC, you can
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
It's best to ask on [Matrix](https://matrix.to/#/#mgmtconfig:matrix.org) to see
if someone can help. If you don't get a response there, you can send a patch to
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).

View File

@@ -0,0 +1,680 @@
// 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"
"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 *int64 `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.MetaParams().Poll == 0 || obj.MetaParams().Poll < 1 { // CF accepts ~4req/s so this is good enough
return fmt.Errorf("cloudflare:dns requires polling, set Meta:poll param (e.g., 60 seconds), min. 1s")
}
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),
)
//TODO: does it make more sense to check it here or in CheckApply()?
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) {
zone, err := obj.client.Zones.List(ctx, zones.ZoneListParams{
Name: cloudflare.F(obj.Zone),
})
if err != nil {
return false, err
}
if len(zone.Result) == 0 {
return false, fmt.Errorf("there's no zone registered with name %s", obj.Zone)
}
if len(zone.Result) > 1 {
return false, fmt.Errorf("there's more than one zone with name %s", obj.Zone)
}
// 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
}
}
// List existing records
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")
}
recordExists := len(recordList.Result) > 0
var record dns.RecordResponse
if recordExists {
record = recordList.Result[0]
}
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 != 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 != 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(float64(*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 "SRV":
param := dns.SRVRecordParam{
Name: cloudflare.F(obj.RecordName),
Type: cloudflare.F(dns.SRVRecordTypeSRV),
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 float64(*obj.Priority) != record.Priority {
return true
}
}
if 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", cfRes.RecordName, cfRes.Type)
excludes[recordKey] = true
}
}
checkOK := true
for _, record := range allRecords {
recordKey := fmt.Sprintf("%s:%s", record.Name, record.Type)
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
}

View File

@@ -127,9 +127,9 @@ type ExecRes struct {
WatchShell string `lang:"watchshell" yaml:"watchshell"`
// IfCmd is the command that runs to guard against running the Cmd. If
// this command succeeds, then Cmd *will* be run. If this command
// returns a non-zero result, then the Cmd will not be run. Any error
// scenario or timeout will cause the resource to error.
// this command succeeds, then Cmd *will not* be blocked from running.
// If this command returns a non-zero result, then the Cmd will not be
// run. Any error scenario or timeout will cause the resource to error.
IfCmd string `lang:"ifcmd" yaml:"ifcmd"`
// IfCwd is the Cwd for the IfCmd. See the docs for Cwd.
@@ -145,6 +145,19 @@ type ExecRes struct {
// does!)
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
// 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
@@ -469,7 +482,7 @@ func (obj *ExecRes) CheckApply(ctx context.Context, apply bool) (bool, error) {
cmdName = obj.IfShell // usually bash, or sh
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 ""
// ignore signals sent to parent process (we're in our own group)
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 _, err := os.Stat(obj.Creates); err == nil {
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
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 ""
// ignore signals sent to parent process (we're in our own group)
cmd.SysProcAttr = &syscall.SysProcAttr{
@@ -910,6 +1010,16 @@ func (obj *ExecRes) Cmp(r engine.Res) error {
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 {
return fmt.Errorf("the Creates differs")
}
@@ -956,6 +1066,7 @@ type ExecUID struct {
Cmd string
WatchCmd string
IfCmd string
NIfCmd string
DoneCmd string
// TODO: add more elements here
}
@@ -1046,6 +1157,7 @@ func (obj *ExecRes) UIDs() []engine.ResUID {
Cmd: obj.getCmd(),
WatchCmd: obj.WatchCmd,
IfCmd: obj.IfCmd,
NIfCmd: obj.NIfCmd,
DoneCmd: obj.DoneCmd,
// TODO: add more params here
}
@@ -1140,6 +1252,11 @@ func (obj *ExecRes) cmdFiles() []string {
} else if sp := strings.Fields(obj.IfCmd); len(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 != "" {
paths = append(paths, obj.DoneShell)
} 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"
}
cmd := exec.Command(cmdName, args...)
cmd := exec.CommandContext(ctx, cmdName, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
Pgid: 0,

View File

@@ -661,7 +661,7 @@ func TestResources1(t *testing.T) {
}
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 {
t.Errorf("test #%d: FAIL", index)
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())
if err != nil && !os.IsNotExist(err) {
// system or permissions error?
return false, nil
return false, err
}
if err == nil && bytes.Equal(expected, b) {
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())
if err != nil && !os.IsNotExist(err) {
// system or permissions error?
return false, nil
return false, err
}
if err == nil && bytes.Equal(expected, b) {
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/chappjc/logrus-prefix v0.0.0-20180227015900-3a1d64819adb // 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/containerd/log v0.1.0 // 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/pflag v1.0.6-0.20250109003754-5ca813443bd2 // 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/twitchyliquid64/golang-asm v0.15.1 // 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/cloudflare/circl v1.5.0 h1:hxIWksrX6XN5a1L2TI/h53AGPhNHoUBo+TD1ms9+pys=
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/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
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/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk=
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
if obj.data.Debug && orderingGraphSingleton {
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)
}
//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
// and debugging. You must not modify the graph (eg: during Lock) when calling
// 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 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 nil

View File

@@ -567,7 +567,7 @@ func TestAstFunc1(t *testing.T) {
}
if runGraphviz {
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: writing graph failed: %+v", index, err)
return
@@ -1120,7 +1120,7 @@ func TestAstFunc2(t *testing.T) {
}
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: writing graph failed: %+v", index, err)
return
@@ -1233,7 +1233,7 @@ func TestAstFunc2(t *testing.T) {
if runGraphviz {
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: writing graph failed: %+v", index, err)
return
@@ -1996,7 +1996,7 @@ func TestAstFunc3(t *testing.T) {
}
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: writing graph failed: %+v", index, err)
return
@@ -2088,7 +2088,7 @@ func TestAstFunc3(t *testing.T) {
if runGraphviz {
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: writing graph failed: %+v", index, err)
return

View File

@@ -1049,7 +1049,8 @@ func (obj *Main) Run() error {
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)
} else {
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
import (
"context"
"fmt"
"html"
"os"
@@ -153,7 +154,7 @@ func (obj *Graphviz) Text() string {
// Exec writes out the graphviz data and runs the correct graphviz filter
// command.
func (obj *Graphviz) Exec() error {
func (obj *Graphviz) Exec(ctx context.Context) error {
filter := ""
switch obj.Filter {
case "":
@@ -195,7 +196,7 @@ func (obj *Graphviz) Exec() error {
}
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 {
cmd.SysProcAttr = &syscall.SysProcAttr{}
@@ -286,7 +287,7 @@ func (obj *Graph) Graphviz() string {
// ExecGraphviz writes out the graphviz data and runs the correct graphviz
// filter command.
func (obj *Graph) ExecGraphviz(filename string) error {
func (obj *Graph) ExecGraphviz(ctx context.Context, filename string) error {
gv := &Graphviz{
Graphs: map[*Graph]*GraphvizOpts{
obj: nil,
@@ -296,5 +297,5 @@ func (obj *Graph) ExecGraphviz(filename string) error {
Filename: filename,
//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(v4, v2, e6) // cycle
G.ExecGraphviz("/tmp/g.dot")
//G.ExecGraphviz(context.TODO(), "/tmp/g.dot")
_, err := G.TopologicalSort()
if err == nil {