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 <>
+
+
+
+ {times.map(x => - {moment(x).format('L LT')} ({moment(x).fromNow()})
)}
+
+ >;
+}
+
+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)
+ })
}
}