diff --git a/htmlui/src/App.css b/htmlui/src/App.css index 93da0a90b..27497a70f 100644 --- a/htmlui/src/App.css +++ b/htmlui/src/App.css @@ -33,8 +33,19 @@ body { margin-top: 4px; } +.policyEffectiveValue { + background-color: #fff; +} + +.policyEditorHeader { + font-size: 110%; + font-weight: bold; + padding-top: 4px; + padding-bottom: 4px; +} + .policyValue { - margin-bottom: 4px; + background-color: #f8f8f8; } .policyField { diff --git a/htmlui/src/PolicyEditor.js b/htmlui/src/PolicyEditor.js index 46fada861..d0a3c5e96 100644 --- a/htmlui/src/PolicyEditor.js +++ b/htmlui/src/PolicyEditor.js @@ -1,6 +1,7 @@ import { faCalendarTimes, faChevronLeft, faClock, faExclamationTriangle, faFileAlt, faFileArchive, faFolderOpen, faMagic } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import axios from 'axios'; +import moment from 'moment'; import React, { Component } from 'react'; import Button from 'react-bootstrap/Button'; import Col from 'react-bootstrap/Col'; @@ -10,6 +11,7 @@ import Spinner from 'react-bootstrap/Spinner'; import Accordion from 'react-bootstrap/Accordion'; import { handleChange, LogDetailSelector, OptionalBoolean, OptionalNumberField, RequiredBoolean, stateProperty, StringList, valueToNumber } from './forms'; import { errorAlert, sourceQueryStringParams } from './uiutil'; +import { getDeepStateProperty } from './deepstate'; function PolicyTypeName(s) { if (!s.host && !s.userName) { @@ -29,7 +31,7 @@ function PolicyTypeName(s) { function LabelColumn(props) { return - {props.name} + {props.name} {props.help && <>

{props.help}

} } @@ -42,6 +44,85 @@ function WideValueColumn(props) { return {props.children}; } +function EffectiveValue(component, policyField) { + const dsp = getDeepStateProperty(component, "resolved.definition." + policyField, undefined); + + return + + + + {component.PolicyDefinitionPoint(dsp)} + + + ; +} + +function EffectiveTextAreaValue(component, policyField) { + const dsp = getDeepStateProperty(component, "resolved.definition." + policyField, undefined); + + return + + + + {component.PolicyDefinitionPoint(dsp)} + + + ; +} + +function EffectiveBooleanValue(component, policyField) { + const dsp = getDeepStateProperty(component, "resolved.definition." + policyField, undefined); + + return + + + + {component.PolicyDefinitionPoint(dsp)} + + + ; +} + +function EffectiveValueColumn(props) { + return {props.children}; +} + +function UpcomingSnapshotTimes(times) { + if (!times) { + return + } + + return <> + + + + ; +} + +function SectionHeaderRow() { + return + +
Values
+
Effective
+
; +} + export class PolicyEditor extends Component { constructor() { super(); @@ -56,7 +137,9 @@ export class PolicyEditor extends Component { this.saveChanges = this.saveChanges.bind(this); this.isGlobal = this.isGlobal.bind(this); this.deletePolicy = this.deletePolicy.bind(this); - this.snapshotURL = this.snapshotURL.bind(this); + this.policyURL = this.policyURL.bind(this); + this.resolvePolicy = this.resolvePolicy.bind(this); + this.PolicyDefinitionPoint = this.PolicyDefinitionPoint.bind(this); } componentDidMount() { @@ -69,12 +152,16 @@ export class PolicyEditor extends Component { }); } - componentWillReceiveProps(props) { - this.fetchPolicy(props); + componentDidUpdate() { + const pjs = JSON.stringify(this.state.policy); + if (pjs !== this.lastResolvedPolicy) { + this.resolvePolicy(this.props); + this.lastResolvedPolicy = pjs; + } } fetchPolicy(props) { - axios.get(this.snapshotURL(props)).then(result => { + axios.get(this.policyURL(props)).then(result => { this.setState({ isLoading: false, policy: result.data, @@ -95,6 +182,30 @@ export class PolicyEditor extends Component { }); } + resolvePolicy(props) { + const u = '/api/v1/policy/resolve?' + sourceQueryStringParams(props); + + axios.post(u, { + "updates": this.state.policy, + "numUpcomingSnapshotTimes": 5, + }).then(result => { + this.setState({ resolved: result.data }); + }).catch(error => { + }); + } + + PolicyDefinitionPoint(p) { + if (!p) { + return ""; + } + + if (p.userName === this.props.userName && p.host === this.props.host && p.path === this.props.path) { + return "(Defined by this policy)"; + } + + return <>Defined by {PolicyTypeName(p)}; + } + saveChanges(e) { e.preventDefault() @@ -137,7 +248,7 @@ export class PolicyEditor extends Component { } this.setState({ saving: true }); - axios.put(this.snapshotURL(this.props), policy).then(result => { + axios.put(this.policyURL(this.props), policy).then(result => { this.props.close(); }).catch(error => { this.setState({ saving: false }); @@ -149,7 +260,7 @@ export class PolicyEditor extends Component { if (window.confirm('Are you sure you want to delete this policy?')) { this.setState({ saving: true }); - axios.delete(this.snapshotURL(this.props)).then(result => { + axios.delete(this.policyURL(this.props)).then(result => { this.props.close(); }).catch(error => { this.setState({ saving: false }); @@ -158,7 +269,7 @@ export class PolicyEditor extends Component { } } - snapshotURL(props) { + policyURL(props) { return '/api/v1/policy?' + sourceQueryStringParams(props); } @@ -185,83 +296,100 @@ export class PolicyEditor extends Component {  Snapshot Retention + {OptionalNumberField(this, null, "policy.retention.keepLatest", { placeholder: "# of latest snapshots" })} + {EffectiveValue(this, "retention.keepLatest")} {OptionalNumberField(this, null, "policy.retention.keepHourly", { placeholder: "# of hourly snapshots" })} + {EffectiveValue(this, "retention.keepHourly")} {OptionalNumberField(this, null, "policy.retention.keepDaily", { placeholder: "# of daily snapshots" })} + {EffectiveValue(this, "retention.keepDaily")} {OptionalNumberField(this, null, "policy.retention.keepWeekly", { placeholder: "# of weekly snapshots" })} + {EffectiveValue(this, "retention.keepWeekly")} {OptionalNumberField(this, null, "policy.retention.keepMonthly", { placeholder: "# of monthly snapshots" })} + {EffectiveValue(this, "retention.keepMonthly")} {OptionalNumberField(this, null, "policy.retention.keepAnnual", { placeholder: "# of annual snapshots" })} + {EffectiveValue(this, "retention.keepAnnual")}  Files + {StringList(this, "policy.files.ignore")} + {EffectiveTextAreaValue(this, "files.ignore")} - - {RequiredBoolean(this, "", "policy.files.noParentIgnore")} - + {RequiredBoolean(this, "", "policy.files.noParentIgnore")} + {StringList(this, "policy.files.ignoreDotFiles")} + {EffectiveTextAreaValue(this, "files.ignoreDotFiles")} {RequiredBoolean(this, "", "policy.files.noParentDotFiles")} + {OptionalBoolean(this, null, "policy.files.ignoreCacheDirs", "inherit from parent")} + {EffectiveBooleanValue(this, "files.ignoreCacheDirs")} {OptionalBoolean(this, null, "policy.files.oneFileSystem", "inherit from parent")} + {EffectiveBooleanValue(this, "files.oneFileSystem")}  Error Handling + {OptionalBoolean(this, null, "policy.errorHandling.ignoreDirectoryErrors", "inherit from parent")} + {EffectiveBooleanValue(this, "errorHandling.ignoreDirectoryErrors")} {OptionalBoolean(this, null, "policy.errorHandling.ignoreFileErrors", "inherit from parent")} + {EffectiveBooleanValue(this, "errorHandling.ignoreFileErrors")} {OptionalBoolean(this, null, "policy.errorHandling.ignoreUnknownTypes", "inherit from parent")} + {EffectiveBooleanValue(this, "errorHandling.ignoreUnknownTypes")}  Compression + @@ -273,32 +401,38 @@ export class PolicyEditor extends Component { {this.state.algorithms && this.state.algorithms.compression.map(x => )} + {EffectiveValue(this, "compression.compressorName")} {OptionalNumberField(this, "", "policy.compression.minSize", { placeholder: "minimum file size in bytes" })} + {EffectiveValue(this, "compression.minSize")} {OptionalNumberField(this, "", "policy.compression.maxSize", { placeholder: "maximum file size in bytes" })} + {EffectiveValue(this, "compression.maxSize")} {StringList(this, "policy.compression.onlyCompress")} + {EffectiveTextAreaValue(this, "compression.onlyCompress")} {StringList(this, "policy.compression.neverCompress")} + {EffectiveTextAreaValue(this, "compression.neverCompress")}  Scheduling + @@ -317,53 +451,70 @@ export class PolicyEditor extends Component { + {EffectiveValue(this, "scheduling.intervalSeconds")} {OptionalBoolean(this, "", "policy.scheduling.manual", "inherit from parent")} + {EffectiveBooleanValue(this, "scheduling.manual")} + + + + + + + {UpcomingSnapshotTimes(this.state?.resolved?.upcomingSnapshotTimes)} +  Logging + {LogDetailSelector(this, "policy.logging.directories.snapshotted")} + {EffectiveValue(this, "logging.directories.snapshotted")} {LogDetailSelector(this, "policy.logging.directories.ignored")} + {EffectiveValue(this, "logging.directories.ignored")} {LogDetailSelector(this, "policy.logging.entries.snapshotted")} + {EffectiveValue(this, "logging.entries.snapshotted")} {LogDetailSelector(this, "policy.logging.entries.ignored")} + {EffectiveValue(this, "logging.entries.ignored")} {LogDetailSelector(this, "policy.logging.entries.cacheHit")} + {EffectiveValue(this, "logging.entries.cacheHit")} {LogDetailSelector(this, "policy.logging.entries.cacheHit")} + {EffectiveValue(this, "logging.entries.cacheMiss")} @@ -387,7 +538,7 @@ export class PolicyEditor extends Component { - + {!this.state.isNew && <>  } @@ -395,7 +546,8 @@ export class PolicyEditor extends Component {   } - + {/*
{JSON.stringify(this.state, null, 4)}
+                
*/} ; } diff --git a/htmlui/src/PolicyEditor.test.js b/htmlui/src/PolicyEditor.test.js new file mode 100644 index 000000000..09bcbcd12 --- /dev/null +++ b/htmlui/src/PolicyEditor.test.js @@ -0,0 +1,144 @@ +import { render, waitFor, logDOM } from '@testing-library/react'; +import React from 'react'; +import { PolicyEditor } from './PolicyEditor'; +import { MemoryRouter } from 'react-router-dom'; +import { setupAPIMock } from './tests/api_mocks'; +import moment from 'moment'; +import { changeControlValue, simulateClick } from './tests/testutils'; + +it('e2e', async () => { + let ref = React.createRef(); + let serverMock = setupAPIMock(); + + const ust1 = "2021-01-01T12:00:00Z"; + const ust2 = "2021-01-01T13:00:00Z"; + + // first request from client - will fetch current policy + serverMock.onGet('/api/v1/policy?userName=some-user&host=some-host&path=some-path').reply(200, + { + "retention": { + "keepLatest": 33, + } + } + ); + + // second request to resolve the policy. + serverMock.onPost('/api/v1/policy/resolve?userName=some-user&host=some-host&path=some-path', { + "updates": { + "retention": { + "keepLatest": 33 + } + }, + "numUpcomingSnapshotTimes": 5 + }).reply(200, + { + "effective": { + "retention": { + "keepLatest": 33, + "keepHourly": 45, + } + }, + "definition": { + "retention": { + "keepLatest": { "host": "h1", "userName": "some-user", "path": "some-path" }, + "keepMonthly": { "host": "h1", "userName": "u1", "path": "p1" } + } + }, + "upcomingSnapshotTimes": [ + ust1, + ust2, + ] + } + ); + + // second request to resolve the policy. + serverMock.onPost('/api/v1/policy/resolve?userName=some-user&host=some-host&path=some-path', { + "updates": { + "retention": { + "keepLatest": 44 + } + }, + "numUpcomingSnapshotTimes": 5 + }).reply(200, + { + "effective": { + "retention": { + "keepLatest": 44, + "keepHourly": 45, + } + }, + "definition": { + "retention": { + "keepLatest": { "host": "some-host", "userName": "some-user", "path": "some-path" }, + "keepMonthly": { "host": "h1", "userName": "u1", "path": "p1" } + } + }, + "upcomingSnapshotTimes": [ + ust1, + ust2, + ] + } + ); + + // request to save updated policy. + serverMock.onPut('/api/v1/policy?userName=some-user&host=some-host&path=some-path', { + "retention": { + "keepLatest": 44 + } + }).reply(200, + { + "effective": { + "retention": { + "keepLatest": 33, + "keepHourly": 45, + } + }, + "definition": { + "retention": { + "keepLatest": { "host": "h1", "userName": "some-user", "path": "some-path" }, + "keepMonthly": { "host": "h1", "userName": "u1", "path": "p1" } + } + }, + "upcomingSnapshotTimes": [ + ust1, + ust2, + ] + } + ); + + let closeCalled = 0; + + function closeFunc() { + closeCalled++; + } + + const { getByTestId } = render( + + ) + + await waitFor(() => expect(getByTestId("effective-retention.keepHourly").value).toBe("45")); + expect(getByTestId("effective-retention.keepLatest").value).toEqual("33"); + if (false) { + logDOM(getByTestId("definition-retention.keepHourly")); + } + await waitFor(() => expect(getByTestId("definition-retention.keepLatest").innerHTML).toEqual("Defined by Directory: some-user@h1:some-path")); + await waitFor(() => expect(getByTestId("definition-retention.keepMonthly").innerHTML).toEqual("Defined by Directory: u1@h1:p1")); + + const expectedUpcomingSnapshotTimes = [ + `
  • ${moment(ust1).format('L LT')} (${moment(ust1).fromNow()})
  • `, + `
  • ${moment(ust2).format('L LT')} (${moment(ust2).fromNow()})
  • `, + ].join(""); + + await waitFor(() => expect(getByTestId("upcoming-snapshot-times").innerHTML).toEqual(expectedUpcomingSnapshotTimes)); + + // change a field + changeControlValue(getByTestId('control-policy.retention.keepLatest'), "44"); + + // this will trigger resolve and will update effective field: "(Defined by this policy)" + await waitFor(() => expect(getByTestId("effective-retention.keepLatest").value).toBe("44")); + await waitFor(() => expect(getByTestId("definition-retention.keepLatest").innerHTML).toEqual("(Defined by this policy)")); + + simulateClick(getByTestId('button-save')); + await waitFor(() => expect(serverMock.history.put.length).toEqual(1)); + await waitFor(() => expect(closeCalled).toEqual(1)); +}); diff --git a/internal/server/api_policies.go b/internal/server/api_policies.go index 64303df00..9cc124aa4 100644 --- a/internal/server/api_policies.go +++ b/internal/server/api_policies.go @@ -5,9 +5,11 @@ import ( "encoding/json" "net/http" "net/url" + "time" "github.com/pkg/errors" + "github.com/kopia/kopia/internal/clock" "github.com/kopia/kopia/internal/serverapi" "github.com/kopia/kopia/repo" "github.com/kopia/kopia/snapshot" @@ -61,6 +63,48 @@ func (s *Server) handlePolicyGet(ctx context.Context, r *http.Request, body []by return pol, nil } +func (s *Server) handlePolicyResolve(ctx context.Context, r *http.Request, body []byte) (interface{}, *apiError) { + var req serverapi.ResolvePolicyRequest + + if err := json.Unmarshal(body, &req); err != nil { + return nil, requestError(serverapi.ErrorMalformedRequest, "unable to decode request: "+err.Error()) + } + + target := getPolicyTargetFromURL(r.URL) + + // build a list of parents + policies, err := policy.GetPolicyHierarchy(ctx, s.rep, target) + if err != nil { + return nil, internalServerError(err) + } + + resp := &serverapi.ResolvePolicyResponse{ + Defined: policies[0], + } + + if req.Updates != nil { + policies[0] = req.Updates + policies[0].Labels = policy.LabelsForSource(target) + } + + resp.Effective, resp.Definition = policy.MergePolicies(policies, target) + resp.UpcomingSnapshotTimes = []time.Time{} + + now := clock.Now().Local() + + for i := 0; i < req.NumUpcomingSnapshotTimes; i++ { + st, ok := resp.Effective.SchedulingPolicy.NextSnapshotTime(now, now) + if !ok { + break + } + + resp.UpcomingSnapshotTimes = append(resp.UpcomingSnapshotTimes, st) + now = st.Add(1 * time.Second) + } + + return resp, nil +} + func (s *Server) handlePolicyDelete(ctx context.Context, r *http.Request, body []byte) (interface{}, *apiError) { w, ok := s.rep.(repo.RepositoryWriter) if !ok { diff --git a/internal/server/api_policies_test.go b/internal/server/api_policies_test.go new file mode 100644 index 000000000..d745a3bcd --- /dev/null +++ b/internal/server/api_policies_test.go @@ -0,0 +1,164 @@ +package server_test + +import ( + "fmt" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/kopia/kopia/internal/apiclient" + "github.com/kopia/kopia/internal/repotesting" + "github.com/kopia/kopia/internal/serverapi" + "github.com/kopia/kopia/internal/testutil" + "github.com/kopia/kopia/repo/compression" + "github.com/kopia/kopia/snapshot" + "github.com/kopia/kopia/snapshot/policy" +) + +func TestPolicies(t *testing.T) { + ctx, env := repotesting.NewEnvironment(t, repotesting.FormatNotImportant) + srvInfo := startServer(t, env, false) + + cli, err := apiclient.NewKopiaAPIClient(apiclient.Options{ + BaseURL: srvInfo.BaseURL, + TrustedServerCertificateFingerprint: srvInfo.TrustedServerCertificateFingerprint, + Username: testUIUsername, + Password: testUIPassword, + }) + + require.NoError(t, err) + + dir0 := testutil.TempDirectory(t) + si0 := localSource(env, dir0) + + dir1 := testutil.TempDirectory(t) + si1 := localSource(env, dir1) + + dir2 := filepath.Join(dir1, "subdir1") + si2 := localSource(env, dir2) + + dir3 := filepath.Join(dir2, "subdir2") + si3 := localSource(env, dir3) + + dir4 := filepath.Join(dir3, "subdir3") + si4 := localSource(env, dir4) + + mustSetPolicy(t, cli, si1, &policy.Policy{ + CompressionPolicy: policy.CompressionPolicy{ + CompressorName: "some-compressor", + }, + }) + + mustSetPolicy(t, cli, si2, &policy.Policy{ + CompressionPolicy: policy.CompressionPolicy{ + NeverCompress: []string{"a", "b"}, + }, + SchedulingPolicy: policy.SchedulingPolicy{ + IntervalSeconds: 60, + }, + }) + + mustSetPolicy(t, cli, si3, &policy.Policy{ + CompressionPolicy: policy.CompressionPolicy{ + CompressorName: "some-compressor3", + }, + }) + + cases := []struct { + si snapshot.SourceInfo + updates *policy.Policy + wantCompressorName compression.Name + wantCompressorNameSource snapshot.SourceInfo + wantNeverCompress []string + wantNeverCompressSource snapshot.SourceInfo + wantUpcomingSnapshotTimesLength int + }{ + { + si: si0, + wantCompressorName: compression.Name("none"), + wantNeverCompress: nil, + wantCompressorNameSource: policy.GlobalPolicySourceInfo, + wantNeverCompressSource: policy.GlobalPolicySourceInfo, + }, + { + si: si1, + wantCompressorName: compression.Name("some-compressor"), + wantNeverCompress: nil, + wantCompressorNameSource: si1, + wantNeverCompressSource: policy.GlobalPolicySourceInfo, + }, + { + si: si1, + wantCompressorName: compression.Name("some-compressor-updated"), + wantNeverCompress: nil, + wantCompressorNameSource: si1, + wantNeverCompressSource: policy.GlobalPolicySourceInfo, + updates: &policy.Policy{ + CompressionPolicy: policy.CompressionPolicy{ + CompressorName: "some-compressor-updated", + }, + }, + }, + { + si: si2, + wantCompressorName: compression.Name("some-compressor"), // inherited + wantNeverCompress: []string{"a", "b"}, + wantCompressorNameSource: si1, + wantNeverCompressSource: si2, + wantUpcomingSnapshotTimesLength: 3, + }, + { + si: si3, + wantCompressorName: compression.Name("some-compressor3"), + wantNeverCompress: []string{"a", "b"}, + wantCompressorNameSource: si3, + wantNeverCompressSource: si2, + wantUpcomingSnapshotTimesLength: 3, + }, + { + si: si4, + wantCompressorName: compression.Name("some-compressor3"), + wantNeverCompress: []string{"a", "b"}, + wantCompressorNameSource: si3, + wantNeverCompressSource: si2, + wantUpcomingSnapshotTimesLength: 3, + }, + { + si: si4, + wantCompressorName: compression.Name("some-compressor-updated"), + wantNeverCompress: []string{"a", "b"}, + wantCompressorNameSource: si4, + wantNeverCompressSource: si2, + updates: &policy.Policy{ + CompressionPolicy: policy.CompressionPolicy{ + CompressorName: "some-compressor-updated", + }, + }, + wantUpcomingSnapshotTimesLength: 3, + }, + } + + for i, tc := range cases { + t.Run(fmt.Sprintf("case-%v", i), func(t *testing.T) { + res, err2 := serverapi.ResolvePolicy(ctx, cli, tc.si, &serverapi.ResolvePolicyRequest{ + Updates: tc.updates, + NumUpcomingSnapshotTimes: 3, + }) + require.NoError(t, err2) + require.Equal(t, tc.wantCompressorName, res.Effective.CompressionPolicy.CompressorName) + require.Equal(t, tc.wantNeverCompress, res.Effective.CompressionPolicy.NeverCompress) + require.Equal(t, tc.wantCompressorNameSource, res.Definition.CompressionPolicy.CompressorName) + require.Equal(t, tc.wantNeverCompressSource, res.Definition.CompressionPolicy.NeverCompress) + require.Len(t, res.UpcomingSnapshotTimes, tc.wantUpcomingSnapshotTimesLength) + + for j, ust := range res.UpcomingSnapshotTimes { + require.Equal(t, ust.Truncate(60*time.Second), ust) + if j > 0 { + require.Equal(t, 60*time.Second, ust.Sub(res.UpcomingSnapshotTimes[j-1])) + } + } + }) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 25c018b94..c6c06a2fe 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -85,6 +85,7 @@ func (s *Server) APIHandlers(legacyAPI bool) http.Handler { m.HandleFunc("/api/v1/policy", s.handleAPI(requireUIUser, s.handlePolicyGet)).Methods(http.MethodGet) m.HandleFunc("/api/v1/policy", s.handleAPI(requireUIUser, s.handlePolicyPut)).Methods(http.MethodPut) m.HandleFunc("/api/v1/policy", s.handleAPI(requireUIUser, s.handlePolicyDelete)).Methods(http.MethodDelete) + m.HandleFunc("/api/v1/policy/resolve", s.handleAPI(requireUIUser, s.handlePolicyResolve)).Methods(http.MethodPost) m.HandleFunc("/api/v1/policies", s.handleAPI(requireUIUser, s.handlePolicyList)).Methods(http.MethodGet) diff --git a/internal/serverapi/client_wrappers.go b/internal/serverapi/client_wrappers.go index 3d62322d5..bf9990030 100644 --- a/internal/serverapi/client_wrappers.go +++ b/internal/serverapi/client_wrappers.go @@ -162,6 +162,17 @@ func SetPolicy(ctx context.Context, c *apiclient.KopiaAPIClient, si snapshot.Sou return nil } +// ResolvePolicy resolves the policy. +func ResolvePolicy(ctx context.Context, c *apiclient.KopiaAPIClient, si snapshot.SourceInfo, req *ResolvePolicyRequest) (*ResolvePolicyResponse, error) { + resp := &ResolvePolicyResponse{} + + if err := c.Post(ctx, "policy/resolve?"+policyTargetURLParamters(si), req, resp); err != nil { + return nil, errors.Wrap(err, "ResolvePolicy") + } + + return resp, nil +} + // ListTasks lists the tasks. func ListTasks(ctx context.Context, c *apiclient.KopiaAPIClient) (*TaskListResponse, error) { resp := &TaskListResponse{} diff --git a/internal/serverapi/serverapi.go b/internal/serverapi/serverapi.go index ae08c6cbf..d7e8822ac 100644 --- a/internal/serverapi/serverapi.go +++ b/internal/serverapi/serverapi.go @@ -220,3 +220,17 @@ type EstimateRequest struct { Root string `json:"root"` MaxExamplesPerBucket int `json:"maxExamplesPerBucket"` } + +// ResolvePolicyRequest contains request structure to ResolvePolicy. +type ResolvePolicyRequest struct { + Updates *policy.Policy `json:"updates"` + NumUpcomingSnapshotTimes int `json:"numUpcomingSnapshotTimes"` // if > 0, return N UpcomingSnapshotTimes +} + +// ResolvePolicyResponse returns the resolved details about a single policy. +type ResolvePolicyResponse struct { + Effective *policy.Policy `json:"effective"` + Definition *policy.Definition `json:"definition"` + Defined *policy.Policy `json:"defined"` + UpcomingSnapshotTimes []time.Time `json:"upcomingSnapshotTimes"` +} diff --git a/snapshot/policy/policy_manager.go b/snapshot/policy/policy_manager.go index dc443d72b..c6353807a 100644 --- a/snapshot/policy/policy_manager.go +++ b/snapshot/policy/policy_manager.go @@ -41,13 +41,25 @@ var log = logging.Module("kopia/snapshot/policy") // with parent policies. The source must contain a path. // Returns the effective policies and all source policies that contributed to that (most specific first). func GetEffectivePolicy(ctx context.Context, rep repo.Repository, si snapshot.SourceInfo) (effective *Policy, definition *Definition, sources []*Policy, e error) { + policies, err := GetPolicyHierarchy(ctx, rep, si) + if err != nil { + return nil, nil, nil, errors.Wrap(err, "unable to get parent policies") + } + + merged, def := MergePolicies(policies, si) + + return merged, def, policies, nil +} + +// GetPolicyHierarchy returns the set of parent policies that apply to the path in most-specific-to-most-general order. +func GetPolicyHierarchy(ctx context.Context, rep repo.Repository, si snapshot.SourceInfo) ([]*Policy, error) { var md []*manifest.EntryMetadata // Find policies applying to paths all the way up to the root. for tmp := si; len(si.Path) > 0; { - manifests, err := rep.FindManifests(ctx, labelsForSource(tmp)) + manifests, err := rep.FindManifests(ctx, LabelsForSource(tmp)) if err != nil { - return nil, nil, nil, errors.Wrapf(err, "unable to find manifest for source %v", tmp) + return nil, errors.Wrapf(err, "unable to find manifest for source %v", tmp) } md = append(md, manifests...) @@ -61,25 +73,25 @@ func GetEffectivePolicy(ctx context.Context, rep repo.Repository, si snapshot.So } // Try user@host policy - userHostManifests, err := rep.FindManifests(ctx, labelsForSource(snapshot.SourceInfo{Host: si.Host, UserName: si.UserName})) + userHostManifests, err := rep.FindManifests(ctx, LabelsForSource(snapshot.SourceInfo{Host: si.Host, UserName: si.UserName})) if err != nil { - return nil, nil, nil, errors.Wrap(err, "unable to find user@host manifest") + return nil, errors.Wrap(err, "unable to find user@host manifest") } md = append(md, userHostManifests...) // Try host-level policy. - hostManifests, err := rep.FindManifests(ctx, labelsForSource(snapshot.SourceInfo{Host: si.Host})) + hostManifests, err := rep.FindManifests(ctx, LabelsForSource(snapshot.SourceInfo{Host: si.Host})) if err != nil { - return nil, nil, nil, errors.Wrap(err, "unable to find host-level manifest") + return nil, errors.Wrap(err, "unable to find host-level manifest") } md = append(md, hostManifests...) // Global policy. - globalManifests, err := rep.FindManifests(ctx, labelsForSource(GlobalPolicySourceInfo)) + globalManifests, err := rep.FindManifests(ctx, LabelsForSource(GlobalPolicySourceInfo)) if err != nil { - return nil, nil, nil, errors.Wrap(err, "unable to find global manifest") + return nil, errors.Wrap(err, "unable to find global manifest") } md = append(md, globalManifests...) @@ -89,21 +101,23 @@ func GetEffectivePolicy(ctx context.Context, rep repo.Repository, si snapshot.So for _, em := range md { p := &Policy{} if err := loadPolicyFromManifest(ctx, rep, em.ID, p); err != nil { - return nil, nil, nil, errors.Wrapf(err, "got unexpected error when loading policy item %v", em.ID) + return nil, errors.Wrapf(err, "got unexpected error when loading policy item %v", em.ID) } policies = append(policies, p) } - merged, def := MergePolicies(policies) - merged.Labels = labelsForSource(si) + // add artificial empty policy for the source. + if len(policies) == 0 || policies[0].Target() != si { + policies = append([]*Policy{{Labels: LabelsForSource(si)}}, policies...) + } - return merged, def, policies, nil + return policies, nil } // GetDefinedPolicy returns the policy defined on the provided snapshot.SourceInfo or ErrPolicyNotFound if not present. func GetDefinedPolicy(ctx context.Context, rep repo.Repository, si snapshot.SourceInfo) (*Policy, error) { - md, err := rep.FindManifests(ctx, labelsForSource(si)) + md, err := rep.FindManifests(ctx, LabelsForSource(si)) if err != nil { return nil, errors.Wrap(err, "unable to find policy for source") } @@ -140,12 +154,12 @@ func SetPolicy(ctx context.Context, rep repo.RepositoryWriter, si snapshot.Sourc } } - md, err := rep.FindManifests(ctx, labelsForSource(si)) + md, err := rep.FindManifests(ctx, LabelsForSource(si)) if err != nil { return errors.Wrapf(err, "unable to load manifests for %v", si) } - if _, err := rep.PutManifest(ctx, labelsForSource(si), pol); err != nil { + if _, err := rep.PutManifest(ctx, LabelsForSource(si), pol); err != nil { return errors.Wrap(err, "error writing policy manifest") } @@ -160,7 +174,7 @@ func SetPolicy(ctx context.Context, rep repo.RepositoryWriter, si snapshot.Sourc // RemovePolicy removes the policy for a given source. func RemovePolicy(ctx context.Context, rep repo.RepositoryWriter, si snapshot.SourceInfo) error { - md, err := rep.FindManifests(ctx, labelsForSource(si)) + md, err := rep.FindManifests(ctx, LabelsForSource(si)) if err != nil { return errors.Wrapf(err, "unable to load manifests for %v", si) } @@ -290,7 +304,8 @@ func loadPolicyFromManifest(ctx context.Context, rep repo.Repository, id manifes return nil } -func labelsForSource(si snapshot.SourceInfo) map[string]string { +// LabelsForSource returns the set of labels that a given source will have on a policy. +func LabelsForSource(si snapshot.SourceInfo) map[string]string { switch { case si.Path != "": return map[string]string{ diff --git a/snapshot/policy/policy_manager_test.go b/snapshot/policy/policy_manager_test.go index fa4bb106c..b5a1779e5 100644 --- a/snapshot/policy/policy_manager_test.go +++ b/snapshot/policy/policy_manager_test.go @@ -55,6 +55,9 @@ func TestPolicyManagerInheritanceTest(t *testing.T) { { sourceInfo: GlobalPolicySourceInfo, wantEffective: defaultPolicyWithLabels, + wantSources: []snapshot.SourceInfo{ + GlobalPolicySourceInfo, + }, }, { sourceInfo: snapshot.SourceInfo{ @@ -69,6 +72,13 @@ func TestPolicyManagerInheritanceTest(t *testing.T) { "path": "/some/path", "username": "myuser", }), + wantSources: []snapshot.SourceInfo{ + { + UserName: "myuser", + Host: "host-c", + Path: "/some/path", + }, + }, }, { sourceInfo: snapshot.SourceInfo{ @@ -84,6 +94,7 @@ func TestPolicyManagerInheritanceTest(t *testing.T) { "username": "myuser", }), wantSources: []snapshot.SourceInfo{ + {UserName: "myuser", Host: "host-a", Path: "/some/path"}, {Host: "host-a"}, }, wantDef: Definition{ @@ -106,6 +117,7 @@ func TestPolicyManagerInheritanceTest(t *testing.T) { "username": "myuser", }), wantSources: []snapshot.SourceInfo{ + {UserName: "myuser", Path: "/some/path2/nested", Host: "host-a"}, {UserName: "myuser", Path: "/some/path2", Host: "host-a"}, {Host: "host-a"}, }, diff --git a/snapshot/policy/policy_merge.go b/snapshot/policy/policy_merge.go index 7a4510105..0463ff43d 100644 --- a/snapshot/policy/policy_merge.go +++ b/snapshot/policy/policy_merge.go @@ -9,12 +9,14 @@ import ( // MergePolicies computes the policy by applying the specified list of policies in order from most // specific to most general. -func MergePolicies(policies []*Policy) (*Policy, *Definition) { +func MergePolicies(policies []*Policy, si snapshot.SourceInfo) (*Policy, *Definition) { var ( merged Policy def Definition ) + merged.Labels = LabelsForSource(si) + for _, p := range policies { merged.RetentionPolicy.Merge(p.RetentionPolicy, &def.RetentionPolicy, p.Target()) merged.FilesPolicy.Merge(p.FilesPolicy, &def.FilesPolicy, p.Target()) diff --git a/snapshot/policy/policy_merge_test.go b/snapshot/policy/policy_merge_test.go index e655a475e..9220164c3 100644 --- a/snapshot/policy/policy_merge_test.go +++ b/snapshot/policy/policy_merge_test.go @@ -168,16 +168,16 @@ func testPolicyMergeSingleField(t *testing.T, fieldName string, typ reflect.Type disableParentMerging(defaultPolicyMod) // merging default policy with no-op policy results in default policy - result, _ := policy.MergePolicies([]*policy.Policy{pol0, defaultPolicyMod}) + result, _ := policy.MergePolicies([]*policy.Policy{pol0, defaultPolicyMod}, pol0.Target()) require.Equal(t, defaultPolicyMod.String(), result.String()) - result, _ = policy.MergePolicies([]*policy.Policy{pol1, pol0, defaultPolicyMod}) + result, _ = policy.MergePolicies([]*policy.Policy{pol1, pol0, defaultPolicyMod}, pol1.Target()) require.Equal(t, pol1.String(), result.String()) - result, _ = policy.MergePolicies([]*policy.Policy{pol2, pol1, pol0, defaultPolicyMod}) + result, _ = policy.MergePolicies([]*policy.Policy{pol2, pol1, pol0, defaultPolicyMod}, pol2.Target()) require.Equal(t, pol2.String(), result.String()) - result, _ = policy.MergePolicies([]*policy.Policy{pol2, pol0, defaultPolicyMod}) + result, _ = policy.MergePolicies([]*policy.Policy{pol2, pol0, defaultPolicyMod}, pol2.Target()) require.Equal(t, pol2.String(), result.String()) } @@ -225,7 +225,7 @@ func TestPolicyMergeOnlyCompressIncludingParents(t *testing.T) { }, } - result, _ := policy.MergePolicies([]*policy.Policy{p0, p1, p2}) + result, _ := policy.MergePolicies([]*policy.Policy{p0, p1, p2}, p0.Target()) want := *policy.DefaultPolicy want.CompressionPolicy.OnlyCompress = []string{"a", "b", "c", "d", "e", "f", "g"} @@ -233,7 +233,7 @@ func TestPolicyMergeOnlyCompressIncludingParents(t *testing.T) { require.Equal(t, want.String(), result.String()) p2.NoParent = true - result, _ = policy.MergePolicies([]*policy.Policy{p0, p1, p2}) + result, _ = policy.MergePolicies([]*policy.Policy{p0, p1, p2}, p0.Target()) want = policy.Policy{} want.CompressionPolicy.OnlyCompress = []string{"a", "b", "c", "d", "e", "f", "g"} @@ -241,7 +241,7 @@ func TestPolicyMergeOnlyCompressIncludingParents(t *testing.T) { require.Equal(t, want.String(), result.String()) p1.NoParent = true - result, _ = policy.MergePolicies([]*policy.Policy{p0, p1, p2}) + result, _ = policy.MergePolicies([]*policy.Policy{p0, p1, p2}, p0.Target()) want = policy.Policy{} want.CompressionPolicy.OnlyCompress = []string{"a", "b", "c", "d", "e"} @@ -249,7 +249,7 @@ func TestPolicyMergeOnlyCompressIncludingParents(t *testing.T) { require.Equal(t, want.String(), result.String()) p0.NoParent = true - result, _ = policy.MergePolicies([]*policy.Policy{p0, p1, p2}) + result, _ = policy.MergePolicies([]*policy.Policy{p0, p1, p2}, p0.Target()) want = policy.Policy{} want.CompressionPolicy.OnlyCompress = []string{"a", "c", "e"} @@ -284,7 +284,7 @@ func TestPolicyMergeTimesOfDayIncludingParents(t *testing.T) { }, } - result, _ := policy.MergePolicies([]*policy.Policy{p0, p1, p2}) + result, _ := policy.MergePolicies([]*policy.Policy{p0, p1, p2}, p0.Target()) want := *policy.DefaultPolicy want.SchedulingPolicy.TimesOfDay = []policy.TimeOfDay{tod0, tod1, tod2, tod3, tod4, tod5, tod6} @@ -292,7 +292,7 @@ func TestPolicyMergeTimesOfDayIncludingParents(t *testing.T) { require.Equal(t, want.String(), result.String()) p2.NoParent = true - result, _ = policy.MergePolicies([]*policy.Policy{p0, p1, p2}) + result, _ = policy.MergePolicies([]*policy.Policy{p0, p1, p2}, p0.Target()) want = policy.Policy{} want.SchedulingPolicy.TimesOfDay = []policy.TimeOfDay{tod0, tod1, tod2, tod3, tod4, tod5, tod6} @@ -300,7 +300,7 @@ func TestPolicyMergeTimesOfDayIncludingParents(t *testing.T) { require.Equal(t, want.String(), result.String()) p1.NoParent = true - result, _ = policy.MergePolicies([]*policy.Policy{p0, p1, p2}) + result, _ = policy.MergePolicies([]*policy.Policy{p0, p1, p2}, p0.Target()) want = policy.Policy{} want.SchedulingPolicy.TimesOfDay = []policy.TimeOfDay{tod0, tod1, tod2, tod3, tod4} @@ -308,7 +308,7 @@ func TestPolicyMergeTimesOfDayIncludingParents(t *testing.T) { require.Equal(t, want.String(), result.String()) p0.NoParent = true - result, _ = policy.MergePolicies([]*policy.Policy{p0, p1, p2}) + result, _ = policy.MergePolicies([]*policy.Policy{p0, p1, p2}, p0.Target()) want = policy.Policy{} want.SchedulingPolicy.TimesOfDay = []policy.TimeOfDay{tod0, tod2, tod4} diff --git a/snapshot/policy/scheduling_policy.go b/snapshot/policy/scheduling_policy.go index 0daf82251..603481e1d 100644 --- a/snapshot/policy/scheduling_policy.go +++ b/snapshot/policy/scheduling_policy.go @@ -88,6 +88,9 @@ func (p *SchedulingPolicy) NextSnapshotTime(previousSnapshotTime, now time.Time) ok bool ) + now = now.Local() + previousSnapshotTime = previousSnapshotTime.Local() + // compute next snapshot time based on interval if interval := p.IntervalSeconds; interval != 0 { interval := time.Duration(interval) * time.Second diff --git a/snapshot/policy/scheduling_policy_test.go b/snapshot/policy/scheduling_policy_test.go index 14c888cc2..c3ec33390 100644 --- a/snapshot/policy/scheduling_policy_test.go +++ b/snapshot/policy/scheduling_policy_test.go @@ -1,6 +1,7 @@ package policy_test import ( + "fmt" "testing" "time" @@ -151,12 +152,24 @@ func TestNextSnapshotTime(t *testing.T) { wantTime: time.Date(2020, time.January, 2, 11, 54, 0, 0, time.Local), wantOK: true, }, + { + pol: policy.SchedulingPolicy{ + IntervalSeconds: 43200, + TimesOfDay: []policy.TimeOfDay{{19, 0}, {20, 0}}, + }, + previousSnapshotTime: time.Date(2020, time.January, 1, 19, 0, 0, 0, time.Local), + now: time.Date(2020, time.January, 1, 10, 0, 0, 0, time.Local), + wantTime: time.Date(2020, time.January, 1, 19, 0, 0, 0, time.Local), + wantOK: true, + }, } - for _, tc := range cases { - gotTime, gotOK := tc.pol.NextSnapshotTime(tc.previousSnapshotTime, tc.now) + for i, tc := range cases { + t.Run(fmt.Sprintf("case-%v", i), func(t *testing.T) { + gotTime, gotOK := tc.pol.NextSnapshotTime(tc.previousSnapshotTime, tc.now) - require.Equal(t, tc.wantTime, gotTime) - require.Equal(t, tc.wantOK, gotOK) + require.Equal(t, tc.wantTime, gotTime) + require.Equal(t, tc.wantOK, gotOK) + }) } }